From 41b505b6d1c0b568c8fcd380ad1b4595b530edbd Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 14:06:41 +0000 Subject: [PATCH 1/3] echo-provider: exercise every provider-plugin capability through the public SDK The designated third-party canary only emitted basic shapes. It now registers every declaration field (strings, labelled reasoning levels and service tiers, fallback models, env passthrough, deriveProviderOptions reading a plugin setting, two extension kinds), ships a bb tool with experimental_presentation, and its bridge emits the whole grammar v3 stream: presentation on every item, command, fileRead, search, a delegation with a parentRef-linked child turn, planSteps, a suppressed tool, the bb tool over item/tool/call stamped server:"bb", an extension item and extension state, the providerOptions/env round trip, usage. Tests use only @get-bb/plugin-sdk/provider-bridge/testing: the twelve conformance scenarios, the assembled v3 stream, and a guard that fails on any @bb/* import in the package. Co-Authored-By: Claude --- examples/plugins/echo-provider/package.json | 8 +- .../provider-bridge.conformance.test.ts | 17 +- .../provider-bridge.stream.test.ts | 503 ++++++++++++ .../echo-provider/public-sdk-only.test.ts | 109 +++ examples/plugins/echo-provider/server.ts | 125 ++- .../echo-provider/src/provider-bridge.ts | 758 ++++++++++++++++-- .../plugins/echo-provider/src/vocabulary.ts | 190 +++++ examples/plugins/echo-provider/tsconfig.json | 14 +- 8 files changed, 1617 insertions(+), 107 deletions(-) create mode 100644 examples/plugins/echo-provider/provider-bridge.stream.test.ts create mode 100644 examples/plugins/echo-provider/public-sdk-only.test.ts create mode 100644 examples/plugins/echo-provider/src/vocabulary.ts diff --git a/examples/plugins/echo-provider/package.json b/examples/plugins/echo-provider/package.json index 528b762201..43ced2b9a8 100644 --- a/examples/plugins/echo-provider/package.json +++ b/examples/plugins/echo-provider/package.json @@ -9,7 +9,7 @@ }, "bb": { "name": "Echo provider", - "description": "A complete third-party agent provider: declaration, bridge, and conformance test.", + "description": "The third-party canary: a complete agent provider that exercises every provider-plugin capability through the public SDK alone.", "branding": { "icon": "Zap" }, @@ -24,13 +24,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@get-bb/plugin-sdk": "workspace:*" + "@get-bb/plugin-sdk": "workspace:*", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-7": "npm:typescript@^7.0.2", - "vitest": "^4.1.1", - "zod": "^4.3.6" + "vitest": "^4.1.1" } } diff --git a/examples/plugins/echo-provider/provider-bridge.conformance.test.ts b/examples/plugins/echo-provider/provider-bridge.conformance.test.ts index bc3a8775db..d6a2fd4b72 100644 --- a/examples/plugins/echo-provider/provider-bridge.conformance.test.ts +++ b/examples/plugins/echo-provider/provider-bridge.conformance.test.ts @@ -1,9 +1,16 @@ /** * The echo bridge's conformance run: drives the bridge in-process through the * canonical Provider Bridge Protocol suite (JSON-RPC hygiene, the initialize - * handshake, and the shared session lifecycle) and asserts a fully green - * report. This is the test every provider bridge should ship — a conformant - * bridge passes all eleven scenarios. + * handshake, the shared session lifecycle, and the zero-work turn) and + * asserts a fully green report. This is the test every provider bridge + * should ship — a conformant bridge passes all twelve scenarios. Everything + * it needs comes from the published `@get-bb/plugin-sdk/provider-bridge/testing` + * kit; no private bb package is involved. + * + * The lifecycle scenarios run the bridge's full grammar v3 turn (command, + * fileRead, search, a delegation with a child turn, planSteps, tools, the + * extension item and state, the streamed message), so `events/schema-valid` + * and `item/opens-before-delta` cover every v3 shape the bridge emits. * * The transport is the in-process pattern: `send` is the bridge's exported * line handler, and `takeMessages` drains a captured stdout buffer (the @@ -66,6 +73,9 @@ it("passes the canonical protocol suite", async () => { session: { cwd: workspaceDir, promptInput: [{ type: "text", text: "say hello", mentions: [] }], + // `/noop` is the prompt the echo agent completes without activity, so + // the kit can check that such a turn still settles. + zeroWorkPromptInput: [{ type: "text", text: "/noop", mentions: [] }], }, timeoutMs: 5_000, }); @@ -89,6 +99,7 @@ it("passes the canonical protocol suite", async () => { "item/opens-before-delta": "pass", "stop/release-not-interrupted": "pass", "session/resume-id-uniqueness": "pass", + "turn/settles-without-activity": "pass", }); expect(report.passed).toBe(true); }, 30_000); diff --git a/examples/plugins/echo-provider/provider-bridge.stream.test.ts b/examples/plugins/echo-provider/provider-bridge.stream.test.ts new file mode 100644 index 0000000000..7b7f4e03d3 --- /dev/null +++ b/examples/plugins/echo-provider/provider-bridge.stream.test.ts @@ -0,0 +1,503 @@ +/** + * The echo bridge's grammar v3 stream, assembled by the REAL runtime delta + * assembler the public testing kit ships. Conformance proves the protocol + * shape; this test proves every capability the bridge claims: what each + * delta becomes once the runtime has minted ids and built canonical events. + * + * It drives the bridge in-process through the published JSON-RPC harness, + * plays the runtime's part for the one request the bridge makes + * (`item/tool/call` for the plugin's bb tool), and asserts over the + * assembled events: + * + * - presentation on EVERY item (open and close), for every shape; + * - the core v3 kinds: command, fileRead, search, delegation, planSteps; + * - the delegation's child turn linked through `parentToolCallId`; + * - a generic tool whose presentation suppresses the row; + * - the bb tool call stamped `server: "bb"` with its definition's + * presentation, call id mapped through the provider-native id space; + * - the extension item and the extension state; + * - the providerOptions the plugin derived (the `shout` setting) and the + * passed-through daemon env var, echoed into the message; + * - the usage dialect and the turn boundary. + * + * Nothing here imports a private bb package: the assembler, the harness and + * the types all come from `@get-bb/plugin-sdk/provider-bridge/testing`. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + experimental_createBridgeDeltaEventCollector as createBridgeDeltaEventCollector, + experimental_createBridgeJsonRpcTestHarness as createBridgeJsonRpcTestHarness, +} from "@get-bb/plugin-sdk/provider-bridge/testing"; +import type { + BridgeDeltaEventCollector, + BridgeJsonRpcObject, + BridgeJsonRpcOutputMessage, + BridgeJsonRpcTestHarness, +} from "@get-bb/plugin-sdk/provider-bridge/testing"; + +import { handleLine } from "./src/provider-bridge.js"; +import { + ECHO_GREETING_ENV, + ECHO_MOOD_KIND, + ECHO_PROVIDER_ID, + ECHO_RECEIPT_KIND, + ECHO_STAMP_TOOL_NAME, + ECHO_STAMP_TOOL_PRESENTATION, +} from "./src/vocabulary.js"; + +/** + * The canonical event type, derived from the kit's collector. The kit does + * not export `ThreadEvent` by name (its vocabulary lives in bb's private + * domain package), so a plugin test names it this way. + */ +type AssembledEvent = ReturnType< + BridgeDeltaEventCollector["assembleMessage"] +>[number]; +type ItemEvent = Extract< + AssembledEvent, + { type: "item/started" | "item/completed" } +>; + +const THREAD_ID = "thr_echo_stream"; +const CWD = "/workspace/echo"; +const PROMPT = "hello world"; + +/** The tool definition the server would inject (presentation resolved). */ +const STAMP_TOOL_DEFINITION = { + name: ECHO_STAMP_TOOL_NAME, + description: "Stamp a piece of text with the echo provider's seal.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + presentation: ECHO_STAMP_TOOL_PRESENTATION, +}; + +const FULL_OPTIONS = { + model: "echo-1", + reasoningLevel: "medium", + permissionMode: "full", + permissionScope: "full", + approvalReviewer: null, + permissionEscalation: null, +}; + +let harness: BridgeJsonRpcTestHarness; +let collector: BridgeDeltaEventCollector; +let requestCounter = 0; +let savedGreeting: string | undefined; + +beforeEach(() => { + harness = createBridgeJsonRpcTestHarness(handleLine); + collector = createBridgeDeltaEventCollector(ECHO_PROVIDER_ID); + savedGreeting = process.env[ECHO_GREETING_ENV]; + process.env[ECHO_GREETING_ENV] = "hi from the daemon"; +}); + +afterEach(() => { + harness.restore(); + if (savedGreeting === undefined) { + delete process.env[ECHO_GREETING_ENV]; + } else { + process.env[ECHO_GREETING_ENV] = savedGreeting; + } +}); + +async function request( + method: string, + params: BridgeJsonRpcObject, +): Promise { + requestCounter += 1; + const id = `test-${requestCounter}`; + harness.sendRequest(id, method, params); + const response = await harness.waitForResponse(id); + expect(response.error, `${method} answered an error`).toBeUndefined(); + return response; +} + +async function startSession(args: { + dynamicTools?: BridgeJsonRpcObject[]; + providerOptions?: BridgeJsonRpcObject; + input?: BridgeJsonRpcObject[]; +}): Promise { + const response = await request("thread/start", { + threadId: THREAD_ID, + cwd: CWD, + instructionMode: "append", + options: { + ...FULL_OPTIONS, + ...(args.providerOptions === undefined + ? {} + : { providerOptions: args.providerOptions }), + }, + ...(args.dynamicTools === undefined + ? {} + : { dynamicTools: args.dynamicTools }), + ...(args.input === undefined ? {} : { input: args.input }), + }); + const result = response.result as { providerThreadId: string }; + expect(typeof result.providerThreadId).toBe("string"); + return result.providerThreadId; +} + +function textInput(text: string): BridgeJsonRpcObject[] { + return [{ type: "text", text, mentions: [] }]; +} + +/** Play the runtime: answer the bridge's pending `item/tool/call`. */ +function answerToolCall( + answer: (params: Record) => BridgeJsonRpcObject, +): Record { + const call = harness.messages.find( + (message) => message.method === "item/tool/call", + ); + expect(call, "the bridge called its bb tool").toBeDefined(); + const params = call?.params as Record; + handleLine( + JSON.stringify({ jsonrpc: "2.0", id: call?.id, result: answer(params) }), + ); + return params; +} + +function assembledEvents(): AssembledEvent[] { + return harness.messages.flatMap((message) => + collector.assembleMessage(message), + ); +} + +function itemEvents(events: AssembledEvent[]): ItemEvent[] { + return events.filter( + (event): event is ItemEvent => + event.type === "item/started" || event.type === "item/completed", + ); +} + +function completedItem( + events: AssembledEvent[], + type: T, +): Extract { + const event = itemEvents(events).find( + (candidate) => + candidate.type === "item/completed" && candidate.item.type === type, + ); + expect(event, `a completed ${type} item`).toBeDefined(); + return event?.item as Extract; +} + +describe("the echo bridge's grammar v3 stream", () => { + it("runs the whole scripted turn through the runtime assembler", async () => { + await request("initialize", { + protocolVersion: 2, + client: { name: "echo-stream-test", version: "0.0.0" }, + grammarVersions: [3, 3], + }); + const providerThreadId = await startSession({ + dynamicTools: [STAMP_TOOL_DEFINITION], + providerOptions: { shout: true, model: "echo-1", promptMode: null }, + }); + await request("turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: textInput(PROMPT), + clientRequestId: "creq_ech2345678", + options: { + ...FULL_OPTIONS, + providerOptions: { shout: true, model: "echo-1", promptMode: null }, + }, + }); + + // The turn pauses on the bb tool until the runtime answers. + const callParams = answerToolCall(() => ({ + success: true, + contentItems: [{ type: "inputText", text: `stamped: ${PROMPT}` }], + })); + expect(callParams).toMatchObject({ + providerThreadId, + threadId: THREAD_ID, + turnId: null, + tool: ECHO_STAMP_TOOL_NAME, + arguments: { text: PROMPT }, + providerNativeIds: true, + }); + + const events = assembledEvents(); + const types = events.map((event) => event.type); + + // Turn lifecycle: identity (its own notification, not a delta), the + // acceptance, the main turn and, inside it, the delegation's child turn. + expect( + harness.messages.find((message) => message.method === "thread/identity") + ?.params, + ).toEqual({ threadId: THREAD_ID, providerThreadId }); + // The assembler opens the turn, then emits the queued acceptance. + expect(types.slice(0, 2)).toEqual(["turn/started", "turn/input/accepted"]); + expect(types.filter((type) => type === "turn/input/accepted")).toHaveLength( + 1, + ); + expect(types.filter((type) => type === "turn/started")).toHaveLength(2); + expect(types.filter((type) => type === "turn/completed")).toHaveLength(2); + expect(types.at(-1)).toBe("turn/completed"); + + // Every item, opened and closed, carries a presentation. + const items = itemEvents(events); + expect(items.length).toBeGreaterThanOrEqual(18); + for (const event of items) { + expect( + "presentation" in event.item ? event.item.presentation : undefined, + `${event.type} ${event.item.type} ${event.item.id} has presentation`, + ).toMatchObject({ + label: { pending: expect.any(String), completed: expect.any(String) }, + icon: { glyph: expect.any(String) }, + }); + } + const completedTypes = items + .filter((event) => event.type === "item/completed") + .map((event) => event.item.type); + expect(completedTypes).toEqual([ + "commandExecution", + "fileRead", + "search", + "agentMessage", // the child turn's message + "delegation", + "planSteps", + "toolCall", // echo_noop (suppressed) + "toolCall", // echo_stamp (server: "bb") + "extension", + "agentMessage", + ]); + + // command: streamed output then the terminal shape. + const command = completedItem(events, "commandExecution"); + expect(command).toMatchObject({ + command: `echo "${PROMPT}"`, + cwd: CWD, + status: "completed", + exitCode: 0, + aggregatedOutput: `${PROMPT}\n`, + presentation: { + label: { pending: "Running command", completed: "Ran command" }, + icon: { glyph: "Terminal" }, + title: `echo "${PROMPT}"`, + }, + }); + expect( + events.some((event) => event.type === "item/commandExecution/outputDelta"), + ).toBe(true); + + // fileRead and search. + expect(completedItem(events, "fileRead")).toMatchObject({ + path: `${CWD}/README.md`, + status: "completed", + presentation: { icon: { glyph: "FileText" }, title: `${CWD}/README.md` }, + }); + expect(completedItem(events, "search")).toMatchObject({ + mode: "content", + query: PROMPT, + path: CWD, + status: "completed", + presentation: { icon: { glyph: "Search" }, title: PROMPT }, + }); + + // delegation: the child turn and its message link back to the row. + const delegation = completedItem(events, "delegation"); + expect(delegation).toMatchObject({ + background: false, + status: "completed", + summary: `child echo: ${PROMPT}`, + presentation: { + label: { + pending: "Running echo child", + completed: "Echo child finished", + }, + icon: { glyph: "UserRound" }, + detail: expect.stringContaining("parentRef"), + }, + }); + const childTurn = events.find( + (event) => + event.type === "turn/started" && + "parentToolCallId" in event && + event.parentToolCallId !== undefined, + ); + expect(childTurn).toMatchObject({ parentToolCallId: delegation.id }); + const childMessage = items.find( + (event) => + event.type === "item/completed" && + event.item.type === "agentMessage" && + event.item.parentToolCallId !== undefined, + ); + expect(childMessage?.item).toMatchObject({ + text: `child echo: ${PROMPT}`, + parentToolCallId: delegation.id, + }); + expect(childMessage?.scope).toEqual(childTurn?.scope); + + // planSteps: the full list, settled. + expect(completedItem(events, "planSteps")).toMatchObject({ + steps: [ + { step: "Hear the prompt", status: "completed" }, + { step: `Echo "${PROMPT}"`, status: "completed" }, + { step: "Write the receipt", status: "completed" }, + ], + explanation: "The echo agent's three-step plan.", + presentation: { icon: { glyph: "ListTodo" }, title: "Write the receipt" }, + }); + + // tools: the suppressed bookkeeping row, then the bb tool with the + // presentation the definition carried and the runtime's answer. + const tools = items + .filter( + (event) => + event.type === "item/completed" && event.item.type === "toolCall", + ) + .map((event) => event.item); + expect(tools[0]).toMatchObject({ + tool: "echo_noop", + result: "ahem", + presentation: { suppress: true, icon: { glyph: "Toolbox" } }, + }); + expect(tools[0]).not.toHaveProperty("server"); + expect(tools[1]).toMatchObject({ + tool: ECHO_STAMP_TOOL_NAME, + server: "bb", + arguments: { text: PROMPT }, + result: `stamped: ${PROMPT}`, + status: "completed", + presentation: ECHO_STAMP_TOOL_PRESENTATION, + }); + // The call id the bridge sent is its provider item id; the runtime maps + // it to this bb id through the assembler. + expect( + collector.assembler.getBbItemId(THREAD_ID, String(callParams.callId)), + ).toBe(tools[1]?.id); + + // The extension item and the extension state. + expect(completedItem(events, "extension")).toMatchObject({ + kind: ECHO_RECEIPT_KIND, + payload: { prompt: PROMPT, itemCount: 7, shouted: true }, + status: "completed", + presentation: { + label: { pending: "Writing receipt", completed: "Wrote receipt" }, + icon: { glyph: "PackageReceive" }, + title: PROMPT, + detail: "Echoed 7 items, shouting.", + tint: { light: "#047857", dark: "#6ee7b7" }, + }, + }); + const mood = events.find( + (event) => event.type === "thread/extensionState/updated", + ); + expect(mood).toMatchObject({ + kind: ECHO_MOOD_KIND, + payload: { mood: "cheerful", turnsEchoed: 1 }, + }); + + // The echoed message proves the round trips: the derived providerOptions + // (shout from the plugin's setting) and the passed-through env var. + const message = items + .filter( + (event) => + event.type === "item/completed" && + event.item.type === "agentMessage" && + event.item.parentToolCallId === undefined, + ) + .map((event) => event.item) + .at(-1); + expect(message).toMatchObject({ + text: [ + "echo: HELLO WORLD", + "providerOptions (server): shout=true model=echo-1 promptMode=none", + `${ECHO_GREETING_ENV}=hi from the daemon`, + `${ECHO_STAMP_TOOL_NAME}: stamped: ${PROMPT}`, + ].join("\n"), + presentation: { label: { pending: "Echoing", completed: "Echoed" } }, + }); + + // Usage and the context window ride the one usage dialect. + expect( + events.find((event) => event.type === "thread/tokenUsage/updated"), + ).toMatchObject({ + tokenUsage: { + total: { inputTokens: PROMPT.length }, + last: { inputTokens: PROMPT.length }, + }, + }); + expect( + events.some((event) => event.type === "thread/contextWindowUsage/updated"), + ).toBe(true); + }); + + it("emits the malformed receipt payload the server must reject", async () => { + await request("initialize", { + protocolVersion: 2, + client: { name: "echo-stream-test", version: "0.0.0" }, + grammarVersions: [3, 3], + }); + await startSession({ input: textInput("malformed-receipt please") }); + const receipt = completedItem(assembledEvents(), "extension"); + // The wire carries it (the payload is opaque at this layer); ingest is + // where the plugin's declared schema rejects it — see the server test. + expect(receipt).toMatchObject({ + kind: ECHO_RECEIPT_KIND, + payload: { prompt: 42, itemCount: "many" }, + presentation: { label: { completed: "Wrote receipt" } }, + }); + }); + + it("settles a zero-work turn and falls back to defaults without providerOptions", async () => { + await request("initialize", { + protocolVersion: 2, + client: { name: "echo-stream-test", version: "0.0.0" }, + grammarVersions: [3, 3], + }); + const providerThreadId = await startSession({}); + await request("turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: textInput("/noop"), + clientRequestId: "creq_ech2345679", + options: FULL_OPTIONS, + }); + const zeroWork = assembledEvents().map((event) => event.type); + expect(zeroWork).toEqual([ + "turn/started", + "turn/input/accepted", + "turn/completed", + ]); + + await request("turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: textInput("plain"), + clientRequestId: "creq_ech234567a", + options: FULL_OPTIONS, + }); + const events = assembledEvents(); + // No bb tool was injected, so no tool call and no stamp row. + expect( + harness.messages.some((message) => message.method === "item/tool/call"), + ).toBe(false); + const tools = itemEvents(events) + .filter((event) => event.type === "item/completed") + .map((event) => event.item) + .filter((item) => item.type === "toolCall"); + expect(tools.map((item) => item.tool)).toEqual(["echo_noop"]); + const message = itemEvents(events) + .filter( + (event) => + event.type === "item/completed" && + event.item.type === "agentMessage" && + event.item.parentToolCallId === undefined, + ) + .at(-1); + expect(message?.item).toMatchObject({ + text: expect.stringContaining( + "providerOptions (defaults): shout=false model=echo-1 promptMode=none", + ), + }); + expect(message?.item).toMatchObject({ + text: expect.stringContaining("echo: plain"), + }); + }); +}); diff --git a/examples/plugins/echo-provider/public-sdk-only.test.ts b/examples/plugins/echo-provider/public-sdk-only.test.ts new file mode 100644 index 0000000000..fd822a8f94 --- /dev/null +++ b/examples/plugins/echo-provider/public-sdk-only.test.ts @@ -0,0 +1,109 @@ +/** + * The rule this example exists to prove: a third-party provider plugin + * reaches EVERY capability through the public SDK alone. No file in this + * package may import a private `@bb/*` workspace package — not the plugin + * code, not the tests. Plugin code may import only `@get-bb/plugin-sdk` (and + * its published subpaths), `zod`, node built-ins, and its own files; tests + * may add `@get-bb/plugin-sdk/provider-bridge/testing` and the test runner. + * + * A `@bb/*` import would still typecheck and run inside this monorepo, which + * is exactly why it needs a test: the workspace hides the privilege. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageRoot = dirname(fileURLToPath(import.meta.url)); + +const SKIPPED_DIRECTORIES = new Set(["node_modules", "dist"]); +const SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]s|tsx)$/u; + +/** Specifiers plugin code (server, host, bridge) may import. */ +const PLUGIN_IMPORT_ALLOWLIST = [ + /^@get-bb\/plugin-sdk$/u, + /^@get-bb\/plugin-sdk\/(?:provider-bridge|host|app)$/u, + /^zod$/u, + /^node:/u, + /^\.\.?\//u, +]; + +/** What a test file may import beyond the plugin allowlist. */ +const TEST_IMPORT_ALLOWLIST = [ + /^@get-bb\/plugin-sdk\/provider-bridge\/testing$/u, + /^vitest$/u, +]; + +const IMPORT_SPECIFIER_PATTERN = + /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']([^"']+)["']/gu; + +function listSourceFiles(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + files.push(...listSourceFiles(join(directory, entry.name))); + } + continue; + } + if (SOURCE_EXTENSIONS.test(entry.name)) { + files.push(join(directory, entry.name)); + } + } + return files; +} + +function importSpecifiers(source: string): string[] { + return [...source.matchAll(IMPORT_SPECIFIER_PATTERN)].map( + (match) => match[1] ?? "", + ); +} + +function isTestFile(path: string): boolean { + return /\.test\.[cm]?[jt]sx?$/u.test(path); +} + +describe("echo-provider imports only the public SDK", () => { + const files = listSourceFiles(packageRoot); + + it("scans the plugin's source files", () => { + const names = files.map((file) => relative(packageRoot, file)); + expect(names).toContain("server.ts"); + expect(names).toContain("host.ts"); + expect(names).toContain(join("src", "provider-bridge.ts")); + }); + + for (const file of files) { + const name = relative(packageRoot, file); + it(`${name} has no @bb/* import and stays inside the allowlist`, () => { + const source = readFileSync(file, "utf8"); + const specifiers = importSpecifiers(source); + const privateImports = specifiers.filter((specifier) => + specifier.startsWith("@bb/"), + ); + expect(privateImports, `${name} imports private packages`).toEqual([]); + + const allowlist = isTestFile(file) + ? [...PLUGIN_IMPORT_ALLOWLIST, ...TEST_IMPORT_ALLOWLIST] + : PLUGIN_IMPORT_ALLOWLIST; + const disallowed = specifiers.filter( + (specifier) => !allowlist.some((pattern) => pattern.test(specifier)), + ); + expect(disallowed, `${name} imports outside the allowlist`).toEqual([]); + }); + } + + it("declares no @bb/* dependency in package.json", () => { + const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const declared = [ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ]; + expect(declared.filter((name) => name.startsWith("@bb/"))).toEqual([]); + }); +}); diff --git a/examples/plugins/echo-provider/server.ts b/examples/plugins/echo-provider/server.ts index 8a9ddfd3f8..70322c58f2 100644 --- a/examples/plugins/echo-provider/server.ts +++ b/examples/plugins/echo-provider/server.ts @@ -1,42 +1,123 @@ /** - * Echo provider — a complete third-party agent provider plugin. + * Echo provider — the third-party canary for bb's provider plugin API. * - * Surfaces demonstrated: - * - bb.providers.register: the provider declaration (id, picker metadata, - * and pre-session capability facts). Metadata only — the - * implementation is the bridge below, and a declaration without one is - * refused. - * - bb.host (package.json): the plugin's one host artifact. host.ts exports - * both the provider bridge (named export, run by the daemon's bridge - * bootstrap) and a host RPC entry (default export, run by the daemon's host - * worker) — one artifact, two consumers, two process lifecycles. The server - * stores it content-addressed and enrolled host daemons download, - * hash-verify, and run it. + * A marketplace plugin must be able to do everything the first-party + * providers do, using ONLY the public SDK (`@get-bb/plugin-sdk` and its + * subpaths) plus zod. This plugin exercises every registration capability + * and, in its bridge, every grammar v3 timeline capability, so the public + * surface is proven by code that has no first-party privilege. The test + * `public-sdk-only.test.ts` fails the moment a `@bb/*` import appears here. * - * The bridge itself lives in src/provider-bridge.ts and implements the - * canonical Provider Bridge Protocol (docs/provider-bridge-protocol.md) - * minimally but correctly — its conformance test drives the official kit - * against it in-process. + * Surfaces demonstrated in this file: + * - bb.settings.define — a plugin-owned toggle (`shout`) that reaches the + * bridge through `experimental_deriveProviderOptions` → `providerOptions`. + * - bb.agents.registerTool — a bb tool with `experimental_presentation`; the + * bridge calls it over `item/tool/call` and stamps the definition's + * presentation on the call's row beside `server: "bb"`. + * - bb.providers.register — the full declaration: strings (with icon tint), + * labelled reasoning levels and service tiers, capabilities, composer + * actions, cold-cache fallback models, env passthrough, provider options, + * and two extension kinds (an item kind and a state kind) with zod schemas + * the server enforces at ingest. + * - bb.host (package.json) — the one host artifact carrying the bridge + * (`experimental_providerBridge`) and a host RPC entry (see host.ts). */ import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { + ECHO_GREETING_ENV, + ECHO_MODEL_ID, + ECHO_PROVIDER_ID, + ECHO_STAMP_TOOL_NAME, + ECHO_STAMP_TOOL_PRESENTATION, + echoExtensionKinds, + echoStampToolParametersSchema, + type EchoProviderOptions, +} from "./src/vocabulary.js"; export default function plugin(bb: BbPluginApi) { + bb.settings.define({ + shout: { + type: "boolean", + label: "Shout", + description: "Echo every prompt in upper case.", + default: false, + }, + }); + + // A bb tool the echo bridge calls during every turn. The row it produces + // reads the way this presentation says — resolved by the server into the + // tool definition, stamped by the bridge, persisted with the item. + bb.agents.registerTool({ + name: ECHO_STAMP_TOOL_NAME, + description: "Stamp a piece of text with the echo provider's seal.", + parameters: echoStampToolParametersSchema, + experimental_presentation: ECHO_STAMP_TOOL_PRESENTATION, + execute: ({ text }) => `stamped: ${text}`, + }); + bb.providers.register({ - id: "echo-agent", - displayName: "Echo Agent", + id: ECHO_PROVIDER_ID, + displayName: "Echo", + icon: "Zap", + experimental_strings: { + signInHint: "Nothing to sign in to: the echo agent runs offline.", + expiredHint: "Echo sessions never expire.", + installUrl: + "https://github.com/get-bb/bb/tree/main/examples/plugins/echo-provider", + brandPrefix: "Echo ", + planModeCopy: "Echo will repeat your plan without running anything.", + iconTint: { light: "#b45309", dark: "#fcd34d" }, + }, capabilities: { experimental_providerHealth: false, experimental_providerUsage: false, experimental_providerInstallation: false, - supportsServiceTier: false, + supportsServiceTier: true, supportsNativeUserQuestion: false, fork: "none", supportsManualCompaction: false, supportsThreadArchive: false, supportsThreadRename: false, - permissionModes: ["full"], - reasoningLevels: ["medium"], + permissionModes: ["accept-edits", "auto", "full"], + reasoningLevels: ["low", "medium", "high"], + }, + experimental_reasoningLevels: [ + { id: "low", label: "Whisper" }, + { id: "medium", label: "Speak" }, + { id: "high", label: "Shout", description: "Echo with conviction." }, + ], + experimental_serviceTiers: [ + { id: "default", label: "Default" }, + { id: "fast", label: "Fast" }, + ], + composerActions: ["plan"], + experimental_models: { + fallback: [ + { + id: ECHO_MODEL_ID, + displayName: "Echo 1", + description: "Repeats what it hears.", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Whisper" }, + { reasoningEffort: "medium", description: "Speak" }, + { reasoningEffort: "high", description: "Shout" }, + ], + defaultReasoningEffort: "medium", + isDefault: true, + }, + ], + }, + experimental_env: { passthrough: [ECHO_GREETING_ENV] }, + // Called on EVERY session and turn command. The bridge reads this back + // from `options.providerOptions` and echoes it, proving the round trip + // plugin setting → server → daemon → bridge → timeline. + experimental_deriveProviderOptions(context): EchoProviderOptions { + return { + shout: context.settings.shout === true, + model: context.model, + promptMode: context.promptMode ?? null, + }; }, - composerActions: [], + experimental_extensionKinds: echoExtensionKinds, }); } diff --git a/examples/plugins/echo-provider/src/provider-bridge.ts b/examples/plugins/echo-provider/src/provider-bridge.ts index 4b17a32037..7b5b971a70 100644 --- a/examples/plugins/echo-provider/src/provider-bridge.ts +++ b/examples/plugins/echo-provider/src/provider-bridge.ts @@ -1,40 +1,69 @@ /** - * The echo-agent provider bridge: the smallest correct implementation of the - * bb Provider Bridge Protocol (docs/provider-bridge-protocol.md). + * The echo-agent provider bridge: a complete grammar v3 implementation of + * the bb Provider Bridge Protocol (docs/provider-bridge-protocol.md) that + * exercises every timeline capability a provider plugin has — using only + * `@get-bb/plugin-sdk/provider-bridge` and zod. * - * `bb plugin build` bundles this file into a fully self-contained - * dist/provider-bridge.mjs; the host daemon downloads that artifact by - * content hash, verifies it, and runs it with its own node for every thread - * on this provider. Transport is line-delimited JSON-RPC 2.0 on - * stdin/stdout. + * `bb plugin build` bundles host.ts (which re-exports this module's + * `experimental_providerBridge`) into a self-contained artifact; the host + * daemon downloads it by content hash, verifies it, and runs it with its own + * node through the bridge bootstrap. Transport is line-delimited JSON-RPC + * 2.0 on stdin/stdout. * - * What "correct" means here, in protocol terms: - * - Hygiene: an unknown method answers METHOD_NOT_FOUND (-32601); invalid - * params answer INVALID_PARAMS (-32602) carrying the validation issues; a - * non-JSON line and an unsolicited response-shaped line are ignored and - * the bridge stays alive. The dispatch table is keyed by the protocol - * package's own method vocabulary, so it cannot drift from the schemas. - * - Handshake: initialize answers protocol version 2 — the narrow-grammar - * dialect — and grammar range [3, 3]. The runtime rejects any other - * protocol version, or a grammar range without 3, at spawn. - * - Grammar: the bridge emits `thread/delta` semantic deltas, never finished - * timeline events — the runtime's assembler mints every turn and item id - * and constructs the canonical events. Every accepted turn settles - * (`input.accepted` → `turn.open` → `turn.boundary`); every session - * construction (start and resume) opens with `session.reset` so the - * assembler drops any prior id space for the thread; a release stop - * fabricates nothing. + * Every accepted prompt runs the same scripted turn, so a reader (or a test) + * knows exactly which rows to expect. In `thread/delta` terms: + * + * input.accepted → turn.open + * → command (item.open + item.outputDelta + item.close) + * → fileRead (item.open + item.close) + * → search (item.open + item.close) + * → delegation (item.open, a keyed CHILD TURN linked by parentRef with + * its own streamed message, item.close with a summary) + * → planSteps (item.open + item.close, the full step list each time) + * → tool (a suppressed bookkeeping row: presentation.suppress) + * → tool, server "bb" — the plugin's own `echo_stamp` tool, called over + * `item/tool/call`; the row carries the presentation the + * server attached to the tool definition + * → extension item `echo-provider/receipt` (payload validated by the + * server against the plugin's declared schema) + * → extension.state `echo-provider/mood` (latest snapshot wins) + * → the echoed message (item.textDelta + item.textClose), which also + * reports the providerOptions the plugin derived from its settings and + * the daemon env var the declaration passed through + * → usage → turn.boundary + * + * EVERY item.open and item.close carries a declarative `presentation`, so + * each row renders on every client without plugin code. + * + * Prompt directives (plain words anywhere in the prompt): + * - `/noop`: a zero-work turn — accepted and settled without any activity. + * - `malformed-receipt`: the receipt payload violates the declared schema, + * so the server persists a `provider/unhandled` in its place. + * + * Protocol hygiene: an unknown method answers METHOD_NOT_FOUND (-32601); + * invalid params answer INVALID_PARAMS (-32602) with the issues; a non-JSON + * line and an unsolicited response-shaped line are ignored and the bridge + * stays alive. The dispatch table is keyed by the protocol package's own + * method vocabulary, so it cannot drift from the schemas. */ import { type ClientTurnRequestId, + type DeltaPresentation, + type DynamicTool, type PromptInput, type ThreadDelta, + type ThreadEventTokenUsageBreakdown, + BRIDGE_INBOUND_REQUEST_METHODS, BRIDGE_JSON_RPC_ERRORS, BRIDGE_NOTIFICATION_METHODS, BRIDGE_REQUEST_METHODS, PROVIDER_BRIDGE_PROTOCOL_VERSION, THREAD_DELTA_GRAMMAR_V3, THREAD_DELTA_NOTIFICATION_METHOD, + ZERO_TOKEN_USAGE, + addTokenUsage, + decodeToolCallResponsePayload, + experimental_defineProviderBridge, initializeParamsSchema, modelListParamsSchema, threadResumeParamsSchema, @@ -42,24 +71,56 @@ import { threadStopParamsSchema, turnStartParamsSchema, turnSteerParamsSchema, - experimental_defineProviderBridge, } from "@get-bb/plugin-sdk/provider-bridge"; import { randomUUID } from "node:crypto"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; +import { + AGENT_MESSAGE_PRESENTATION, + ECHO_GREETING_ENV, + ECHO_MODEL_ID, + ECHO_MOOD_KIND, + ECHO_RECEIPT_KIND, + ECHO_STAMP_TOOL_NAME, + NOOP_TOOL_PRESENTATION, + commandPresentation, + delegationPresentation, + echoProviderOptionsSchema, + fileReadPresentation, + planStepsPresentation, + receiptPresentation, + searchPresentation, + type EchoMood, + type EchoProviderOptions, + type EchoReceipt, +} from "./vocabulary.js"; // --------------------------------------------------------------------------- -// State: one bridge process serves many threads; sessions are in-memory only -// (the echo agent has nothing to persist, so its handshake advertises no -// sessionRestore and every capability defaults to "no"). +// State: one bridge process serves many threads. Sessions are in-memory only +// — the echo agent has nothing to persist, and a resume re-adopts whatever +// provider thread id the runtime hands back, which is why the handshake can +// honestly report `sessionRestore: true`. // --------------------------------------------------------------------------- /** Per-instance entropy baked into minted provider thread ids. */ const instanceNonce = randomUUID().replaceAll("-", "").slice(0, 12); let threadCounter = 0; -/** threadId → providerThreadId for sessions this instance has opened. */ -const sessions = new Map(); +interface Session { + threadId: string; + providerThreadId: string; + cwd: string; + /** Turns echoed since this session was constructed (mood state). */ + turnsEchoed: number; + /** Running usage total, reset at every session construction. */ + usageTotal: ThreadEventTokenUsageBreakdown; + /** The bb tools the runtime injected at construction, by name. */ + tools: ReadonlyMap; +} + +/** bb threadId → session. */ +const sessions = new Map(); type JsonRpcId = string | number; @@ -94,10 +155,31 @@ function emitDeltas(threadId: string, deltas: ThreadDelta[]): void { } // --------------------------------------------------------------------------- -// The echo turn, in deltas: input.accepted → turn.open → a streamed assistant -// message → turn.boundary. The runtime's assembler turns this into the -// canonical accepted/started/item/completed event sequence with ids it mints -// itself. Turns settle synchronously — echoing needs no provider round-trip. +// Requests the bridge makes of the runtime (item/tool/call) and the replies +// it waits for. Requests and responses share one id space on the channel; +// the bridge numbers its own from 1 with a distinctive prefix. +// --------------------------------------------------------------------------- + +let outboundRequestCounter = 0; + +interface PendingToolCall { + turn: TurnContext; +} + +const pendingToolCalls = new Map(); + +function sendRequest( + method: string, + params: Record, +): string { + outboundRequestCounter += 1; + const id = `echo-req-${outboundRequestCounter}`; + writeMessage({ id, method, params }); + return id; +} + +// --------------------------------------------------------------------------- +// The scripted echo turn // --------------------------------------------------------------------------- function promptText(input: readonly PromptInput[]): string { @@ -110,13 +192,51 @@ function promptText(input: readonly PromptInput[]): string { .join(""); } +/** + * What the plugin derived for this command. A missing or malformed bag reads + * as the defaults — the conformance kit and the runtime unit suites send + * none — but a real server always sends one, and the echoed message shows + * which case this was. + */ +function parseProviderOptions( + options: unknown, +): { source: "server" | "defaults"; values: EchoProviderOptions } { + const parsed = echoProviderOptionsSchema.safeParse(options); + if (parsed.success) { + return { source: "server", values: parsed.data }; + } + return { + source: "defaults", + values: { shout: false, model: ECHO_MODEL_ID, promptMode: null }, + }; +} + +interface TurnContext { + session: Session; + /** Session-unique turn ordinal; prefixes every provider item id. */ + ordinal: number; + prompt: string; + providerOptions: ReturnType; + /** Items opened before the receipt (the receipt's `itemCount`). */ + itemCount: number; + malformedReceipt: boolean; + /** The bb tool item awaiting its `item/tool/call` reply, if any. */ + stamp: { itemId: string; presentation: DeltaPresentation | undefined } | null; +} + +function itemId(turn: TurnContext, name: string): string { + return `echo-${turn.session.providerThreadId}-t${turn.ordinal}-${name}`; +} + function runEchoTurn(args: { - threadId: string; + session: Session; input: readonly PromptInput[]; + options: unknown; /** Present only for turn/start; thread/start input has no request id. */ clientRequestId?: ClientTurnRequestId; }): void { - const text = `echo: ${promptText(args.input)}`; + const { session } = args; + const prompt = promptText(args.input); const deltas: ThreadDelta[] = []; // The provider consumed the input. thread/start input carries no // clientRequestId, so a first-turn-on-start emits no acceptance. @@ -126,48 +246,449 @@ function runEchoTurn(args: { clientRequestId: args.clientRequestId, }); } - deltas.push( - { kind: "turn.open" }, - // A streamed assistant message on an anonymous stream (the echo agent - // names no item ids, so the key is a bridge-chosen channel): the - // assembler synthesizes item/started for the delta-first stream, and the - // close's `text` is the provider's final text for the completed item. + + // A zero-work turn: accepted and settled with no activity in between. The + // boundary claims the turn the pending acceptance opened (`claimIfIdle`) + // so the thread never hangs active behind a turn that did nothing. + if (/(?:^|\s)\/noop(?:\s|$)/u.test(prompt)) { + deltas.push({ kind: "turn.boundary", status: "completed", claimIfIdle: true }); + emitDeltas(session.threadId, deltas); + return; + } + + session.turnsEchoed += 1; + const turn: TurnContext = { + session, + ordinal: session.turnsEchoed, + prompt, + providerOptions: parseProviderOptions(args.options), + itemCount: 0, + malformedReceipt: /(?:^|\s)malformed-receipt(?:\s|$)/u.test(prompt), + stamp: null, + }; + deltas.push({ kind: "turn.open" }); + deltas.push(...commandDeltas(turn)); + deltas.push(...fileReadDeltas(turn)); + deltas.push(...searchDeltas(turn)); + deltas.push(...delegationDeltas(turn)); + deltas.push(...planStepsDeltas(turn)); + deltas.push(...suppressedToolDeltas(turn)); + + // The bb tool: only when the runtime injected it (a real server always + // does once the plugin is installed; the conformance kit never does). + const stampTool = session.tools.get(ECHO_STAMP_TOOL_NAME); + if (stampTool !== undefined) { + const id = itemId(turn, "stamp"); + turn.stamp = { itemId: id, presentation: stampTool.presentation }; + turn.itemCount += 1; + deltas.push({ + kind: "item.open", + key: { providerItemId: id }, + item: { + type: "tool", + tool: ECHO_STAMP_TOOL_NAME, + server: "bb", + args: { text: prompt }, + }, + ...(stampTool.presentation === undefined + ? {} + : { presentation: stampTool.presentation }), + }); + emitDeltas(session.threadId, deltas); + // `providerNativeIds`: the runtime maps this call id through the + // assembler to the bb item id it minted for the row above, and resolves + // the turn from the open one (`turnId: null`). + const requestId = sendRequest(BRIDGE_INBOUND_REQUEST_METHODS.toolCall, { + providerThreadId: session.providerThreadId, + threadId: session.threadId, + turnId: null, + callId: id, + tool: ECHO_STAMP_TOOL_NAME, + arguments: { text: prompt }, + providerNativeIds: true, + }); + pendingToolCalls.set(requestId, { turn }); + return; + } + emitDeltas(session.threadId, deltas); + finishEchoTurn(turn, null); +} + +/** A shell command with streamed output. */ +function commandDeltas(turn: TurnContext): ThreadDelta[] { + const id = itemId(turn, "command"); + const command = `echo ${JSON.stringify(turn.prompt)}`; + const output = `${turn.prompt}\n`; + const presentation = commandPresentation(command); + turn.itemCount += 1; + return [ + { + kind: "item.open", + key: { providerItemId: id }, + item: { type: "command", command, cwd: turn.session.cwd }, + presentation, + }, + { + kind: "item.outputDelta", + key: { providerItemId: id }, + channel: "command", + text: output, + }, + { + kind: "item.close", + key: { providerItemId: id }, + status: "completed", + exitCode: 0, + aggregatedOutput: output, + item: { + type: "command", + command, + cwd: turn.session.cwd, + aggregatedOutput: output, + exitCode: 0, + durationMs: 1, + }, + presentation, + }, + ]; +} + +function fileReadDeltas(turn: TurnContext): ThreadDelta[] { + const id = itemId(turn, "read"); + const path = join(turn.session.cwd, "README.md"); + const presentation = fileReadPresentation(path); + turn.itemCount += 1; + return [ + { + kind: "item.open", + key: { providerItemId: id }, + item: { type: "fileRead", path }, + presentation, + }, + { + kind: "item.close", + key: { providerItemId: id }, + status: "completed", + item: { type: "fileRead", path }, + presentation, + }, + ]; +} + +function searchDeltas(turn: TurnContext): ThreadDelta[] { + const id = itemId(turn, "search"); + const item = { + type: "search", + mode: "content", + query: turn.prompt, + path: turn.session.cwd, + } as const; + const presentation = searchPresentation(turn.prompt); + turn.itemCount += 1; + return [ + { kind: "item.open", key: { providerItemId: id }, item, presentation }, + { + kind: "item.close", + key: { providerItemId: id }, + status: "completed", + item, + presentation, + }, + ]; +} + +/** + * A delegation with a real child turn. The child turn is keyed + * (`providerTurnId`) and names the delegation as its `parentRef`, so the + * assembler links its `turn/started` (and every item inside it) to the + * delegation row through `parentToolCallId` — the one encoding for + * delegated work in grammar v3. + */ +function delegationDeltas(turn: TurnContext): ThreadDelta[] { + const id = itemId(turn, "delegate"); + const childTurnId = `${id}-turn`; + const childRef = `${id}-child`; + const childMessageId = `${id}-message`; + const label = `Echo "${turn.prompt}" one more time`; + const childText = `child echo: ${turn.prompt}`; + const presentation = delegationPresentation(label); + turn.itemCount += 1; + return [ + { + kind: "item.open", + key: { providerItemId: id }, + item: { type: "delegation", childRef, label, background: false }, + presentation, + }, + { kind: "turn.open", providerTurnId: childTurnId, parentRef: id }, + // The child's message is opened explicitly so it carries a presentation: + // a stream that opens itself through `item.textDelta` alone has nowhere + // to put one. The close echoes the opened presentation. + { + kind: "item.open", + key: { providerItemId: childMessageId, parentRef: id }, + item: { type: "agentMessage", text: "" }, + presentation: AGENT_MESSAGE_PRESENTATION, + providerTurnId: childTurnId, + }, { kind: "item.textDelta", - key: { channel: "echo" }, + key: { providerItemId: childMessageId, parentRef: id }, channel: "agentMessage", - text, + text: childText, + providerTurnId: childTurnId, }, { kind: "item.textClose", - key: { channel: "echo" }, + key: { providerItemId: childMessageId, parentRef: id }, channel: "agentMessage", - text, + text: childText, + providerTurnId: childTurnId, + }, + { kind: "turn.boundary", status: "completed", providerTurnId: childTurnId }, + { + kind: "item.close", + key: { providerItemId: id }, + status: "completed", + item: { + type: "delegation", + childRef, + label, + background: false, + summary: childText, + }, + presentation, + }, + ]; +} + +/** A plan snapshot: the full step list each time, the active step headlined. */ +function planStepsDeltas(turn: TurnContext): ThreadDelta[] { + const id = itemId(turn, "plan"); + const steps = [ + { step: "Hear the prompt", status: "completed" }, + { step: `Echo "${turn.prompt}"`, status: "active" }, + { step: "Write the receipt", status: "pending" }, + ] as const; + const explanation = "The echo agent's three-step plan."; + turn.itemCount += 1; + return [ + { + kind: "item.open", + key: { providerItemId: id }, + item: { type: "planSteps", steps: [...steps], explanation }, + presentation: planStepsPresentation(steps[1].step), + }, + { + kind: "item.close", + key: { providerItemId: id }, + status: "completed", + item: { + type: "planSteps", + steps: steps.map((step) => ({ step: step.step, status: "completed" })), + explanation, + }, + presentation: planStepsPresentation(steps[2].step), + }, + ]; +} + +/** A generic provider tool whose row is low-value: `presentation.suppress`. */ +function suppressedToolDeltas(turn: TurnContext): ThreadDelta[] { + const id = itemId(turn, "noop"); + turn.itemCount += 1; + return [ + { + kind: "item.open", + key: { providerItemId: id }, + item: { type: "tool", tool: "echo_noop", args: {} }, + presentation: NOOP_TOOL_PRESENTATION, + }, + { + kind: "item.close", + key: { providerItemId: id }, + status: "completed", + item: { type: "tool", tool: "echo_noop", args: {}, result: "ahem" }, + presentation: NOOP_TOOL_PRESENTATION, + }, + ]; +} + +/** + * The second half of the turn, after the bb tool answered (or immediately + * when no bb tool was injected): the tool row's close, the receipt, the mood, + * the echoed message, usage, and the boundary. + */ +function finishEchoTurn( + turn: TurnContext, + stamp: { content: string; isError: boolean } | null, +): void { + const { session } = turn; + const deltas: ThreadDelta[] = []; + + if (turn.stamp !== null) { + deltas.push({ + kind: "item.close", + key: { providerItemId: turn.stamp.itemId }, + status: stamp === null || stamp.isError ? "failed" : "completed", + item: { + type: "tool", + tool: ECHO_STAMP_TOOL_NAME, + server: "bb", + args: { text: turn.prompt }, + ...(stamp === null + ? { error: "no reply" } + : stamp.isError + ? { error: stamp.content } + : { result: stamp.content }), + }, + ...(turn.stamp.presentation === undefined + ? {} + : { presentation: turn.stamp.presentation }), + }); + } + + // The extension item. The payload is opaque on the wire; the server + // validates it against the schema this plugin declared for + // `echo-provider/receipt` and replaces a miss with `provider/unhandled`. + const receiptId = itemId(turn, "receipt"); + const receipt: EchoReceipt = { + prompt: turn.prompt, + itemCount: turn.itemCount, + shouted: turn.providerOptions.values.shout, + }; + const receiptPayload = turn.malformedReceipt + ? { prompt: 42, itemCount: "many" } + : receipt; + const receiptRow = receiptPresentation(receipt); + deltas.push( + { + kind: "item.open", + key: { providerItemId: receiptId }, + item: { type: "extension", kind: ECHO_RECEIPT_KIND, payload: receiptPayload }, + presentation: receiptRow, + }, + { + kind: "item.close", + key: { providerItemId: receiptId }, + status: "completed", + item: { type: "extension", kind: ECHO_RECEIPT_KIND, payload: receiptPayload }, + presentation: receiptRow, + }, + ); + + // Thread state: the whole snapshot every time, latest wins. + const mood: EchoMood = { + mood: session.turnsEchoed > 3 ? "bored" : "cheerful", + turnsEchoed: session.turnsEchoed, + }; + deltas.push({ + kind: "extension.state", + extensionKind: ECHO_MOOD_KIND, + payload: mood, + }); + + // The echoed message, with the round-trip evidence: what the plugin's + // deriveProviderOptions produced (from its settings) and the daemon env + // var the declaration passed through. + const options = turn.providerOptions.values; + const echoed = options.shout ? turn.prompt.toUpperCase() : turn.prompt; + const greeting = process.env[ECHO_GREETING_ENV]; + const lines = [ + `echo: ${echoed}`, + `providerOptions (${turn.providerOptions.source}): shout=${String(options.shout)} model=${options.model} promptMode=${options.promptMode ?? "none"}`, + `${ECHO_GREETING_ENV}=${greeting === undefined ? "" : greeting}`, + ...(stamp === null ? [] : [`${ECHO_STAMP_TOOL_NAME}: ${stamp.content}`]), + ]; + const text = lines.join("\n"); + const messageKey = { providerItemId: itemId(turn, "message") }; + deltas.push( + { + kind: "item.open", + key: messageKey, + item: { type: "agentMessage", text: "" }, + presentation: AGENT_MESSAGE_PRESENTATION, + }, + // Streamed in two pieces, then settled with the provider-final text. + { + kind: "item.textDelta", + key: messageKey, + channel: "agentMessage", + text: lines[0] ?? "", + }, + { + kind: "item.textDelta", + key: messageKey, + channel: "agentMessage", + text: text.slice((lines[0] ?? "").length), + }, + { kind: "item.textClose", key: messageKey, channel: "agentMessage", text }, + ); + + // The one usage dialect: this turn's usage plus the running total. + const last: ThreadEventTokenUsageBreakdown = { + ...ZERO_TOKEN_USAGE, + inputTokens: turn.prompt.length, + outputTokens: text.length, + totalTokens: turn.prompt.length + text.length, + }; + session.usageTotal = addTokenUsage(session.usageTotal, last); + deltas.push( + { + kind: "usage", + total: session.usageTotal, + last, + modelContextWindow: 8192, + }, + { + kind: "contextWindow", + used: session.usageTotal.totalTokens, + size: 8192, + estimated: true, + attach: "open", }, { kind: "turn.boundary", status: "completed" }, ); - emitDeltas(args.threadId, deltas); + emitDeltas(session.threadId, deltas); } +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + /** * Every session construction is a provider id-space boundary: identity * precedes traffic, and `session.reset` tells the assembler to drop any * assembly state it still holds for the thread from a previous session. */ -function openSession(threadId: string, providerThreadId: string): void { - sessions.set(threadId, providerThreadId); +function openSession(args: { + threadId: string; + providerThreadId: string; + cwd: string; + dynamicTools: readonly DynamicTool[] | undefined; +}): Session { + const session: Session = { + threadId: args.threadId, + providerThreadId: args.providerThreadId, + cwd: args.cwd, + turnsEchoed: 0, + usageTotal: ZERO_TOKEN_USAGE, + tools: new Map((args.dynamicTools ?? []).map((tool) => [tool.name, tool])), + }; + sessions.set(args.threadId, session); notify(BRIDGE_NOTIFICATION_METHODS.threadIdentity, { - threadId, - providerThreadId, + threadId: args.threadId, + providerThreadId: args.providerThreadId, }); - emitDeltas(threadId, [{ kind: "session.reset" }]); + emitDeltas(args.threadId, [{ kind: "session.reset" }]); + return session; } // --------------------------------------------------------------------------- // Request handlers, keyed by the protocol vocabulary. A vocabulary method // with no handler here (thread/fork, thread/archive, …) answers -32601 like // any unknown method — the runtime only sends capability-gated methods to -// bridges that advertised them, and this bridge advertises none. +// bridges that advertised them, and this bridge advertises none of those. // --------------------------------------------------------------------------- type RequestHandler = (id: JsonRpcId, params: unknown) => void; @@ -188,15 +709,24 @@ const handlers: Record = { invalidParams(id, BRIDGE_REQUEST_METHODS.initialize, parsed.error.issues); return; } - // Session capabilities absent: sessionRestore, threadArchive, threadRename - // and threadGoalClear read false and fork reads "none", so the runtime - // will never send this bridge a capability-gated method. The grammar - // range is stated: the runtime assembles grammar v3 only, and a bridge - // that says nothing reads as v2 and is refused at the handshake. + // Session-behavior facts are REPORTED here, never declared: the code + // that implements a feature is the code that says it exists. The grammar + // range is stated explicitly — a bridge that says nothing reads as v2 + // and is refused at the handshake. respondResult(id, { protocolVersion: PROVIDER_BRIDGE_PROTOCOL_VERSION, capabilities: { grammarVersions: [THREAD_DELTA_GRAMMAR_V3, THREAD_DELTA_GRAMMAR_V3], + // Stateless resume: a released session re-attaches from its id. + sessionRestore: true, + threadArchive: false, + threadRename: false, + threadGoalClear: false, + fork: "none", + approvalEnforcedBy: "runtime", + // A steer never reaches a live echo turn; it waits for the next + // prompt boundary. + steerMode: "queue", }, }); }, @@ -207,8 +737,25 @@ const handlers: Record = { invalidParams(id, BRIDGE_REQUEST_METHODS.modelList, parsed.error.issues); return; } - // The echo agent exposes no models; the picker falls back to defaults. - respondResult(id, { models: [], selectedOnlyModels: [] }); + // The live model list replaces the declaration's cold-cache fallback. + respondResult(id, { + models: [ + { + id: ECHO_MODEL_ID, + model: ECHO_MODEL_ID, + displayName: "Echo 1", + description: "Repeats what it hears.", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Whisper" }, + { reasoningEffort: "medium", description: "Speak" }, + { reasoningEffort: "high", description: "Shout" }, + ], + defaultReasoningEffort: "medium", + isDefault: true, + }, + ], + selectedOnlyModels: [], + }); }, [BRIDGE_REQUEST_METHODS.threadStart]: (id, params) => { @@ -223,15 +770,21 @@ const handlers: Record = { } threadCounter += 1; const providerThreadId = `echo_${instanceNonce}_${threadCounter}`; - openSession(parsed.data.threadId, providerThreadId); - respondResult(id, { providerThreadId }); + const session = openSession({ + threadId: parsed.data.threadId, + providerThreadId, + cwd: parsed.data.cwd, + dynamicTools: parsed.data.dynamicTools, + }); + respondResult(id, { providerThreadId, sessionRestorable: true }); // A start that carries input runs its first turn immediately. It has no // clientRequestId (only turn/start and turn/steer carry one), so no // input.accepted delta is emitted for it. if (parsed.data.input !== undefined && parsed.data.input.length > 0) { runEchoTurn({ - threadId: parsed.data.threadId, + session, input: parsed.data.input, + options: parsed.data.options.providerOptions, }); } }, @@ -248,10 +801,18 @@ const handlers: Record = { } // Stateless resume: re-adopt the caller's provider thread id. The // session.reset inside openSession is what keeps assembler-minted turn - // and item ids unique across the resume even if this provider reused - // its native keys. - openSession(parsed.data.threadId, parsed.data.providerThreadId); - respondResult(id, { providerThreadId: parsed.data.providerThreadId }); + // and item ids unique across the resume even though this bridge reuses + // its native keys per session. + openSession({ + threadId: parsed.data.threadId, + providerThreadId: parsed.data.providerThreadId, + cwd: parsed.data.cwd, + dynamicTools: parsed.data.dynamicTools, + }); + respondResult(id, { + providerThreadId: parsed.data.providerThreadId, + sessionRestorable: true, + }); }, [BRIDGE_REQUEST_METHODS.turnStart]: (id, params) => { @@ -260,10 +821,20 @@ const handlers: Record = { invalidParams(id, BRIDGE_REQUEST_METHODS.turnStart, parsed.error.issues); return; } + const session = sessions.get(parsed.data.threadId); + if (session === undefined) { + respondError( + id, + BRIDGE_JSON_RPC_ERRORS.INVALID_PARAMS, + `No session for thread ${parsed.data.threadId}; send thread/start or thread/resume first`, + ); + return; + } respondResult(id, {}); runEchoTurn({ - threadId: parsed.data.threadId, + session, input: parsed.data.input, + options: parsed.data.options.providerOptions, clientRequestId: parsed.data.clientRequestId, }); }, @@ -274,8 +845,9 @@ const handlers: Record = { invalidParams(id, BRIDGE_REQUEST_METHODS.turnSteer, parsed.error.issues); return; } - // Echo turns settle synchronously, so a steer can never find its target - // turn still active. The honest reply is the typed protocol error. + // Echo turns settle as soon as the bb tool answers, so a steer can never + // find its target turn still active. The honest reply is the typed + // protocol error; the runtime then starts the steer text as a new turn. respondError( id, BRIDGE_JSON_RPC_ERRORS.NO_ACTIVE_TURN, @@ -291,7 +863,8 @@ const handlers: Record = { } // Both intents drop the in-memory session. `release` detaches an idle // session and must fabricate nothing; `interrupt` would settle an active - // turn, but echo turns are synchronous so none can be in flight. + // turn, but the only thing an echo turn ever waits on is its bb tool + // reply, which the runtime itself answers before it interrupts. sessions.delete(parsed.data.threadId); respondResult(id, {}); }, @@ -302,6 +875,46 @@ const handlers: Record = { // conformance kit's transport calls handleLine and drains captured stdout. // --------------------------------------------------------------------------- +const jsonRpcResponseSchema = z + .object({ + id: z.union([z.string(), z.number()]), + result: z.unknown().optional(), + error: z.unknown().optional(), + }) + .passthrough(); + +/** A reply to one of this bridge's own requests (the bb tool call). */ +function handleResponse(message: unknown): void { + const parsed = jsonRpcResponseSchema.safeParse(message); + if (!parsed.success || typeof parsed.data.id !== "string") { + return; + } + const pending = pendingToolCalls.get(parsed.data.id); + if (pending === undefined) { + // An unsolicited response-shaped line: ignored by design. + return; + } + pendingToolCalls.delete(parsed.data.id); + if (!sessions.has(pending.turn.session.threadId)) { + return; + } + if (parsed.data.error !== undefined) { + const error = z + .object({ message: z.string() }) + .safeParse(parsed.data.error); + finishEchoTurn(pending.turn, { + content: error.success ? error.data.message : "tool call failed", + isError: true, + }); + return; + } + const decoded = decodeToolCallResponsePayload(parsed.data.result); + finishEchoTurn(pending.turn, { + content: decoded.content, + isError: decoded.isError, + }); +} + export function handleLine(line: string): void { let message: unknown; try { @@ -323,8 +936,9 @@ export function handleLine(line: string): void { params?: unknown; }; // Request vs response is discriminated on the presence of `method`, never - // on result shape: a response-shaped line is not treated as a request. + // on result shape: a response-shaped line is never treated as a request. if (typeof method !== "string") { + handleResponse(message); return; } if (typeof id !== "string" && typeof id !== "number") { @@ -347,7 +961,7 @@ export function handleLine(line: string): void { * The bridge surface this plugin's host artifact exports. The daemon-side * bootstrap imports the artifact, finds this export, and owns the process: * argv, the plugin-scoped directories below, stdin framing, and signals. - * Importing this module (the conformance test does) starts nothing. + * Importing this module (the tests do) starts nothing. */ export const experimental_providerBridge = experimental_defineProviderBridge({ handleLine, diff --git a/examples/plugins/echo-provider/src/vocabulary.ts b/examples/plugins/echo-provider/src/vocabulary.ts new file mode 100644 index 0000000000..7325d995e8 --- /dev/null +++ b/examples/plugins/echo-provider/src/vocabulary.ts @@ -0,0 +1,190 @@ +/** + * The echo provider's own vocabulary: the plugin id (the namespace of its + * extension kinds), its extension kinds with their payload schemas, the bb + * tool it ships, and the presentation of every row its bridge opens. + * + * This module is shared by `server.ts` (the declaration) and + * `src/provider-bridge.ts` (the bridge that emits the rows), so the two can + * never disagree about a kind name or a schema. Everything here is plain + * data plus zod — it bundles into the host artifact untouched. + * + * Only `@get-bb/plugin-sdk/*` and `zod` are imported anywhere in this plugin. + * That is the rule this example exists to prove: a third-party provider + * plugin reaches every capability through the public SDK alone. + */ +import type { DeltaPresentation } from "@get-bb/plugin-sdk/provider-bridge"; +import { z } from "zod"; + +/** `bb-plugin-echo-provider` → plugin id `echo-provider` (see package.json). */ +export const ECHO_PLUGIN_ID = "echo-provider"; + +/** The provider id. Stable: thread rows persist it. */ +export const ECHO_PROVIDER_ID = "echo-agent"; + +/** The one model the bridge lists. */ +export const ECHO_MODEL_ID = "echo-1"; + +// --------------------------------------------------------------------------- +// Extension kinds (docs/provider-plugin-api.md §3) +// --------------------------------------------------------------------------- + +/** + * `echo-provider/receipt` — an ITEM kind. One receipt per echoed prompt: + * what was echoed and how much work the turn pretended to do. The server + * validates every receipt against this schema at ingest; a payload that + * misses it persists as `provider/unhandled` instead. + */ +export const ECHO_RECEIPT_KIND = `${ECHO_PLUGIN_ID}/receipt` as const; + +export const echoReceiptSchema = z.object({ + prompt: z.string(), + /** Number of timeline items the echo turn opened before the receipt. */ + itemCount: z.number().int().nonnegative(), + /** Whether the "shout" setting upper-cased the echo. */ + shouted: z.boolean(), +}); +export type EchoReceipt = z.infer; + +/** + * `echo-provider/mood` — a STATE kind. Latest snapshot wins per thread: the + * bridge re-sends the whole value after every turn, never a diff. + */ +export const ECHO_MOOD_KIND = `${ECHO_PLUGIN_ID}/mood` as const; + +export const echoMoodSchema = z.object({ + mood: z.enum(["cheerful", "bored"]), + /** Turns this session has echoed so far. */ + turnsEchoed: z.number().int().nonnegative(), +}); +export type EchoMood = z.infer; + +/** What `bb.providers.register` declares (`experimental_extensionKinds`). */ +export const echoExtensionKinds = { + receipt: { item: echoReceiptSchema }, + mood: { state: echoMoodSchema }, +} as const; + +// --------------------------------------------------------------------------- +// Provider options (plugin settings → bridge) and env passthrough +// --------------------------------------------------------------------------- + +/** + * The bag `experimental_deriveProviderOptions` returns on every command and + * the bridge reads back from `options.providerOptions`. Core never + * interprets it; the bridge validates it with this schema. + */ +export const echoProviderOptionsSchema = z.object({ + /** The plugin's `shout` setting (`bb.settings.define`). */ + shout: z.boolean(), + /** The resolved model the server handed this command. */ + model: z.string(), + /** `"plan"` when the composer entered plan mode through this provider. */ + promptMode: z.enum(["plan"]).nullable(), +}); +export type EchoProviderOptions = z.infer; + +/** + * The one daemon env var the bridge reads. Provider processes are spawned + * with every inherited `BB_*` variable stripped; the declaration's + * `experimental_env.passthrough` names this one so the daemon forwards it. + */ +export const ECHO_GREETING_ENV = "BB_ECHO_PROVIDER_GREETING"; + +// --------------------------------------------------------------------------- +// The bb tool this plugin ships (bb.agents.registerTool) +// --------------------------------------------------------------------------- + +export const ECHO_STAMP_TOOL_NAME = "echo_stamp"; + +export const echoStampToolParametersSchema = z.object({ + text: z.string().min(1), +}); + +/** + * How a call to `echo_stamp` reads as a timeline row. Declared once on the + * tool registration; the server resolves it into the tool definition it + * hands the bridge, and the bridge stamps it on the call's item beside + * `server: "bb"` — no tool-name table anywhere. + */ +export const ECHO_STAMP_TOOL_PRESENTATION = { + label: { pending: "Stamping receipt", completed: "Stamped receipt" }, + icon: { glyph: "Check" }, + tint: { light: "#1d4ed8", dark: "#93c5fd" }, +} as const; + +// --------------------------------------------------------------------------- +// Presentation for every row the bridge opens +// --------------------------------------------------------------------------- + +/** Row headlines stay one line and short; the item carries the full text. */ +const TITLE_MAX_LENGTH = 160; + +export function presentationTitle(text: string): string { + const firstLine = text.trim().split("\n", 1)[0]?.trim() ?? ""; + return firstLine.length > TITLE_MAX_LENGTH + ? `${firstLine.slice(0, TITLE_MAX_LENGTH - 1)}…` + : firstLine; +} + +export const AGENT_MESSAGE_PRESENTATION: DeltaPresentation = { + label: { pending: "Echoing", completed: "Echoed" }, + icon: { glyph: "Repeat" }, +}; + +export function commandPresentation(command: string): DeltaPresentation { + return { + label: { pending: "Running command", completed: "Ran command" }, + icon: { glyph: "Terminal" }, + title: presentationTitle(command), + }; +} + +export function fileReadPresentation(path: string): DeltaPresentation { + return { + label: { pending: "Reading file", completed: "Read file" }, + icon: { glyph: "FileText" }, + title: presentationTitle(path), + }; +} + +export function searchPresentation(query: string): DeltaPresentation { + return { + label: { pending: "Searching files", completed: "Searched files" }, + icon: { glyph: "Search" }, + title: presentationTitle(query), + }; +} + +export function delegationPresentation(label: string): DeltaPresentation { + return { + label: { pending: "Running echo child", completed: "Echo child finished" }, + icon: { glyph: "UserRound" }, + title: presentationTitle(label), + detail: "A scripted child turn, linked to this row through its parentRef.", + }; +} + +export function planStepsPresentation(activeStep: string): DeltaPresentation { + return { + label: { pending: "Updating plan", completed: "Updated plan" }, + icon: { glyph: "ListTodo" }, + title: presentationTitle(activeStep), + }; +} + +/** A low-value bookkeeping tool: clients collapse the row by default. */ +export const NOOP_TOOL_PRESENTATION: DeltaPresentation = { + label: { pending: "Clearing throat", completed: "Cleared throat" }, + icon: { glyph: "Toolbox" }, + suppress: true, +}; + +export function receiptPresentation(receipt: EchoReceipt): DeltaPresentation { + return { + label: { pending: "Writing receipt", completed: "Wrote receipt" }, + icon: { glyph: "PackageReceive" }, + title: presentationTitle(receipt.prompt), + detail: `Echoed ${receipt.itemCount} item${receipt.itemCount === 1 ? "" : "s"}${receipt.shouted ? ", shouting" : ""}.`, + tint: { light: "#047857", dark: "#6ee7b7" }, + }; +} diff --git a/examples/plugins/echo-provider/tsconfig.json b/examples/plugins/echo-provider/tsconfig.json index 4627fea494..fe526e8274 100644 --- a/examples/plugins/echo-provider/tsconfig.json +++ b/examples/plugins/echo-provider/tsconfig.json @@ -4,15 +4,17 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "noEmit": true, "skipLibCheck": true, - "types": ["node"] + "types": [ + "node" + ] }, "include": [ - "server.ts", - "src", - "provider-bridge.conformance.test.ts", - "vitest.config.ts" + "*.ts", + "src" ] } From c53e2cc8cf484be31b87114a71b2579dc77ace23 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 14:12:15 +0000 Subject: [PATCH 2/3] server: run the echo-provider canary through install, runtime, assembler and ingest Installs examples/plugins/echo-provider from its checkout path, builds the real thread.start command (settings-derived providerOptions, the bb tool with resolved presentation, the artifact bridgeLaunch), runs it on the real agent runtime, posts every event through /internal/session/events and answers the tool call through /internal/session/tool-call, then asserts the persisted rows: presentation on every item, the validated extension item and state, the parentToolCallId-linked child turn, planSteps, the server:"bb" tool row, and a malformed extension payload replaced by provider/unhandled. @bb/agent-runtime joins the server's devDependencies for this test only. Co-Authored-By: Claude --- apps/server/package.json | 1 + .../providers/echo-provider-canary.test.ts | 503 ++++++++++++++++++ pnpm-lock.yaml | 9 +- turbo.json | 3 + 4 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 apps/server/test/providers/echo-provider-canary.test.ts diff --git a/apps/server/package.json b/apps/server/package.json index b88790d090..3cee77d5d1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -51,6 +51,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@bb/agent-runtime": "workspace:*", "@bb/scripts": "workspace:*", "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", diff --git a/apps/server/test/providers/echo-provider-canary.test.ts b/apps/server/test/providers/echo-provider-canary.test.ts new file mode 100644 index 0000000000..b2d99d996f --- /dev/null +++ b/apps/server/test/providers/echo-provider-canary.test.ts @@ -0,0 +1,503 @@ +/** + * The third-party canary, end to end on the server side. + * + * `examples/plugins/echo-provider` is a provider plugin that uses ONLY the + * public SDK. This test installs it the way a user would (from its checkout + * path), lets the server build the real thread command for it (plugin + * settings → `deriveProviderOptions` → `providerOptions`; the plugin's bb + * tool → `dynamicTools` with its resolved presentation; the built host + * artifact → `bridgeLaunch`), runs that command on the REAL agent runtime + * (bridge bootstrap → the artifact → bridge-protocol adapter → delta + * assembler), feeds every runtime event through the REAL ingest route the + * daemon uses, answers the bridge's tool call through the REAL tool-call + * route, and then reads the rows back out of the database. + * + * What it proves, row by row: presentation persisted on every item; the + * extension item validated against the plugin's declared schema (and a + * malformed payload replaced by `provider/unhandled`); the extension state + * row; the delegation's child turn linked by `parentToolCallId`; the + * planSteps snapshot; the bb tool stamped `server: "bb"` with the + * definition's presentation and the result the plugin's own `execute` + * produced; and the settings/env round trip echoed into the message. + * + * Core test code, so `@bb/*` imports are fine here; the plugin under test has + * none (its own suite guards that). + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createAgentRuntime, type AgentRuntime } from "@bb/agent-runtime"; +import { events } from "@bb/db"; +import { + encodeClientTurnRequestIdNumber, + toolCallResponseSchema, + type ThreadEvent, + type ToolCallRequest, + type ToolCallResponse, +} from "@bb/domain"; +import { groupHostDaemonEvents } from "@bb/host-daemon-contract"; +import { buildThreadStartCommand } from "../../src/services/threads/thread-commands.js"; +import { resolveExecutionOptions } from "../../src/services/threads/thread-runtime-config.js"; +import { internalAuthHeaders } from "../helpers/commands.js"; +import { textInput } from "../helpers/prompt-input.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, +} from "../helpers/seed.js"; +import { createTestAppHarness, type TestAppHarness } from "../helpers/test-app.js"; + +const ECHO_PLUGIN_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../examples/plugins/echo-provider", +); +const PLUGIN_ID = "echo-provider"; +const PROVIDER_ID = "echo-agent"; +const RECEIPT_KIND = `${PLUGIN_ID}/receipt`; +const MOOD_KIND = `${PLUGIN_ID}/mood`; +const GREETING_ENV = "BB_ECHO_PROVIDER_GREETING"; +const STAMP_PRESENTATION = { + label: { pending: "Stamping receipt", completed: "Stamped receipt" }, + icon: { glyph: "Check" }, + tint: { light: "#1d4ed8", dark: "#93c5fd" }, +}; + +interface StoredRow { + type: string; + itemKind: string | null; + turnId: string | null; + data: Record & { + item?: Record; + parentToolCallId?: string; + }; +} + +function storedRows(harness: TestAppHarness, threadId: string): StoredRow[] { + return harness.db + .select({ + type: events.type, + itemKind: events.itemKind, + turnId: events.turnId, + data: events.data, + }) + .from(events) + .where(eq(events.threadId, threadId)) + .orderBy(events.sequence) + .all() + .map((row) => ({ + type: row.type, + itemKind: row.itemKind, + turnId: row.turnId, + data: JSON.parse(row.data) as StoredRow["data"], + })); +} + +function completedItems(rows: StoredRow[]): StoredRow[] { + return rows.filter((row) => row.type === "item/completed"); +} + +function itemOf(rows: StoredRow[], itemKind: string, tool?: string): StoredRow { + const row = completedItems(rows).find( + (candidate) => + candidate.itemKind === itemKind && + (tool === undefined || candidate.data.item?.tool === tool), + ); + expect(row, `a completed ${itemKind}${tool ? ` ${tool}` : ""} row`).toBeDefined(); + return row as StoredRow; +} + +function waitFor( + predicate: () => boolean, + label: string, + timeoutMs = 30_000, +): Promise { + const startedAt = Date.now(); + return new Promise((resolvePromise, rejectPromise) => { + const tick = () => { + if (predicate()) { + resolvePromise(); + return; + } + if (Date.now() - startedAt > timeoutMs) { + rejectPromise(new Error(`Timed out waiting for ${label}`)); + return; + } + setTimeout(tick, 25); + }; + tick(); + }); +} + +describe("echo-provider canary: plugin install → server command → runtime → ingest", () => { + let harness: TestAppHarness; + let runtime: AgentRuntime | null = null; + let workspaceDir: string; + let bridgeDataDir: string; + let savedGreeting: string | undefined; + + beforeEach(async () => { + harness = await createTestAppHarness(); + workspaceDir = await mkdtemp(join(tmpdir(), "bb-echo-canary-ws-")); + bridgeDataDir = await mkdtemp(join(tmpdir(), "bb-echo-canary-bridge-")); + savedGreeting = process.env[GREETING_ENV]; + // The declaration names this variable in `experimental_env.passthrough`; + // the runtime forwards exactly the declared names past its `BB_*` strip. + process.env[GREETING_ENV] = "hello from the daemon"; + }); + + afterEach(async () => { + await runtime?.shutdown(); + runtime = null; + if (savedGreeting === undefined) { + delete process.env[GREETING_ENV]; + } else { + process.env[GREETING_ENV] = savedGreeting; + } + await harness.cleanup(); + await rm(workspaceDir, { recursive: true, force: true }); + await rm(bridgeDataDir, { recursive: true, force: true }); + }); + + it("persists every grammar v3 capability the third-party bridge emits", async () => { + // 1. Install the example plugin from its checkout path: the server runs + // server.ts (registration, settings, the bb tool) and builds host.ts + // into the artifact the daemon would download. + const entry = await harness.pluginService.installPath(ECHO_PLUGIN_ROOT); + expect(entry.status, entry.statusDetail ?? "").toBe("running"); + expect(entry.id).toBe(PLUGIN_ID); + const artifact = harness.deps.pluginHostArtifacts.get(PLUGIN_ID); + expect(artifact, "the plugin's bb.host artifact was built").toBeDefined(); + if (artifact === undefined) throw new Error("unreachable"); + + // The plugin's own setting, flipped through the server's settings API. + await harness.pluginService.updateSettings(PLUGIN_ID, { shout: true }); + + const registration = harness.deps.providerRegistry.get(PROVIDER_ID); + expect(registration?.info).toMatchObject({ + id: PROVIDER_ID, + displayName: "Echo", + capabilities: { supportsServiceTier: true }, + }); + expect( + harness.deps.providerRegistry.getExtensionKindSchemas(RECEIPT_KIND)?.item, + ).toBeDefined(); + expect( + harness.deps.providerRegistry.getExtensionKindSchemas(MOOD_KIND)?.state, + ).toBeDefined(); + + // 2. A thread on the echo provider, and the REAL thread.start command. + const { host, session } = seedHostSession(harness.deps, { + id: "host-echo-canary", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: workspaceDir, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + providerId: PROVIDER_ID, + status: "active", + }); + const execution = await resolveExecutionOptions(harness.deps, { + threadId: thread.id, + requestedExecution: { model: "echo-1", source: "client/turn/requested" }, + }); + const command = await buildThreadStartCommand(harness.deps, { + environment, + execution, + fork: null, + permissionEscalation: "ask", + input: textInput("hello canary"), + projectId: project.id, + providerId: PROVIDER_ID, + requestId: encodeClientTurnRequestIdNumber({ value: 1 }), + syncGeneratedTitle: false, + thread, + }); + + // What the server derived for the bridge: the plugin's setting inside + // providerOptions, the declared env passthrough, the bb tool with the + // presentation resolved from its registration, the artifact launch. + expect(command.options.providerOptions).toEqual({ + shout: true, + model: "echo-1", + promptMode: null, + }); + expect(command.bridgeLaunch).toMatchObject({ + pluginId: PLUGIN_ID, + source: { kind: "artifact", digest: artifact.digest }, + envPassthrough: [GREETING_ENV], + capabilities: { supportsServiceTier: true, fork: "none" }, + }); + const stampTool = command.dynamicTools.find( + (tool) => tool.name === "echo_stamp", + ); + expect(stampTool).toMatchObject({ presentation: STAMP_PRESENTATION }); + if (command.bridgeLaunch.source.kind !== "artifact") { + throw new Error("expected an artifact launch"); + } + + // 3. The REAL runtime, launching the built artifact exactly as the + // daemon does, with every event and tool call routed to the REAL + // server routes the daemon uses. + const runtimeEvents: ThreadEvent[] = []; + let ingest: Promise = Promise.resolve(); + const ingestEvent = (event: ThreadEvent): void => { + ingest = ingest.then(async () => { + const response = await harness.app.request("/internal/session/events", { + method: "POST", + headers: internalAuthHeaders(harness), + body: JSON.stringify({ + sessionId: session.id, + eventGroups: groupHostDaemonEvents([ + { threadId: event.threadId, event }, + ]), + }), + }); + expect(response.status, `ingest ${event.type}`).toBe(200); + }); + }; + const toolCalls: ToolCallRequest[] = []; + const runtimeInstance = createAgentRuntime({ + workspacePath: workspaceDir, + onEvent: (event) => { + runtimeEvents.push(event); + ingestEvent(event); + }, + onToolCall: async (request): Promise => { + toolCalls.push(request); + const response = await harness.app.request( + "/internal/session/tool-call", + { + method: "POST", + headers: internalAuthHeaders(harness), + body: JSON.stringify({ + sessionId: session.id, + threadId: request.threadId, + providerThreadId: request.providerThreadId, + turnId: request.turnId, + callId: request.callId, + tool: request.tool, + arguments: request.arguments, + }), + }, + ); + expect(response.status).toBe(200); + return toolCallResponseSchema.parse(await response.json()); + }, + }); + runtime = runtimeInstance; + const bridgeLaunch = { + pluginId: command.bridgeLaunch.pluginId, + dataDir: bridgeDataDir, + source: { + kind: "artifact" as const, + digest: command.bridgeLaunch.source.digest, + artifactPath: artifact.path, + }, + capabilities: command.bridgeLaunch.capabilities, + providerOptions: command.bridgeLaunch.providerOptions, + envPassthrough: command.bridgeLaunch.envPassthrough, + }; + await runtimeInstance.startThread({ + bridgeLaunch, + environmentId: environment.id, + threadId: thread.id, + projectId: project.id, + providerId: PROVIDER_ID, + clientRequestId: command.requestId, + input: command.input, + options: command.options, + instructions: command.instructions, + dynamicTools: command.dynamicTools, + instructionMode: command.instructionMode, + }); + const turnCompletedCount = () => + runtimeEvents.filter((event) => event.type === "turn/completed").length; + // The main turn and the delegation's child turn. + await waitFor(() => turnCompletedCount() >= 2, "the first echo turn"); + await ingest; + + // The bb tool ran through the server: the plugin's own execute answered. + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]).toMatchObject({ + threadId: thread.id, + tool: "echo_stamp", + arguments: { text: "hello canary" }, + }); + + // 4. The rows. + const rows = storedRows(harness, thread.id); + const completed = completedItems(rows); + expect(completed.map((row) => row.itemKind)).toEqual([ + "commandExecution", + "fileRead", + "search", + "agentMessage", + "delegation", + "planSteps", + "toolCall", + "toolCall", + "extension", + "agentMessage", + ]); + // Presentation persisted on EVERY item row, opened and completed. + const itemRows = rows.filter( + (row) => row.type === "item/started" || row.type === "item/completed", + ); + expect(itemRows.length).toBeGreaterThanOrEqual(20); + for (const row of itemRows) { + expect( + row.data.item?.presentation, + `${row.type} ${row.itemKind} carries presentation`, + ).toMatchObject({ + label: { pending: expect.any(String), completed: expect.any(String) }, + icon: { glyph: expect.any(String) }, + }); + } + + // The extension item, validated against the declared schema. + expect(itemOf(rows, "extension").data.item).toMatchObject({ + kind: RECEIPT_KIND, + payload: { prompt: "hello canary", itemCount: 7, shouted: true }, + presentation: { + label: { completed: "Wrote receipt" }, + icon: { glyph: "PackageReceive" }, + detail: "Echoed 7 items, shouting.", + }, + }); + // The extension state row. + expect( + rows.find((row) => row.type === "thread/extensionState/updated")?.data, + ).toMatchObject({ + kind: MOOD_KIND, + payload: { mood: "cheerful", turnsEchoed: 1 }, + }); + + // The delegation and its child turn, linked by parentToolCallId. + const delegation = itemOf(rows, "delegation"); + expect(delegation.data.item).toMatchObject({ + background: false, + status: "completed", + summary: "child echo: hello canary", + presentation: { icon: { glyph: "UserRound" } }, + }); + const childTurn = rows.find( + (row) => + row.type === "turn/started" && row.data.parentToolCallId !== undefined, + ); + expect(childTurn?.data.parentToolCallId).toBe(delegation.data.item?.id); + const childMessage = completed.find( + (row) => + row.itemKind === "agentMessage" && + row.data.item?.parentToolCallId !== undefined, + ); + expect(childMessage?.data.item).toMatchObject({ + text: "child echo: hello canary", + parentToolCallId: delegation.data.item?.id, + }); + expect(childMessage?.turnId).toBe(childTurn?.turnId); + expect(childTurn?.turnId).not.toBe(delegation.turnId); + + // planSteps. + expect(itemOf(rows, "planSteps").data.item).toMatchObject({ + steps: [ + { step: "Hear the prompt", status: "completed" }, + { step: 'Echo "hello canary"', status: "completed" }, + { step: "Write the receipt", status: "completed" }, + ], + presentation: { icon: { glyph: "ListTodo" } }, + }); + + // The core v3 kinds. + expect(itemOf(rows, "fileRead").data.item).toMatchObject({ + path: `${workspaceDir}/README.md`, + presentation: { icon: { glyph: "FileText" } }, + }); + expect(itemOf(rows, "search").data.item).toMatchObject({ + mode: "content", + query: "hello canary", + presentation: { icon: { glyph: "Search" } }, + }); + expect(itemOf(rows, "commandExecution").data.item).toMatchObject({ + command: 'echo "hello canary"', + exitCode: 0, + aggregatedOutput: "hello canary\n", + presentation: { icon: { glyph: "Terminal" } }, + }); + + // Tools: the suppressed row and the bb tool with the definition's + // presentation, stamped server:"bb", carrying the plugin's real result. + expect(itemOf(rows, "toolCall", "echo_noop").data.item).toMatchObject({ + presentation: { suppress: true }, + }); + expect(itemOf(rows, "toolCall", "echo_stamp").data.item).toMatchObject({ + server: "bb", + status: "completed", + result: "stamped: hello canary", + presentation: STAMP_PRESENTATION, + }); + + // The echoed message: the setting and the env var made the round trip. + const message = completed + .filter( + (row) => + row.itemKind === "agentMessage" && + row.data.item?.parentToolCallId === undefined, + ) + .at(-1); + expect(message?.data.item?.text).toBe( + [ + "echo: HELLO CANARY", + "providerOptions (server): shout=true model=echo-1 promptMode=none", + `${GREETING_ENV}=hello from the daemon`, + "echo_stamp: stamped: hello canary", + ].join("\n"), + ); + + // 5. A malformed extension payload is rejected at ingest: the item rows + // persist as provider/unhandled, nothing else in the batch is lost. + const before = rows.length; + await runtimeInstance.runTurn({ + threadId: thread.id, + clientRequestId: encodeClientTurnRequestIdNumber({ value: 2 }), + input: textInput("malformed-receipt now"), + options: command.options, + }); + await waitFor(() => turnCompletedCount() >= 4, "the second echo turn"); + await ingest; + const secondTurnRows = storedRows(harness, thread.id).slice(before); + expect( + secondTurnRows.filter((row) => row.itemKind === "extension"), + ).toEqual([]); + const unhandled = secondTurnRows.filter( + (row) => row.type === "provider/unhandled", + ); + expect(unhandled).toHaveLength(2); + expect(unhandled[0]?.data).toMatchObject({ + providerId: PROVIDER_ID, + rawType: `extension/item:${RECEIPT_KIND}`, + rawEvent: { + params: { + kind: RECEIPT_KIND, + payload: { prompt: 42, itemCount: "many" }, + reason: expect.stringContaining("prompt"), + }, + }, + }); + // The well-formed state row of the same turn still persisted. + expect( + secondTurnRows.find((row) => row.type === "thread/extensionState/updated") + ?.data, + ).toMatchObject({ kind: MOOD_KIND, payload: { turnsEchoed: 2 } }); + }, 120_000); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37a730dde7..ea086979ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1180,6 +1180,9 @@ importers: specifier: 4.3.6 version: 4.3.6 devDependencies: + '@bb/agent-runtime': + specifier: workspace:* + version: link:../../packages/agent-runtime '@bb/scripts': specifier: workspace:* version: link:../../packages/scripts @@ -1355,6 +1358,9 @@ importers: '@get-bb/plugin-sdk': specifier: workspace:* version: link:../../../packages/plugin-sdk + zod: + specifier: 4.3.6 + version: 4.3.6 devDependencies: '@types/node': specifier: ^22.0.0 @@ -1368,9 +1374,6 @@ importers: vitest: specifier: ^4.1.1 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) - zod: - specifier: 4.3.6 - version: 4.3.6 examples/plugins/replacement-lab-alpha: devDependencies: diff --git a/turbo.json b/turbo.json index 04f216a6df..c4be4e272a 100644 --- a/turbo.json +++ b/turbo.json @@ -409,6 +409,9 @@ "$TURBO_DEFAULT$", "$TURBO_ROOT$/tests/scripted-echo-provider/host.ts", "$TURBO_ROOT$/tests/scripted-echo-provider/src/**", + "$TURBO_ROOT$/examples/plugins/echo-provider/*.ts", + "$TURBO_ROOT$/examples/plugins/echo-provider/package.json", + "$TURBO_ROOT$/examples/plugins/echo-provider/src/**", "$TURBO_ROOT$/vitest.shared.ts" ] }, From a1fcb960dcd10a0918572162c255eb8f1c738dd3 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 14:21:36 +0000 Subject: [PATCH 3/3] echo-provider: document every capability and point the docs at the public testing kit The README lists each registration and bridge capability with where it lives and how it is proven, and states the only-public-SDK rule. The plugin-authoring skill and the protocol doc stop pointing at the private @bb/provider-bridge-protocol/conformance package. Co-Authored-By: Claude --- .../bb-plugin-authoring/SKILL.md | 19 ++- .../providers/echo-provider-canary.test.ts | 10 +- docs/provider-bridge-protocol.md | 3 +- examples/plugins/echo-provider/README.md | 118 ++++++++++++------ .../provider-bridge.stream.test.ts | 8 +- .../echo-provider/src/provider-bridge.ts | 30 +++-- examples/plugins/echo-provider/tsconfig.json | 13 +- 7 files changed, 132 insertions(+), 69 deletions(-) diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 91392d49b8..c3d6e28a61 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1218,12 +1218,19 @@ keys, parent refs); the runtime's delta assembler — never the bridge — mints every bb turn and item id and constructs the canonical timeline events. -**Conformance.** Ship a test that drives -`@bb/provider-bridge-protocol/conformance` against your bridge in-process: -export the bridge surface, wire `runBridgeConformance` with a -transport whose `send` calls it and whose `takeMessages` drains captured -stdout, and assert all eleven scenarios pass (see -`examples/plugins/echo-provider/provider-bridge.conformance.test.ts`). +**Conformance.** Ship a test that drives the published kit, +`@get-bb/plugin-sdk/provider-bridge/testing`, against your bridge +in-process: export the bridge surface, wire `experimental_runBridgeConformance` +with a transport whose `send` calls it and whose `takeMessages` drains +captured stdout (through `experimental_toConformanceMessages` and the kit's +delta collector, which is the runtime's real assembler), and assert every +scenario passes (see +`examples/plugins/echo-provider/provider-bridge.conformance.test.ts`). The +same kit assembles your deltas into canonical events, so a second test can +assert what each row becomes +(`examples/plugins/echo-provider/provider-bridge.stream.test.ts`). Never +import a private `@bb/*` package from a plugin: an installed plugin cannot +resolve it. **Delivery.** On install/reload the server builds `dist/host.js` and records its digest. Thread commands for the provider carry `{pluginId, digest}` to the diff --git a/apps/server/test/providers/echo-provider-canary.test.ts b/apps/server/test/providers/echo-provider-canary.test.ts index b2d99d996f..55f494760d 100644 --- a/apps/server/test/providers/echo-provider-canary.test.ts +++ b/apps/server/test/providers/echo-provider-canary.test.ts @@ -49,7 +49,10 @@ import { seedProjectWithSource, seedThread, } from "../helpers/seed.js"; -import { createTestAppHarness, type TestAppHarness } from "../helpers/test-app.js"; +import { + createTestAppHarness, + type TestAppHarness, +} from "../helpers/test-app.js"; const ECHO_PLUGIN_ROOT = resolve( dirname(fileURLToPath(import.meta.url)), @@ -106,7 +109,10 @@ function itemOf(rows: StoredRow[], itemKind: string, tool?: string): StoredRow { candidate.itemKind === itemKind && (tool === undefined || candidate.data.item?.tool === tool), ); - expect(row, `a completed ${itemKind}${tool ? ` ${tool}` : ""} row`).toBeDefined(); + expect( + row, + `a completed ${itemKind}${tool ? ` ${tool}` : ""} row`, + ).toBeDefined(); return row as StoredRow; } diff --git a/docs/provider-bridge-protocol.md b/docs/provider-bridge-protocol.md index 42b3ef400f..f9fbea4a3b 100644 --- a/docs/provider-bridge-protocol.md +++ b/docs/provider-bridge-protocol.md @@ -33,7 +33,8 @@ above, the bounded stdin framing, and the signals. A bridge that started itself could not be imported by a test, and could not share an artifact with a host RPC entry. First-party bridges use exactly this path — `plugins/provider-codex/src/bridge/bridge.ts` is the largest worked example, -and `examples/plugins/echo-provider` the smallest. +and `examples/plugins/echo-provider` the smallest complete one: it emits every +grammar v3 shape and uses only the public SDK. The bundle is self-contained (only node builtins stay external) and may not import bb's private `@bb/*` workspace packages at all — an installed plugin diff --git a/examples/plugins/echo-provider/README.md b/examples/plugins/echo-provider/README.md index 738f7d9385..989265ffb1 100644 --- a/examples/plugins/echo-provider/README.md +++ b/examples/plugins/echo-provider/README.md @@ -1,41 +1,81 @@ # bb-plugin-echo-provider -A complete third-party **agent provider** in ~300 lines: it registers an -"Echo Agent" provider that answers every prompt by echoing it back. Useless -as an agent, complete as a template — it exercises the entire provider -plugin surface: the declaration, the bridge build target, artifact delivery -to hosts, and the official conformance kit. +The **third-party canary** for bb's provider plugin API. It is a complete +agent provider — a picker entry, a bridge, a bb tool, plugin settings, and +its own timeline vocabulary — that answers every prompt by echoing it back. +Useless as an agent, complete as a proof: it exercises **every** capability +a provider plugin has, and it does so through the public SDK alone. + +## The rule + +Everything under this directory imports only: + +- `@get-bb/plugin-sdk` (and its published subpaths `/provider-bridge`, + `/host`, `/app`), +- `zod`, +- node built-ins and the plugin's own files. + +Tests may add `@get-bb/plugin-sdk/provider-bridge/testing` and the test +runner. **No `@bb/*` workspace package is imported anywhere**, and +`public-sdk-only.test.ts` fails the suite if one ever is. A marketplace +plugin cannot resolve bb's private packages; if this example needed one, the +public API would have a hole. ## What it demonstrates -- **`bb.providers.register`** (`server.ts`) — the provider - declaration: stable id, picker display name, and pre-session capability - facts (all `false` here; permission mode `full`, reasoning level `medium`). - Metadata only: the implementation is the bridge below, and a declaration - without one is refused. -- **`bb.host`** (`package.json`) — the plugin's one host artifact. - `bb plugin build` compiles `host.ts` into a fully self-contained - `dist/host.js` (everything inlined; only node builtins external) plus - `dist/host.meta.json` recording its digest. That artifact carries **both** - host surfaces this plugin has, which is the point of the shape: the named - `experimental_providerBridge` export (run by the daemon's bridge bootstrap - in its own process) and the `default` host RPC entry (run by the daemon's - host worker in another). Bridge authoring imports come from the published - `@get-bb/plugin-sdk/provider-bridge` — a host artifact cannot import bb's private - workspace packages. -- **The bridge protocol** (`src/provider-bridge.ts`) — a minimal but correct - implementation of the canonical Provider Bridge Protocol - (`docs/provider-bridge-protocol.md`): line-delimited JSON-RPC over stdio, - the `initialize` handshake, `thread/start`/`thread/resume` identity, - the full turn grammar (`turn/input/accepted` → `turn/started` → - `item/started` → `item/agentMessage/delta` → `item/completed` → - `turn/completed`) with bridge-minted, entropy-prefixed turn/item ids, - honest `thread/stop` intents, and `-32601`/`-32602` reply hygiene keyed by - the protocol package's own method vocabulary. -- **The conformance kit** (`provider-bridge.conformance.test.ts`) — drives - `@bb/provider-bridge-protocol/conformance` against the bridge in-process - (its exported bridge surface's `handleLine` + captured stdout) and asserts all eleven - scenarios pass. Ship this test with every provider bridge. +Registration (`server.ts`, `bb.providers.register`): + +| Capability | Where | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `experimental_strings` — sign-in and expiry hints, install URL, brand prefix, plan-mode copy, icon tint | `server.ts` | +| `experimental_reasoningLevels` — labelled picker options beside the coarse ladder | `server.ts` | +| `experimental_serviceTiers` and `capabilities.supportsServiceTier` | `server.ts` | +| `capabilities` — permission modes, fork, archive/rename, maintenance facts | `server.ts` | +| `composerActions: ["plan"]` | `server.ts` | +| `experimental_models.fallback` — the cold-cache model list | `server.ts` | +| `experimental_env.passthrough` — one daemon env var the bridge may read | `server.ts`, read in `src/provider-bridge.ts` | +| `experimental_deriveProviderOptions` — the plugin's `shout` setting (`bb.settings.define`) travels to the bridge as `providerOptions` | `server.ts`, read back in `src/provider-bridge.ts` | +| `experimental_extensionKinds` — one item kind (`echo-provider/receipt`) and one state kind (`echo-provider/mood`), each with a zod schema the server enforces at ingest | `src/vocabulary.ts` | +| `bb.agents.registerTool` with `experimental_presentation` — a bb tool whose row reads the way the plugin says | `server.ts` | +| `bb.host` — one artifact carrying the bridge and a host RPC entry | `host.ts`, `contract.ts` | + +The bridge (`src/provider-bridge.ts`, grammar v3). Every accepted prompt runs +the same scripted turn: + +| Capability | Delta | +| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Handshake reports `grammarVersions: [3, 3]`, `steerMode`, `sessionRestore`, `approvalEnforcedBy` | `initialize` | +| `presentation` on **every** `item.open` and `item.close` | all items | +| A shell command with streamed output | `command` + `item.outputDelta` | +| A file read | `fileRead` | +| A content search | `search` | +| Delegated work with a real child turn linked through `parentRef` | `delegation` + keyed `turn.open` | +| A plan snapshot | `planSteps` | +| A bookkeeping tool whose row clients collapse | `tool` with `presentation.suppress` | +| The plugin's bb tool, called over `item/tool/call` and stamped `server: "bb"` with the definition's presentation | `tool` | +| The extension item, validated server-side against the declared schema | `extension` | +| The extension state, latest snapshot wins | `extension.state` | +| The echoed message, reporting the derived `providerOptions` and the passed-through env var | `item.textDelta` / `item.textClose` | +| Usage and the context-window meter | `usage`, `contextWindow` | +| A zero-work turn (`/noop`) that still settles | `turn.boundary` with `claimIfIdle` | +| A malformed extension payload (`malformed-receipt`) the server replaces with `provider/unhandled` | `extension` | + +## How it is proven + +1. **The public testing kit** (`@get-bb/plugin-sdk/provider-bridge/testing`): + - `provider-bridge.conformance.test.ts` drives the bridge through the + canonical Provider Bridge Protocol suite — all twelve scenarios, + including the zero-work turn. + - `provider-bridge.stream.test.ts` plays the runtime's part (including the + `item/tool/call` reply), runs the bridge's deltas through the **real** + delta assembler the kit ships, and asserts every capability above on the + assembled events. + - `public-sdk-only.test.ts` guards the rule. +2. **The server**: `apps/server/test/providers/echo-provider-canary.test.ts` + installs this plugin from its path, builds the real thread command, runs + it on the real agent runtime, ingests every event through the real + routes, and asserts the persisted rows — including the `provider/unhandled` + a malformed receipt becomes. ## How the bridge reaches a host @@ -44,7 +84,8 @@ to hosts, and the official conformance kit. every `bb.host` plugin uses. 2. Thread commands for `echo-agent` carry a `bridgeLaunch` spec — `{source: {kind: "artifact", pluginId, digest, byteLength}}` — over the - daemon wire. + daemon wire, beside the `providerOptions` the plugin derived and the + `dynamicTools` (with presentation) the server resolved. 3. The enrolled daemon downloads the bytes from `/internal/plugins/:pluginId/host/:digest`, verifies the digest **before** caching them, and runs the artifact with its own node through the bridge @@ -58,14 +99,15 @@ only what its server instructs. ## Install ``` -bb plugin install ./examples/plugins/echo-provider +bb plugin install ./examples/plugins/echo-provider --yes +bb plugin config echo-provider set shout true # optional: prove the settings round trip ``` -Then pick "Echo Agent" in the provider picker and send a message. After -editing sources, `bb plugin reload echo-provider`. +Then pick "Echo" in the provider picker and send a message. After editing +sources, `bb plugin reload echo-provider`. ## Test ``` -pnpm --dir examples/plugins/echo-provider test +pnpm exec turbo run test --filter=bb-plugin-echo-provider ``` diff --git a/examples/plugins/echo-provider/provider-bridge.stream.test.ts b/examples/plugins/echo-provider/provider-bridge.stream.test.ts index 7b7f4e03d3..e1d14d0fbb 100644 --- a/examples/plugins/echo-provider/provider-bridge.stream.test.ts +++ b/examples/plugins/echo-provider/provider-bridge.stream.test.ts @@ -282,7 +282,9 @@ describe("the echo bridge's grammar v3 stream", () => { }, }); expect( - events.some((event) => event.type === "item/commandExecution/outputDelta"), + events.some( + (event) => event.type === "item/commandExecution/outputDelta", + ), ).toBe(true); // fileRead and search. @@ -424,7 +426,9 @@ describe("the echo bridge's grammar v3 stream", () => { }, }); expect( - events.some((event) => event.type === "thread/contextWindowUsage/updated"), + events.some( + (event) => event.type === "thread/contextWindowUsage/updated", + ), ).toBe(true); }); diff --git a/examples/plugins/echo-provider/src/provider-bridge.ts b/examples/plugins/echo-provider/src/provider-bridge.ts index 7b5b971a70..0b341a10df 100644 --- a/examples/plugins/echo-provider/src/provider-bridge.ts +++ b/examples/plugins/echo-provider/src/provider-bridge.ts @@ -168,10 +168,7 @@ interface PendingToolCall { const pendingToolCalls = new Map(); -function sendRequest( - method: string, - params: Record, -): string { +function sendRequest(method: string, params: Record): string { outboundRequestCounter += 1; const id = `echo-req-${outboundRequestCounter}`; writeMessage({ id, method, params }); @@ -198,9 +195,10 @@ function promptText(input: readonly PromptInput[]): string { * none — but a real server always sends one, and the echoed message shows * which case this was. */ -function parseProviderOptions( - options: unknown, -): { source: "server" | "defaults"; values: EchoProviderOptions } { +function parseProviderOptions(options: unknown): { + source: "server" | "defaults"; + values: EchoProviderOptions; +} { const parsed = echoProviderOptionsSchema.safeParse(options); if (parsed.success) { return { source: "server", values: parsed.data }; @@ -251,7 +249,11 @@ function runEchoTurn(args: { // boundary claims the turn the pending acceptance opened (`claimIfIdle`) // so the thread never hangs active behind a turn that did nothing. if (/(?:^|\s)\/noop(?:\s|$)/u.test(prompt)) { - deltas.push({ kind: "turn.boundary", status: "completed", claimIfIdle: true }); + deltas.push({ + kind: "turn.boundary", + status: "completed", + claimIfIdle: true, + }); emitDeltas(session.threadId, deltas); return; } @@ -565,14 +567,22 @@ function finishEchoTurn( { kind: "item.open", key: { providerItemId: receiptId }, - item: { type: "extension", kind: ECHO_RECEIPT_KIND, payload: receiptPayload }, + item: { + type: "extension", + kind: ECHO_RECEIPT_KIND, + payload: receiptPayload, + }, presentation: receiptRow, }, { kind: "item.close", key: { providerItemId: receiptId }, status: "completed", - item: { type: "extension", kind: ECHO_RECEIPT_KIND, payload: receiptPayload }, + item: { + type: "extension", + kind: ECHO_RECEIPT_KIND, + payload: receiptPayload, + }, presentation: receiptRow, }, ); diff --git a/examples/plugins/echo-provider/tsconfig.json b/examples/plugins/echo-provider/tsconfig.json index fe526e8274..7a39b4e126 100644 --- a/examples/plugins/echo-provider/tsconfig.json +++ b/examples/plugins/echo-provider/tsconfig.json @@ -4,17 +4,10 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": [ - "ES2022" - ], + "lib": ["ES2022"], "noEmit": true, "skipLibCheck": true, - "types": [ - "node" - ] + "types": ["node"] }, - "include": [ - "*.ts", - "src" - ] + "include": ["*.ts", "src"] }