diff --git a/src/plugin-sdk/provider-stream-shared.test.ts b/src/plugin-sdk/provider-stream-shared.test.ts index f10493e69f51..dbf18465e074 100644 --- a/src/plugin-sdk/provider-stream-shared.test.ts +++ b/src/plugin-sdk/provider-stream-shared.test.ts @@ -93,8 +93,9 @@ function createByteOverCapZeroArgumentXmlCall(name: string): string { return `${"\u00a0".repeat(128_001)}`; } -async function collectPlainTextToolCallCompatEvents(events: unknown[]): Promise { - const baseStreamFn: StreamFn = () => createEventStream(events); +async function collectPlainTextToolCallCompatEventsFromStream( + baseStreamFn: StreamFn, +): Promise { const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); const stream = await resolveStream( wrapped({} as never, { tools: [{ name: "read" }] } as never, {}), @@ -106,6 +107,10 @@ async function collectPlainTextToolCallCompatEvents(events: unknown[]): Promise< return output; } +async function collectPlainTextToolCallCompatEvents(events: unknown[]): Promise { + return collectPlainTextToolCallCompatEventsFromStream(() => createEventStream(events)); +} + async function resolveStream(stream: ReturnType) { return stream instanceof Promise ? await stream : stream; } @@ -383,16 +388,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "start", @@ -426,16 +422,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual(["done"]); const done = events.at(-1) as { @@ -459,16 +446,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -778,57 +756,29 @@ describe("createPlainTextToolCallCompatWrapper", () => { expect(requireRecord(events[2], "second text").delta).toBe(secondText); }); - it("keeps CR-separated bracketed tool calls buffered for conversion", async () => { + it.each([ + { + name: "CR-separated bracketed tool calls", + rawToolText: '[read]\r{"path":"src/index.ts"}\r[END_TOOL_REQUEST]', + }, + { + name: "bracketed XML parameter tool calls", + rawToolText: [ + "[tool:read]", + "", + "src/index.ts", + "", + "", + ].join("\n"), + }, + ])("keeps $name buffered for conversion", async ({ name, rawToolText }) => { const { source, stream } = createControlledPlainTextToolCallCompatStream(); const iterator = (await resolveStream(stream))[Symbol.asyncIterator](); try { source.push({ type: "start", partial: { content: [] } } as never); expect((await nextEvent(iterator, "start")).type).toBe("start"); - - source.push({ - type: "text_delta", - contentIndex: 0, - delta: '[read]\r{"path":"src/index.ts"}\r[END_TOOL_REQUEST]', - } as never); - source.push({ - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [{ type: "text", text: '[read]\r{"path":"src/index.ts"}\r[END_TOOL_REQUEST]' }], - stopReason: "stop", - }, - } as never); - - const event = await nextEvent(iterator, "converted CR tool call"); - expect(event.type).toBe("toolcall_start"); - } finally { - source.end(); - await iterator.return?.(); - } - }); - - it("keeps bracketed XML parameter tool calls buffered for conversion", async () => { - const { source, stream } = createControlledPlainTextToolCallCompatStream(); - const iterator = (await resolveStream(stream))[Symbol.asyncIterator](); - const rawToolText = [ - "[tool:read]", - "", - "src/index.ts", - "", - "", - ].join("\n"); - - try { - source.push({ type: "start", partial: { content: [] } } as never); - expect((await nextEvent(iterator, "start")).type).toBe("start"); - - source.push({ - type: "text_delta", - contentIndex: 0, - delta: rawToolText, - } as never); + source.push({ type: "text_delta", contentIndex: 0, delta: rawToolText } as never); source.push({ type: "done", reason: "stop", @@ -839,8 +789,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, } as never); - const event = await nextEvent(iterator, "converted bracketed XML tool call"); - expect(event.type).toBe("toolcall_start"); + expect((await nextEvent(iterator, `converted ${name}`)).type).toBe("toolcall_start"); } finally { source.end(); await iterator.return?.(); @@ -919,16 +868,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "start", @@ -968,16 +908,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => requireRecord(event, "event").type)).toEqual([ "text_delta", @@ -993,60 +924,54 @@ describe("createPlainTextToolCallCompatWrapper", () => { expect(JSON.stringify(events)).not.toContain(marker); }); - it("keeps a byte-over-cap visible suffix at its streamed content index in done messages", async () => { - const marker = ""; - const visibleText = "Visible answer"; - const firstChunk = `${marker}${"\u00a0".repeat(100_000)}`; - const secondChunk = `${"\u00a0".repeat(28_001)}\n${visibleText}`; - const content = [ - { type: "text", text: firstChunk }, - { type: "thinking", thinking: "checking" }, - { type: "text", text: secondChunk }, - ]; - const baseStreamFn: StreamFn = () => - createEventStream([ - { type: "text_delta", contentIndex: 0, delta: firstChunk }, - { - type: "text_delta", - contentIndex: 2, - delta: secondChunk, - partial: { role: "assistant", content }, - }, - { - type: "done", - reason: "stop", - message: { role: "assistant", content, stopReason: "stop" }, - }, + it.each(["first pass", "repeated pass"])( + "keeps a byte-over-cap visible suffix at its streamed content index in done messages (%s)", + async () => { + const marker = ""; + const visibleText = "Visible answer"; + const firstChunk = `${marker}${"\u00a0".repeat(100_000)}`; + const secondChunk = `${"\u00a0".repeat(28_001)}\n${visibleText}`; + const content = [ + { type: "text", text: firstChunk }, + { type: "thinking", thinking: "checking" }, + { type: "text", text: secondChunk }, + ]; + const baseStreamFn: StreamFn = () => + createEventStream([ + { type: "text_delta", contentIndex: 0, delta: firstChunk }, + { + type: "text_delta", + contentIndex: 2, + delta: secondChunk, + partial: { role: "assistant", content }, + }, + { + type: "done", + reason: "stop", + message: { role: "assistant", content, stopReason: "stop" }, + }, + ]); + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); + + expect(events.map((event) => requireRecord(event, "event").type)).toEqual([ + "text_delta", + "done", ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(events.map((event) => requireRecord(event, "event").type)).toEqual([ - "text_delta", - "done", - ]); - const expectedContent = [ - { type: "text", text: "" }, - { type: "thinking", thinking: "checking" }, - { type: "text", text: visibleText }, - ]; - expect(requireRecord(events[0], "text event")).toMatchObject({ - delta: visibleText, - partial: { content: expectedContent }, - }); - expect(requireRecord(events[1], "done event").message).toMatchObject({ - content: expectedContent, - }); - expect(JSON.stringify(events)).not.toContain(marker); - }); + const expectedContent = [ + { type: "text", text: "" }, + { type: "thinking", thinking: "checking" }, + { type: "text", text: visibleText }, + ]; + expect(requireRecord(events[0], "text event")).toMatchObject({ + delta: visibleText, + partial: { content: expectedContent }, + }); + expect(requireRecord(events[1], "done event").message).toMatchObject({ + content: expectedContent, + }); + expect(JSON.stringify(events)).not.toContain(marker); + }, + ); it("scrubs earlier partial blocks when a later block completes a byte-over-cap XML prefix", async () => { const marker = ""; @@ -1069,16 +994,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => requireRecord(event, "event").type)).toEqual(["text_delta"]); expect(requireRecord(events[0], "text event")).toMatchObject({ @@ -1093,61 +1009,6 @@ describe("createPlainTextToolCallCompatWrapper", () => { expect(JSON.stringify(events)).not.toContain(marker); }); - it("keeps a byte-over-cap visible suffix at its streamed content index in done messages", async () => { - const marker = ""; - const visibleText = "Visible answer"; - const firstChunk = `${marker}${"\u00a0".repeat(100_000)}`; - const secondChunk = `${"\u00a0".repeat(28_001)}\n${visibleText}`; - const content = [ - { type: "text", text: firstChunk }, - { type: "thinking", thinking: "checking" }, - { type: "text", text: secondChunk }, - ]; - const baseStreamFn: StreamFn = () => - createEventStream([ - { type: "text_delta", contentIndex: 0, delta: firstChunk }, - { - type: "text_delta", - contentIndex: 2, - delta: secondChunk, - partial: { role: "assistant", content }, - }, - { - type: "done", - reason: "stop", - message: { role: "assistant", content, stopReason: "stop" }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(events.map((event) => requireRecord(event, "event").type)).toEqual([ - "text_delta", - "done", - ]); - const expectedContent = [ - { type: "text", text: "" }, - { type: "thinking", thinking: "checking" }, - { type: "text", text: visibleText }, - ]; - expect(requireRecord(events[0], "text event")).toMatchObject({ - delta: visibleText, - partial: { content: expectedContent }, - }); - expect(requireRecord(events[1], "done event").message).toMatchObject({ - content: expectedContent, - }); - expect(JSON.stringify(events)).not.toContain(marker); - }); - it("scrubs split byte-over-cap XML prefixes from terminal errors without visible text", async () => { const marker = ""; const firstChunk = `${marker}${"\u00a0".repeat(100_000)}`; @@ -1166,16 +1027,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { error: { content, errorMessage: "stream failed" }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => requireRecord(event, "event").type)).toEqual(["error"]); const errorEvent = requireRecord(events[0], "error event"); @@ -1216,16 +1068,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual(["error"]); const errorEvent = requireRecord(events[0], "error event"); @@ -1241,16 +1084,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { const rawToolText = createByteOverCapZeroArgumentXmlCall("read"); const baseStreamFn: StreamFn = () => createEventStream([{ type: "text_delta", contentIndex: 0, delta: rawToolText }]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events).toEqual([]); }); @@ -1445,16 +1279,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual(["error"]); const errorEvent = requireRecord(events[0], "error event"); @@ -1485,16 +1310,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual(["done"]); const doneEvent = requireRecord(events[0], "done event"); @@ -1549,480 +1365,126 @@ describe("createPlainTextToolCallCompatWrapper", () => { expect(JSON.stringify(result)).not.toContain("[tool:read]"); }); - it("scrubs split over-cap bracketed XML parameter text from done messages", async () => { - const rawToolTextParts = [ - "[tool:read]\n", - ["x".repeat(256_001), "", ""].join("\n"), - ]; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: rawToolTextParts.map((text) => ({ type: "text", text })), - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; + const overCapPath = "x".repeat(256_001); + const overCapXml = ["[tool:read]", "", overCapPath].join("\n"); + const closingXml = ["", ""].join("\n"); + const visibleAfterTool = "Visible text after the tool-looking blocks."; + const textBlock = (text: string) => ({ type: "text", text }); + const thinkingBlock = { type: "thinking", thinking: "Checking path." }; + const completeTool = '[tool:read] {"path":"src/index.ts"}'; + const unallowedTool = '[tool:write] {"path":"keep-visible"}'; - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + it.each([ + { + name: "scrubs split over-cap bracketed XML parameter text from done messages", + content: [ + textBlock("[tool:read]\n"), + textBlock([overCapPath, closingXml].join("\n")), + ], + expected: [], + absent: ["[tool:read]", ""], + }, + { + name: "scrubs split over-cap bracketed XML tails before later visible text", + content: [ + textBlock("[tool:read]\n"), + textBlock(overCapPath), + textBlock(closingXml), + textBlock(visibleAfterTool), + ], + expected: [textBlock(visibleAfterTool)], + absent: ["[tool:read]", ""], + }, + { + name: "scrubs split over-cap bracketed XML around non-text blocks", + content: [ + textBlock("[tool:read]\n"), + thinkingBlock, + textBlock([overCapPath, closingXml].join("\n")), + ], + expected: [thinkingBlock], + absent: ["[tool:read]", ""], + }, + { + name: "scrubs closing tails after a single over-cap bracketed XML block", + content: [textBlock(overCapXml), textBlock(closingXml), textBlock(visibleAfterTool)], + expected: [textBlock(visibleAfterTool)], + absent: ["[tool:read]", ""], + }, + { + name: "scrubs closing tails after a single over-cap bracketed XML block without visible text", + content: [textBlock(overCapXml), textBlock(closingXml)], + expected: [], + absent: ["[tool:read]", ""], + }, + { + name: "scrubs over-cap buffers even when later text blocks contain complete tool calls", + content: [textBlock(overCapXml), textBlock(completeTool)], + expected: [], + absent: ["[tool:read]", "src/index.ts"], + }, + { + name: "scrubs multiple incomplete over-cap tool blocks from done messages", + content: [ + textBlock(overCapXml), + textBlock(["[tool:read]", "", "y".repeat(256_001)].join("\n")), + textBlock(visibleAfterTool), + ], + expected: [], + absent: ["[tool:read]", overCapPath, "y".repeat(256_001)], + }, + { + name: "scrubs done-message over-cap blocks after visible text", + content: [textBlock("Visible intro."), textBlock(overCapXml)], + expected: [textBlock("Visible intro.")], + absent: ["[tool:read]"], + }, + { + name: "scrubs split done-message over-cap blocks after visible text", + content: [ + textBlock("Visible intro."), + textBlock("[tool:read]\n"), + textBlock(overCapPath), + textBlock(closingXml), + ], + expected: [textBlock("Visible intro.")], + absent: ["[tool:read]", ""], + }, + { + name: "scrubs small complete tool calls after over-cap visible text", + content: [textBlock(`Visible intro ${overCapPath}`), textBlock(completeTool)], + expected: [textBlock(`Visible intro ${overCapPath}`)], + absent: [completeTool], + }, + { + name: "does not leak over-cap buffers when stripped later tool blocks are followed by text", + content: [textBlock(overCapXml), textBlock(completeTool), textBlock(visibleAfterTool)], + expected: [textBlock(visibleAfterTool)], + absent: ["[tool:read]", "src/index.ts"], + }, + { + name: "preserves unallowed tool-looking text while scrubbing an over-cap allowed tool block", + content: [textBlock([overCapXml, closingXml].join("\n")), textBlock(unallowedTool)], + expected: [textBlock(unallowedTool)], + absent: ["[tool:read]"], + }, + ])("$name", async ({ content, expected, absent }) => { + const events = await collectPlainTextToolCallCompatEvents([ + { + type: "done", + reason: "stop", + message: { role: "assistant", content, stopReason: "stop" }, + }, + ]); - const doneEvent = requireRecord(events[0], "done event"); - expect(doneEvent.reason).toBe("stop"); - expect(doneEvent.message).toMatchObject({ - role: "assistant", - content: [], - stopReason: "stop", + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(requireRecord(events[0], "done event")).toMatchObject({ + reason: "stop", + message: { role: "assistant", content: expected, stopReason: "stop" }, }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain(""); - }); - - it("scrubs split over-cap bracketed XML tails before later visible text", async () => { - const rawToolTextParts = [ - "[tool:read]\n", - "x".repeat(256_001), - ["", ""].join("\n"), - ]; - const visibleText = "Visible text after the tool-looking blocks."; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - ...rawToolTextParts.map((text) => ({ type: "text", text })), - { type: "text", text: visibleText }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); + for (const marker of absent) { + expect(JSON.stringify(events)).not.toContain(marker); } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: visibleText }], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain(""); - }); - - it("scrubs split over-cap bracketed XML around non-text blocks", async () => { - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: "[tool:read]\n" }, - { type: "thinking", thinking: "Checking path." }, - { - type: "text", - text: ["x".repeat(256_001), "", ""].join("\n"), - }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "thinking", thinking: "Checking path." }], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain(""); - }); - - it("scrubs closing tails after a single over-cap bracketed XML block", async () => { - const rawToolTextParts = [ - ["[tool:read]", "", "x".repeat(256_001)].join("\n"), - ["", ""].join("\n"), - ]; - const visibleText = "Visible text after the tool-looking blocks."; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - ...rawToolTextParts.map((text) => ({ type: "text", text })), - { type: "text", text: visibleText }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: visibleText }], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain(""); - }); - - it("scrubs closing tails after a single over-cap bracketed XML block without visible text", async () => { - const rawToolTextParts = [ - ["[tool:read]", "", "x".repeat(256_001)].join("\n"), - ["", ""].join("\n"), - ]; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: rawToolTextParts.map((text) => ({ type: "text", text })), - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain(""); - }); - - it("scrubs over-cap buffers even when later text blocks contain complete tool calls", async () => { - const incompleteOverCapTool = ["[tool:read]", "", "x".repeat(256_001)].join( - "\n", - ); - const completeTool = '[tool:read] {"path":"src/index.ts"}'; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: incompleteOverCapTool }, - { type: "text", text: completeTool }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain("src/index.ts"); - }); - - it("scrubs multiple incomplete over-cap tool blocks from done messages", async () => { - const firstOverCapTool = ["[tool:read]", "", "x".repeat(256_001)].join("\n"); - const secondOverCapTool = ["[tool:read]", "", "y".repeat(256_001)].join("\n"); - const visibleText = "Visible text after the tool-looking blocks."; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: firstOverCapTool }, - { type: "text", text: secondOverCapTool }, - { type: "text", text: visibleText }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain("x".repeat(256_001)); - expect(JSON.stringify(events)).not.toContain("y".repeat(256_001)); - }); - - it("scrubs done-message over-cap blocks after visible text", async () => { - const intro = "Visible intro."; - const incompleteOverCapTool = ["[tool:read]", "", "x".repeat(256_001)].join( - "\n", - ); - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: intro }, - { type: "text", text: incompleteOverCapTool }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: intro }], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - }); - - it("scrubs split done-message over-cap blocks after visible text", async () => { - const intro = "Visible intro."; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: intro }, - { type: "text", text: "[tool:read]\n" }, - { type: "text", text: "x".repeat(256_001) }, - { type: "text", text: ["", ""].join("\n") }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: intro }], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain(""); - }); - - it("scrubs small complete tool calls after over-cap visible text", async () => { - const visibleText = `Visible intro ${"x".repeat(256_001)}`; - const toolText = '[tool:read] {"path":"src/index.ts"}'; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: visibleText }, - { type: "text", text: toolText }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(events.map((event) => (event as { type?: string }).type)).toEqual(["done"]); - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: visibleText }], - stopReason: "stop", - }); - expect(JSON.stringify(events)).not.toContain(toolText); - }); - - it("does not leak over-cap buffers when stripped later tool blocks are followed by text", async () => { - const incompleteOverCapTool = ["[tool:read]", "", "x".repeat(256_001)].join( - "\n", - ); - const completeTool = '[tool:read] {"path":"src/index.ts"}'; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: incompleteOverCapTool }, - { type: "text", text: completeTool }, - { type: "text", text: "Visible text after the tool-looking blocks." }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(JSON.stringify(events)).not.toContain("[tool:read]"); - expect(JSON.stringify(events)).not.toContain("src/index.ts"); - expect(requireRecord(events[0], "done event").message).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: "Visible text after the tool-looking blocks." }], - stopReason: "stop", - }); - }); - - it("preserves unallowed tool-looking text while scrubbing an over-cap allowed tool block", async () => { - const allowedOverCapTool = [ - "[tool:read]", - "", - "x".repeat(256_001), - "", - "", - ].join("\n"); - const unallowedToolText = '[tool:write] {"path":"keep-visible"}'; - const baseStreamFn: StreamFn = () => - createEventStream([ - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [ - { type: "text", text: allowedOverCapTool }, - { type: "text", text: unallowedToolText }, - ], - stopReason: "stop", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(JSON.stringify(events)).toContain("[tool:write]"); - expect(JSON.stringify(events)).not.toContain("[tool:read]"); }); it("flushes over-cap text for closed tool names that only prefix-match configured tools", async () => { @@ -2079,16 +1541,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2162,16 +1615,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => requireRecord(event, "event").type)).toEqual([ "text_delta", @@ -2200,16 +1644,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2246,16 +1681,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2288,16 +1714,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(String(requireRecord(events[0], "text event").delta)).toBe(visibleSuffix); expect(JSON.stringify(events)).not.toContain(""); @@ -2325,16 +1742,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2372,16 +1780,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2410,16 +1809,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => requireRecord(event, "event").type)).toEqual([ "text_delta", @@ -2456,16 +1846,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2497,16 +1878,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2537,16 +1909,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ "text_delta", @@ -2614,16 +1977,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); const secondEvent = requireRecord(events[1], "second text event"); expect(events.map((event) => (event as { type?: string }).type)).toEqual([ @@ -2660,16 +2014,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); const doneMessage = requireRecord( requireRecord(events.at(-1), "done event").message, @@ -2702,16 +2047,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { }, }, ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } + const events = await collectPlainTextToolCallCompatEventsFromStream(baseStreamFn); const doneMessage = requireRecord( requireRecord(events.at(-1), "done event").message, @@ -2724,7 +2060,10 @@ describe("createPlainTextToolCallCompatWrapper", () => { expect(JSON.stringify(events)).not.toContain("[tool:read]"); }); - it("keeps legacy bracketed XML parameter tool calls buffered for conversion", async () => { + it.each([ + { name: "legacy bracketed XML parameter tool calls", separator: "\n" }, + { name: "CRLF legacy bracketed XML parameter tool calls", separator: "\r\n" }, + ])("keeps $name buffered for conversion", async ({ name, separator }) => { const { source, stream } = createControlledPlainTextToolCallCompatStream(); const iterator = (await resolveStream(stream))[Symbol.asyncIterator](); const rawToolText = [ @@ -2733,17 +2072,12 @@ describe("createPlainTextToolCallCompatWrapper", () => { "src/index.ts", "", "", - ].join("\n"); + ].join(separator); try { source.push({ type: "start", partial: { content: [] } } as never); expect((await nextEvent(iterator, "start")).type).toBe("start"); - - source.push({ - type: "text_delta", - contentIndex: 0, - delta: rawToolText, - } as never); + source.push({ type: "text_delta", contentIndex: 0, delta: rawToolText } as never); source.push({ type: "done", reason: "stop", @@ -2753,47 +2087,7 @@ describe("createPlainTextToolCallCompatWrapper", () => { stopReason: "stop", }, } as never); - - const event = await nextEvent(iterator, "converted legacy bracketed XML tool call"); - expect(event.type).toBe("toolcall_start"); - } finally { - source.end(); - await iterator.return?.(); - } - }); - - it("keeps CRLF legacy bracketed XML parameter tool calls buffered for conversion", async () => { - const { source, stream } = createControlledPlainTextToolCallCompatStream(); - const iterator = (await resolveStream(stream))[Symbol.asyncIterator](); - const rawToolText = [ - "[read]", - "", - "src/index.ts", - "", - "", - ].join("\r\n"); - - try { - source.push({ type: "start", partial: { content: [] } } as never); - expect((await nextEvent(iterator, "start")).type).toBe("start"); - - source.push({ - type: "text_delta", - contentIndex: 0, - delta: rawToolText, - } as never); - source.push({ - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [{ type: "text", text: rawToolText }], - stopReason: "stop", - }, - } as never); - - const event = await nextEvent(iterator, "converted CRLF legacy XML tool call"); - expect(event.type).toBe("toolcall_start"); + expect((await nextEvent(iterator, `converted ${name}`)).type).toBe("toolcall_start"); } finally { source.end(); await iterator.return?.(); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 70f5779cb05c..122188091688 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -95,375 +95,179 @@ function withManifestLoadPaths( } function createManifestRegistryFixture(): PluginManifestRegistry { + const plugins = [ + { id: "demo-channel", channels: ["demo-channel"] }, + { id: "demo-other-channel", channels: ["demo-other-channel"] }, + { + id: "browser", + activation: { onStartup: true, onConfigPaths: ["browser"] }, + enabledByDefault: true, + }, + { + id: "demo-provider-plugin", + providers: ["demo-provider"], + cliBackends: ["demo-cli"], + }, + { + id: "microsoft", + enabledByDefault: true, + contracts: { speechProviders: ["microsoft"] }, + }, + { + id: "tts-local-cli", + enabledByDefault: true, + contracts: { speechProviders: ["tts-local-cli", "cli"] }, + }, + { id: "gradium", origin: "global", contracts: { speechProviders: ["gradium"] } }, + { + id: "anthropic", + enabledByDefault: true, + providers: ["anthropic"], + modelSupport: { modelPrefixes: ["claude-"] }, + cliBackends: ["claude-cli"], + }, + { + id: "openai", + enabledByDefault: true, + providers: ["openai", "openai-codex"], + modelSupport: { modelPrefixes: ["gpt-"] }, + contracts: { + speechProviders: ["openai"], + realtimeTranscriptionProviders: ["openai"], + realtimeVoiceProviders: ["openai"], + imageGenerationProviders: ["openai"], + videoGenerationProviders: ["openai"], + memoryEmbeddingProviders: ["openai"], + }, + }, + { + id: "ollama", + enabledByDefault: true, + providers: ["ollama"], + contracts: { memoryEmbeddingProviders: ["ollama"] }, + }, + { + id: "generic-embedding", + enabledByDefault: true, + contracts: { embeddingProviders: ["generic-embed"] }, + }, + { + id: "llama-cpp", + origin: "global", + enabledByDefault: true, + contracts: { embeddingProviders: ["local"] }, + }, + { + id: "google", + enabledByDefault: true, + providers: ["google", "google-gemini-cli"], + cliBackends: ["google-gemini-cli"], + contracts: { + realtimeVoiceProviders: ["google"], + imageGenerationProviders: ["google"], + videoGenerationProviders: ["google"], + musicGenerationProviders: ["google"], + }, + }, + { id: "amazon-bedrock", enabledByDefault: true, providers: ["amazon-bedrock"] }, + { id: "brave", origin: "global", contracts: { webSearchProviders: ["brave"] } }, + { id: "codex", providers: ["codex"], activation: { onAgentHarnesses: ["codex"] } }, + { + id: "activation-only-channel-plugin", + activation: { onChannels: ["activation-only-channel"] }, + }, + { + id: "workspace-activation-channel-plugin", + origin: "workspace", + activation: { onChannels: ["workspace-activation-channel"] }, + }, + { + id: "global-activation-channel-plugin", + origin: "global", + activation: { onChannels: ["global-activation-channel"] }, + }, + { + id: "external-env-channel-plugin", + origin: "config", + channels: ["external-env-channel"], + packageChannel: { + id: "external-env-channel", + configuredState: { + env: { allOf: ["EXTERNAL_ENV_CHANNEL_HOST", "EXTERNAL_ENV_CHANNEL_NICK"] }, + }, + }, + }, + { id: "voice-call", activation: { onStartup: true } }, + { id: "memory-core", kind: "memory" }, + { id: "memory-lancedb", kind: "memory" }, + { id: "demo-global-sidecar", origin: "global", activation: { onStartup: true } }, + { + id: "demo-global-startup-opt-out", + origin: "global", + activation: { onStartup: false }, + }, + { + id: "demo-global-explicit-startup", + origin: "global", + activation: { onStartup: true }, + }, + { + id: "demo-config-startup", + enabledByDefault: true, + activation: { + onStartup: false, + onConfigPaths: ["plugins.entries.demo-config-startup.config.autoStart"], + }, + }, + { + id: "external-config-startup", + origin: "global", + activation: { + onStartup: false, + onConfigPaths: ["plugins.entries.external-config-startup.config.autoStart"], + }, + }, + { + id: "external-hook-capability", + origin: "global", + activation: { onCapabilities: ["hook"] }, + }, + { id: "external-hook-policy", origin: "global" }, + { + id: "external-trusted-policy", + origin: "global", + contracts: { trustedToolPolicies: ["workflow-budget"] }, + }, + // Keep the legacy installed-index origin: #76576 must exercise the original + // context-engine regression even though current manifest origins are narrower. + { + id: "lossless-claw", + kind: "context-engine", + origin: "installed" as PluginManifestRecord["origin"], + }, + { + id: "qa-lab", + activation: { onStartup: false }, + contracts: { workerProviders: ["static-ssh"] }, + }, + { + id: "external-worker-provider", + origin: "global", + contracts: { workerProviders: ["external-ssh"] }, + }, + ] satisfies Array & Partial>; + return { - plugins: [ - { - id: "demo-channel", - channels: ["demo-channel"], - origin: "bundled", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "demo-other-channel", - channels: ["demo-other-channel"], - origin: "bundled", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "browser", - channels: [], - activation: { - onStartup: true, - onConfigPaths: ["browser"], - }, - origin: "bundled", - enabledByDefault: true, - providers: [], - cliBackends: [], - }, - { - id: "demo-provider-plugin", - channels: [], - origin: "bundled", - enabledByDefault: undefined, - providers: ["demo-provider"], - cliBackends: ["demo-cli"], - }, - { - id: "microsoft", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: [], - cliBackends: [], - contracts: { speechProviders: ["microsoft"] }, - }, - { - id: "tts-local-cli", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: [], - cliBackends: [], - contracts: { speechProviders: ["tts-local-cli", "cli"] }, - }, - { - id: "gradium", - channels: [], - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - contracts: { speechProviders: ["gradium"] }, - }, - { - id: "anthropic", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: ["anthropic"], - modelSupport: { - modelPrefixes: ["claude-"], - }, - cliBackends: ["claude-cli"], - }, - { - id: "openai", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: ["openai", "openai-codex"], - modelSupport: { - modelPrefixes: ["gpt-"], - }, - cliBackends: [], - contracts: { - speechProviders: ["openai"], - realtimeTranscriptionProviders: ["openai"], - realtimeVoiceProviders: ["openai"], - imageGenerationProviders: ["openai"], - videoGenerationProviders: ["openai"], - memoryEmbeddingProviders: ["openai"], - }, - }, - { - id: "ollama", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: ["ollama"], - cliBackends: [], - contracts: { - memoryEmbeddingProviders: ["ollama"], - }, - }, - { - id: "generic-embedding", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: [], - cliBackends: [], - contracts: { - embeddingProviders: ["generic-embed"], - }, - }, - { - id: "llama-cpp", - channels: [], - origin: "global", - enabledByDefault: true, - providers: [], - cliBackends: [], - contracts: { - embeddingProviders: ["local"], - }, - }, - { - id: "google", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: ["google", "google-gemini-cli"], - cliBackends: ["google-gemini-cli"], - contracts: { - realtimeVoiceProviders: ["google"], - imageGenerationProviders: ["google"], - videoGenerationProviders: ["google"], - musicGenerationProviders: ["google"], - }, - }, - { - id: "amazon-bedrock", - channels: [], - origin: "bundled", - enabledByDefault: true, - providers: ["amazon-bedrock"], - cliBackends: [], - }, - { - id: "brave", - channels: [], - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - contracts: { - webSearchProviders: ["brave"], - }, - }, - { - id: "codex", - channels: [], - activation: { - onAgentHarnesses: ["codex"], - }, - origin: "bundled", - enabledByDefault: undefined, - providers: ["codex"], - cliBackends: [], - }, - { - id: "activation-only-channel-plugin", - channels: [], - activation: { - onChannels: ["activation-only-channel"], - }, - origin: "bundled", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "workspace-activation-channel-plugin", - channels: [], - activation: { - onChannels: ["workspace-activation-channel"], - }, - origin: "workspace", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "global-activation-channel-plugin", - channels: [], - activation: { - onChannels: ["global-activation-channel"], - }, - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "external-env-channel-plugin", - channels: ["external-env-channel"], - packageChannel: { - id: "external-env-channel", - configuredState: { - env: { - allOf: ["EXTERNAL_ENV_CHANNEL_HOST", "EXTERNAL_ENV_CHANNEL_NICK"], - }, - }, - }, - origin: "config", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "voice-call", - channels: [], - activation: { - onStartup: true, - }, - origin: "bundled", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "memory-core", - kind: "memory", + plugins: plugins.map((plugin) => + withManifestLoadPaths({ channels: [], origin: "bundled", enabledByDefault: undefined, providers: [], cliBackends: [], - }, - { - id: "memory-lancedb", - kind: "memory", - channels: [], - origin: "bundled", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "demo-global-sidecar", - channels: [], - activation: { - onStartup: true, - }, - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "demo-global-startup-opt-out", - channels: [], - activation: { - onStartup: false, - }, - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "demo-global-explicit-startup", - channels: [], - activation: { - onStartup: true, - }, - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "demo-config-startup", - channels: [], - activation: { - onStartup: false, - onConfigPaths: ["plugins.entries.demo-config-startup.config.autoStart"], - }, - origin: "bundled", - enabledByDefault: true, - providers: [], - cliBackends: [], - }, - { - id: "external-config-startup", - channels: [], - activation: { - onStartup: false, - onConfigPaths: ["plugins.entries.external-config-startup.config.autoStart"], - }, - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "external-hook-capability", - channels: [], - activation: { - onCapabilities: ["hook"], - }, - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "external-hook-policy", - channels: [], - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "external-trusted-policy", - channels: [], - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - contracts: { - trustedToolPolicies: ["workflow-budget"], - }, - }, - { - id: "lossless-claw", - kind: "context-engine", - channels: [], - // No activation.onStartup — this is the bug scenario (#76576): - // external context-engine plugins do not set onStartup but must be - // included in gateway startup when selected via plugins.slots.contextEngine. - origin: "installed", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - }, - { - id: "qa-lab", - channels: [], - activation: { onStartup: false }, - origin: "bundled", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - contracts: { workerProviders: ["static-ssh"] }, - }, - { - id: "external-worker-provider", - channels: [], - origin: "global", - enabledByDefault: undefined, - providers: [], - cliBackends: [], - contracts: { workerProviders: ["external-ssh"] }, - }, - ].map(withManifestLoadPaths) as PluginManifestRecord[], + ...plugin, + }), + ) as PluginManifestRecord[], diagnostics: [], }; } @@ -609,16 +413,6 @@ function expectStartupPluginIds(params: { ).toEqual(params.expected); } -function expectStartupPluginIdsCase(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workerProviderIds?: readonly string[]; - expected: readonly string[]; -}) { - expectStartupPluginIds(params); -} - function resolveConfiguredDeferredChannelPluginIdsForFixture(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -649,106 +443,65 @@ function createStartupConfig(params: { ...(params.contextEngine ? { contextEngine: params.contextEngine } : {}), }; const hasSlots = Object.keys(slotsConfig).length > 0; - return { - ...(params.noConfiguredChannels - ? { - channels: {}, - } - : params.channelIds?.length + const includeSlots = + hasSlots && (!params.allowPluginIds?.length || Boolean(params.enabledPluginIds?.length)); + const config: Record = {}; + + if (params.noConfiguredChannels) { + config.channels = {}; + } else if (params.channelIds?.length) { + config.channels = Object.fromEntries( + params.channelIds.map((channelId) => [channelId, { enabled: true }]), + ); + } + + if (params.enabledPluginIds?.length || params.allowPluginIds?.length || hasSlots) { + config.plugins = { + ...(params.allowPluginIds?.length ? { allow: params.allowPluginIds } : {}), + ...(includeSlots ? { slots: slotsConfig } : {}), + ...(params.enabledPluginIds?.length ? { - channels: Object.fromEntries( - params.channelIds.map((channelId) => [channelId, { enabled: true }]), - ), - } - : {}), - ...(params.enabledPluginIds?.length - ? { - plugins: { - ...(params.allowPluginIds?.length ? { allow: params.allowPluginIds } : {}), - ...(hasSlots ? { slots: slotsConfig } : {}), entries: Object.fromEntries( params.enabledPluginIds.map((pluginId) => [pluginId, { enabled: true }]), ), - }, - } - : params.allowPluginIds?.length - ? { - plugins: { - allow: params.allowPluginIds, - }, - } - : hasSlots - ? { - plugins: { - slots: slotsConfig, - }, - } - : {}), - ...(params.providerIds?.length - ? { - models: { - providers: Object.fromEntries( - params.providerIds.map((providerId) => [ - providerId, - { - baseUrl: "https://example.com", - models: [], - }, - ]), - ), - }, - } - : {}), - ...(params.modelId - ? { - agents: { - defaults: { - model: { primary: params.modelId }, - ...(params.agentRuntimeId - ? { - agentRuntime: { - id: params.agentRuntimeId, - fallback: "none", - }, - } - : {}), - models: { - [params.modelId]: {}, - }, - }, - ...(params.agentRuntimeIds?.length - ? { - list: params.agentRuntimeIds.map((runtime, index) => ({ - id: `agent-${index + 1}`, - agentRuntime: { id: runtime }, - })), - } - : {}), - }, - } - : params.agentRuntimeId || params.agentRuntimeIds?.length - ? { - agents: { - defaults: params.agentRuntimeId - ? { - agentRuntime: { - id: params.agentRuntimeId, - fallback: "none", - }, - } - : {}, - ...(params.agentRuntimeIds?.length - ? { - list: params.agentRuntimeIds.map((runtime, index) => ({ - id: `agent-${index + 1}`, - agentRuntime: { id: runtime }, - })), - } - : {}), - }, } : {}), - } as OpenClawConfig; + }; + } + + if (params.providerIds?.length) { + config.models = { + providers: Object.fromEntries( + params.providerIds.map((providerId) => [ + providerId, + { baseUrl: "https://example.com", models: [] }, + ]), + ), + }; + } + + if (params.modelId || params.agentRuntimeId || params.agentRuntimeIds?.length) { + config.agents = { + defaults: { + ...(params.modelId + ? { model: { primary: params.modelId }, models: { [params.modelId]: {} } } + : {}), + ...(params.agentRuntimeId + ? { agentRuntime: { id: params.agentRuntimeId, fallback: "none" } } + : {}), + }, + ...(params.agentRuntimeIds?.length + ? { + list: params.agentRuntimeIds.map((runtime, index) => ({ + id: `agent-${index + 1}`, + agentRuntime: { id: runtime }, + })), + } + : {}), + }; + } + + return config as OpenClawConfig; } describe("resolveGatewayStartupPluginIds", () => { @@ -1404,7 +1157,7 @@ describe("resolveGatewayStartupPluginIds", () => { ["demo-channel", "demo-other-channel", "browser", "memory-core"], ], ] as const)("%s", (_name, config, expected) => { - expectStartupPluginIdsCase({ config, expected }); + expectStartupPluginIds({ config, expected }); }); it("matches explicitly disabled channel ids case-insensitively", () => { @@ -1418,7 +1171,7 @@ describe("resolveGatewayStartupPluginIds", () => { ), }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: { "external-env-channel": { enabled: false }, @@ -1444,7 +1197,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: activationSourceConfig, activationSourceConfig, expected: ["browser", "memory-core", "qa-lab"], @@ -1463,7 +1216,7 @@ describe("resolveGatewayStartupPluginIds", () => { manifestRegistry: createManifestRegistryFixture(), }).config; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: effectiveConfig, activationSourceConfig: authoredConfig, expected: ["browser", "qa-lab"], @@ -1471,7 +1224,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads bundled worker-provider owners required by durable environments", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {} } as OpenClawConfig, workerProviderIds: [" Static-SSH ", "STATIC-SSH"], expected: ["browser", "memory-core", "qa-lab"], @@ -1479,12 +1232,12 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("keeps durable external worker-provider owners behind explicit enablement", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {} } as OpenClawConfig, workerProviderIds: ["external-ssh"], expected: ["browser", "memory-core"], }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { entries: { "external-worker-provider": { enabled: true } } }, @@ -1505,7 +1258,7 @@ describe("resolveGatewayStartupPluginIds", () => { plugins: { entries: { "qa-lab": { enabled: false } } }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config, activationSourceConfig: config, expected: ["browser", "memory-core"], @@ -1523,7 +1276,7 @@ describe("resolveGatewayStartupPluginIds", () => { plugins: { allow: ["browser"] }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config, activationSourceConfig: config, expected: ["browser"], @@ -1531,12 +1284,12 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("keeps durable worker-provider owners behind disable and allowlist gates", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { enabled: false } } as OpenClawConfig, workerProviderIds: ["static-ssh"], expected: [], }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { entries: { "qa-lab": { enabled: false } } }, @@ -1544,12 +1297,12 @@ describe("resolveGatewayStartupPluginIds", () => { workerProviderIds: ["static-ssh"], expected: ["browser", "memory-core"], }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { deny: ["qa-lab"] } } as OpenClawConfig, workerProviderIds: ["static-ssh"], expected: ["browser", "memory-core"], }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { allow: ["browser"] } } as OpenClawConfig, workerProviderIds: ["static-ssh"], expected: ["browser"], @@ -1575,7 +1328,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: effectiveConfig, activationSourceConfig: rawConfig, expected: ["browser"], @@ -1609,7 +1362,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: effectiveConfig, activationSourceConfig: rawConfig, expected: ["browser", "brave"], @@ -1645,7 +1398,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: runtimeConfig, activationSourceConfig, expected: [], @@ -1653,7 +1406,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("skips startup when activation.onStartup is false", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["demo-global-startup-opt-out"], allowPluginIds: ["demo-global-startup-opt-out"], @@ -1665,7 +1418,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads explicit startup plugins when activation.onStartup is true", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["demo-global-explicit-startup"], allowPluginIds: ["demo-global-explicit-startup"], @@ -1677,7 +1430,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads explicit trusted policy plugins at startup", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ allowPluginIds: ["external-trusted-policy"], noConfiguredChannels: true, @@ -1688,7 +1441,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads startup-lazy bundled plugins only when their activation config is present", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ noConfiguredChannels: true, memorySlot: "none", @@ -1696,7 +1449,7 @@ describe("resolveGatewayStartupPluginIds", () => { expected: ["browser"], }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1716,7 +1469,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads startup-lazy external plugins from config only when explicitly enabled", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1732,7 +1485,7 @@ describe("resolveGatewayStartupPluginIds", () => { expected: ["browser", "external-config-startup"], }); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1793,7 +1546,7 @@ describe("resolveGatewayStartupPluginIds", () => { ]; for (const testCase of cases) { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: testCase.plugins } as OpenClawConfig, expected: testCase.expected, }); @@ -1822,7 +1575,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: runtimeConfig, activationSourceConfig, expected: ["browser"], @@ -1830,7 +1583,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads explicit hook-capability plugins at startup", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["external-hook-capability"], allowPluginIds: ["external-hook-capability"], @@ -1842,7 +1595,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not ambient-load hook-capability plugins at startup", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ noConfiguredChannels: true, memorySlot: "none", @@ -1852,7 +1605,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("blocks hook-capability plugins when plugins are globally disabled", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1871,7 +1624,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("blocks hook-capability plugins when explicitly denied", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1890,7 +1643,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("loads explicit hook-policy plugins at startup", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1916,7 +1669,7 @@ describe("resolveGatewayStartupPluginIds", () => { ["conversation access", { allowConversationAccess: true }], ["prompt injection", { allowPromptInjection: true }], ] as const)("loads hook-policy plugins with only %s enabled", (_name, hooks) => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1936,7 +1689,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("keeps hook-policy plugins behind restrictive allowlists", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -1989,7 +1742,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: runtimeConfig, activationSourceConfig, expected: [], @@ -2015,7 +1768,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } satisfies OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: effectiveConfig, activationSourceConfig: rawConfig, expected: ["browser", "memory-core"], @@ -2023,7 +1776,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("lets bundled root config activation paths bypass restrictive allowlists", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { browser: { enabled: true, @@ -2038,7 +1791,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not bypass restrictive allowlists for disabled root config activation paths", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { browser: { enabled: false, @@ -2061,7 +1814,7 @@ describe("resolveGatewayStartupPluginIds", () => { const config = {} as OpenClawConfig; - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config, env: createPluginPlanningTestEnv({ DEMO_CHANNEL_ANYTHING: "1", @@ -2098,7 +1851,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("preserves explicit bundled channel config under restrictive allowlists", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: { "demo-channel": { @@ -2625,7 +2378,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not treat explicitly disabled stale channel config as startup intent", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: { "demo-channel": { @@ -2648,7 +2401,7 @@ describe("resolveGatewayStartupPluginIds", () => { ) => (options?.includePersistedAuthState === false ? [] : ["demo-channel"]), ); - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: {} as OpenClawConfig, env: createPluginPlanningTestEnv({ OPENCLAW_STATE_DIR: "/tmp/openclaw-with-persisted-demo-channel", @@ -2751,7 +2504,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes the explicitly selected memory slot plugin in startup scope", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["memory-lancedb"], memorySlot: "memory-lancedb", @@ -2761,7 +2514,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes memory-core as a dreaming sidecar for restrictive selected-memory allowlists", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -2799,7 +2552,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include denied memory-core as a restrictive dreaming startup sidecar", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -2816,7 +2569,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include explicitly disabled memory-core as a restrictive dreaming startup sidecar", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { channels: {}, plugins: { @@ -2833,7 +2586,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("normalizes the raw memory slot id before startup filtering", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["memory-core"], memorySlot: "Memory-Core", @@ -2843,7 +2596,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes the default memory slot plugin when the allowlist permits it", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ allowPluginIds: ["browser", "memory-core"], noConfiguredChannels: true, @@ -2853,7 +2606,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include non-selected memory plugins only because they are enabled", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["memory-lancedb"], }), @@ -2862,7 +2615,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes the selected context-engine slot plugin in startup scope even without activation.onStartup (#76576)", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["lossless-claw"], contextEngine: "lossless-claw", @@ -2872,7 +2625,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include context-engine plugins not selected via the slot", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["lossless-claw"], }), @@ -2881,7 +2634,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include the context-engine slot plugin when it is the built-in legacy engine", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ contextEngine: "legacy", }), @@ -2890,7 +2643,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("normalizes the context-engine slot id before startup filtering", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["lossless-claw"], contextEngine: "Lossless-Claw", @@ -2900,7 +2653,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("ignores legacy default agent runtime during startup planning", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ agentRuntimeId: "codex", enabledPluginIds: ["codex"], @@ -2910,7 +2663,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes required agent harness owner plugins for model runtime policy", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { agents: { defaults: { @@ -2930,7 +2683,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes Codex when an OpenAI agent model uses the implicit runtime default", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ modelId: "openai/gpt-5.5", }), @@ -2939,7 +2692,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes Codex when OpenAI is a selectable default agent model", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { agents: { defaults: { @@ -2955,7 +2708,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include Codex when an OpenAI model is manually pinned to OpenClaw", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { agents: { defaults: { @@ -2971,7 +2724,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("ignores legacy per-agent runtime during startup planning", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ agentRuntimeIds: ["codex"], enabledPluginIds: ["codex"], @@ -2981,7 +2734,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("ignores env runtime overrides during startup planning", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ enabledPluginIds: ["codex"], }), @@ -2991,7 +2744,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("ignores legacy CLI backend runtime during startup planning", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ agentRuntimeId: "demo-cli", enabledPluginIds: ["demo-provider-plugin"], @@ -3001,7 +2754,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes required CLI backend owner plugins for provider runtime policy", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { models: { providers: { @@ -3023,7 +2776,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("includes required CLI backend owner plugins for model runtime policy", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { agents: { defaults: { @@ -3040,7 +2793,7 @@ describe("resolveGatewayStartupPluginIds", () => { it.each(["claude-cli", "codex-cli", "google-gemini-cli"] as const)( "ignores legacy bundled %s runtime at startup", (runtime) => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: createStartupConfig({ agentRuntimeId: runtime, }), @@ -3050,7 +2803,7 @@ describe("resolveGatewayStartupPluginIds", () => { ); it("does not include required CLI backend owner plugins when they are explicitly disabled", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { models: { providers: { @@ -3074,7 +2827,7 @@ describe("resolveGatewayStartupPluginIds", () => { }); it("does not include required agent harness owner plugins when they are explicitly disabled", () => { - expectStartupPluginIdsCase({ + expectStartupPluginIds({ config: { agents: { defaults: { @@ -3115,156 +2868,97 @@ describe("resolveConfiguredChannelPluginIds", () => { useManifestRegistryFixture(); }); - it("uses manifest activation channel ownership before falling back to direct channel lists", () => { + it.each([ + { + name: "uses manifest activation channel ownership before falling back to direct channel lists", + config: createStartupConfig({ channelIds: ["activation-only-channel"] }), + expected: ["activation-only-channel-plugin"], + }, + { + name: "keeps bundled activation owners behind restrictive allowlists", + config: createStartupConfig({ + channelIds: ["activation-only-channel"], + allowPluginIds: ["browser"], + }), + expected: [], + }, + { + name: "keeps explicitly configured bundled channel owners under restrictive allowlists", + config: { + channels: { "demo-channel": { token: "configured" } }, + plugins: { allow: ["browser"] }, + } as OpenClawConfig, + env: {}, + expected: ["demo-channel"], + }, + { + name: "blocks bundled activation owners when explicitly denied", + config: { + channels: { "activation-only-channel": { enabled: true } }, + plugins: { deny: ["activation-only-channel-plugin"] }, + } as OpenClawConfig, + expected: [], + }, + { + name: "blocks bundled activation owners when plugins are globally disabled", + config: { + channels: { "activation-only-channel": { enabled: true } }, + plugins: { enabled: false }, + } as OpenClawConfig, + expected: [], + }, + { + name: "filters untrusted workspace activation owners from configured-channel runtime planning", + config: createStartupConfig({ channelIds: ["workspace-activation-channel"] }), + expected: [], + }, + { + name: "filters untrusted global activation owners from configured-channel runtime planning", + config: createStartupConfig({ channelIds: ["global-activation-channel"] }), + expected: [], + }, + { + name: "keeps explicitly enabled global activation owners eligible for configured-channel runtime planning", + config: createStartupConfig({ + channelIds: ["global-activation-channel"], + enabledPluginIds: ["global-activation-channel-plugin"], + }), + expected: ["global-activation-channel-plugin"], + }, + { + name: "does not treat auto-enabled non-bundled channel owners as explicitly trusted", + config: createStartupConfig({ + channelIds: ["global-activation-channel"], + enabledPluginIds: ["global-activation-channel-plugin"], + }), + activationSourceConfig: createStartupConfig({ + channelIds: ["global-activation-channel"], + }), + expected: [], + }, + { + name: "blocks bundled activation owners when explicitly disabled", + config: { + channels: { "activation-only-channel": { enabled: true } }, + plugins: { entries: { "activation-only-channel-plugin": { enabled: false } } }, + } as OpenClawConfig, + expected: [], + }, + ] satisfies Array<{ + name: string; + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + expected: string[]; + }>)("$name", ({ config, activationSourceConfig, env, expected }) => { expect( resolveConfiguredChannelPluginIds({ - config: createStartupConfig({ - channelIds: ["activation-only-channel"], - }), + config, + ...(activationSourceConfig ? { activationSourceConfig } : {}), workspaceDir: "/tmp", - env: process.env, + env: env ?? process.env, }), - ).toEqual(["activation-only-channel-plugin"]); - }); - - it("keeps bundled activation owners behind restrictive allowlists", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: createStartupConfig({ - channelIds: ["activation-only-channel"], - allowPluginIds: ["browser"], - }), - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); - }); - - it("keeps explicitly configured bundled channel owners under restrictive allowlists", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: { - channels: { - "demo-channel": { - token: "configured", - }, - }, - plugins: { - allow: ["browser"], - }, - } as OpenClawConfig, - workspaceDir: "/tmp", - env: {}, - }), - ).toEqual(["demo-channel"]); - }); - - it("blocks bundled activation owners when explicitly denied", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: { - channels: { - "activation-only-channel": { enabled: true }, - }, - plugins: { - deny: ["activation-only-channel-plugin"], - }, - } as OpenClawConfig, - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); - }); - - it("blocks bundled activation owners when plugins are globally disabled", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: { - channels: { - "activation-only-channel": { enabled: true }, - }, - plugins: { - enabled: false, - }, - } as OpenClawConfig, - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); - }); - - it("filters untrusted workspace activation owners from configured-channel runtime planning", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: createStartupConfig({ - channelIds: ["workspace-activation-channel"], - }), - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); - }); - - it("filters untrusted global activation owners from configured-channel runtime planning", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: createStartupConfig({ - channelIds: ["global-activation-channel"], - }), - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); - }); - - it("keeps explicitly enabled global activation owners eligible for configured-channel runtime planning", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: createStartupConfig({ - channelIds: ["global-activation-channel"], - enabledPluginIds: ["global-activation-channel-plugin"], - }), - workspaceDir: "/tmp", - env: process.env, - }), - ).toEqual(["global-activation-channel-plugin"]); - }); - - it("does not treat auto-enabled non-bundled channel owners as explicitly trusted", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: createStartupConfig({ - channelIds: ["global-activation-channel"], - enabledPluginIds: ["global-activation-channel-plugin"], - }), - activationSourceConfig: createStartupConfig({ - channelIds: ["global-activation-channel"], - }), - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); - }); - - it("blocks bundled activation owners when explicitly disabled", () => { - expect( - resolveConfiguredChannelPluginIds({ - config: { - channels: { - "activation-only-channel": { enabled: true }, - }, - plugins: { - entries: { - "activation-only-channel-plugin": { - enabled: false, - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp", - env: process.env, - }), - ).toStrictEqual([]); + ).toStrictEqual(expected); }); });