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/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 new file mode 100644 index 0000000000..55f494760d --- /dev/null +++ b/apps/server/test/providers/echo-provider-canary.test.ts @@ -0,0 +1,509 @@ +/** + * 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/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/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..e1d14d0fbb --- /dev/null +++ b/examples/plugins/echo-provider/provider-bridge.stream.test.ts @@ -0,0 +1,507 @@ +/** + * 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..0b341a10df 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,28 @@ 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 +189,52 @@ 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 +244,461 @@ 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 +719,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 +747,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 +780,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 +811,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 +831,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 +855,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 +873,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 +885,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 +946,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 +971,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..7a39b4e126 100644 --- a/examples/plugins/echo-provider/tsconfig.json +++ b/examples/plugins/echo-provider/tsconfig.json @@ -9,10 +9,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": [ - "server.ts", - "src", - "provider-bridge.conformance.test.ts", - "vitest.config.ts" - ] + "include": ["*.ts", "src"] } 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" ] },