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);
});
});