From 57780450c21a2355766a1ad8e4b8e3aa28277a2e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 15:44:51 -0700 Subject: [PATCH] Carry provider extension UI through the bridge, daemon, server, and app --- .../plugin/PluginProviderExtensionStates.tsx | 54 +++++++ .../plugin/PluginSettingsSections.tsx | 29 +++- .../promptbox/FollowUpPromptBox.tsx | 24 +++ ...ThreadPendingInteractionBanner.stories.tsx | 4 +- .../ThreadTimelinePanelContent.test.tsx | 2 + .../useThreadTimelineController.test.tsx | 1 + .../timeline/useThreadTimelineController.ts | 2 + .../UserQuestionInteractionContent.tsx | 15 +- .../user-question-form-state.test.ts | 59 +++++-- .../user-question-form-state.ts | 14 +- .../cache-owners/realtime-cache-registry.ts | 3 + .../cache-owners/thread-detail-cache-owner.ts | 6 +- .../thread-runtime-cache-owner.test.ts | 31 ++-- .../thread-runtime-mutations.test.tsx | 7 +- .../src/hooks/queries/query-helpers.test.ts | 1 + .../src/hooks/queries/thread-queries.test.tsx | 2 + .../useCommandSuggestions.prefetch.test.tsx | 5 +- apps/app/src/lib/plugin-slots.ts | 9 ++ .../thread-detail/SplitThreadArea.stories.tsx | 1 + ...ThreadDetailPromptArea.keystrokes.test.tsx | 1 + .../ThreadDetailPromptArea.test.tsx | 1 + .../thread-detail/ThreadDetailPromptArea.tsx | 8 + .../views/thread-detail/ThreadDetailView.tsx | 2 + .../helpers/command-output-fixtures.ts | 5 +- apps/cli/src/commands/thread/interactions.ts | 28 +++- .../src/commands/thread/pending-todos.test.ts | 1 + apps/demo-server/src/demo-world.ts | 1 + apps/host-daemon/src/app.test.ts | 6 + apps/host-daemon/src/app.ts | 35 ++++- .../src/command-dispatch-support.ts | 14 +- apps/host-daemon/src/command-dispatch.test.ts | 2 + apps/host-daemon/src/command-dispatch.ts | 29 ++++ .../src/command-handlers/list-commands.ts | 39 ++++- apps/host-daemon/src/command-router.ts | 4 + .../src/interactive-request-registry.test.ts | 39 +++++ .../src/interactive-request-registry.ts | 33 ++++ apps/host-daemon/src/runtime-manager.test.ts | 2 + .../src/terminals/terminal-manager.test.ts | 2 + .../test/command/dispatch-helpers.ts | 14 ++ .../test/command/list-commands-bridge.test.ts | 128 +++++++++++++++ .../data/interactions/question-form-state.ts | 24 ++- apps/mobile/src/data/test/fixtures.ts | 1 + .../data/thread-detail/timeline-fetch.test.ts | 1 + .../src/lib/query/realtime-invalidation.ts | 6 + .../thread/interactions/QuestionForm.tsx | 11 +- .../server/src/internal/extension-payloads.ts | 41 ++++- .../src/internal/interactive-requests.ts | 16 +- apps/server/src/routes/projects.ts | 10 +- apps/server/src/routes/threads/actions.ts | 30 ++++ apps/server/src/server.ts | 28 ++++ .../pending-interaction-validation.ts | 27 +++- .../server/src/services/lib/async-ttl-memo.ts | 10 ++ .../server/src/services/plugins/plugin-api.ts | 58 +++++++ .../src/services/plugins/plugin-host-rpc.ts | 56 +++++++ .../src/services/plugins/plugin-runtime.ts | 12 ++ .../plugins/plugin-service-internal.ts | 16 ++ .../src/services/providers/native-roots.ts | 23 ++- .../bb-plugin-authoring/SKILL.md | 50 +++++- .../src/services/system/event-pruning.ts | 15 +- .../services/system/provider-model-cache.ts | 24 +++ .../threads/provider-command-typeahead.ts | 2 + apps/server/src/services/threads/timeline.ts | 16 +- .../internal-extension-payloads.test.ts | 38 ++++- .../internal-interactive-requests.test.ts | 52 ++++++ .../public/public-project-commands.test.ts | 54 ++++++- .../public-project-workspace-routing.test.ts | 14 +- ...ic-provider-extension-state-action.test.ts | 133 ++++++++++++++++ .../public/public-thread-interactions.test.ts | 3 + .../services/pending-interactions.test.ts | 3 + .../plugins/plugin-authoring-docs.test.ts | 13 +- .../system/provider-model-cache.test.ts | 28 ++++ .../provider-command-typeahead.test.ts | 4 + .../services/threads/timeline-cache.test.ts | 1 + .../threads/timeline-head-state.test.ts | 43 ++++- .../timeline-output-truncation.test.ts | 1 + docs/api_to_audit.md | 92 +++++++++++ docs/provider-bridge-protocol.md | 64 +++++++- docs/provider-plugin-api.md | 2 +- .../src/bridge-protocol-adapter.test.ts | 55 +++++++ .../src/bridge-protocol-adapter.ts | 66 ++++++-- packages/agent-runtime/src/index.ts | 2 + .../src/permission-matrix.test.ts | 1 + .../agent-runtime/src/provider-adapter.ts | 11 ++ .../src/runtime-provider-requests.ts | 36 +++-- .../src/runtime.command-contract.test.ts | 148 ++++++++++++++++++ .../src/runtime.interactive-requests.test.ts | 55 +++++++ .../src/runtime.tool-calls.test.ts | 2 + packages/agent-runtime/src/runtime.ts | 100 ++++++++++++ .../src/test/runtime-test-harness.ts | 3 + packages/agent-runtime/src/types.ts | 33 ++++ .../src/timeline/timelineRowSignatures.ts | 1 + .../client-core/test/timeline-merge.test.ts | 1 + packages/db/src/data/events.ts | 59 +++++++ packages/db/src/data/index.ts | 2 + packages/db/src/data/pending-interactions.ts | 2 +- packages/db/test/data/events.test.ts | 43 +++++ packages/domain/src/change-kinds.ts | 1 + packages/domain/src/pending-interactions.ts | 110 ++++++++++--- packages/domain/src/thread-event-scope.ts | 6 +- packages/host-daemon-contract/src/commands.ts | 49 ++++++ packages/host-daemon-contract/src/protocol.ts | 14 +- packages/host-daemon-contract/src/session.ts | 2 + .../test/contract.test.ts | 12 +- .../__tests__/provider-declaration-v3.test.ts | 2 +- .../src/__tests__/public-types.test.ts | 1 + packages/plugin-sdk/src/app-contract.ts | 60 ++++++- packages/plugin-sdk/src/backend-contract.ts | 40 ++++- .../plugin-sdk/src/internal/host-policy.ts | 21 +-- .../src/internal/plugin-app-collector.ts | 19 +++ packages/plugin-sdk/src/provider-bridge.ts | 17 ++ .../__tests__/fake-plugin-host.test.ts | 10 +- packages/plugin-sdk/src/testing/app.tsx | 2 + .../src/testing/fake-plugin-host.ts | 78 +++++++++ .../src/bridge-kit/contracts.ts | 9 ++ .../src/bridge-requests.ts | 21 ++- .../provider-bridge-grammar.v2.snapshot.json | 4 + .../provider-bridge-protocol/src/index.ts | 1 + .../src/notifications.ts | 13 ++ .../src/provider-commands.ts | 52 ++++++ .../provider-bridge-protocol/src/requests.ts | 34 ++++ packages/sdk/src/areas/threads.ts | 23 +++ packages/sdk/test/public-types.test.ts | 1 + packages/server-contract/src/api/projects.ts | 1 + packages/server-contract/src/api/threads.ts | 35 +++++ packages/server-contract/src/public-api.ts | 11 ++ .../src/templates/bb-guide-projects.md | 3 +- .../thread-view/src/build-thread-timeline.ts | 6 + .../src/extension-state-extraction.ts | 23 +++ .../thread-view/src/timeline-row-title.ts | 3 + .../src/interactive-requests.test.ts | 5 + .../src/interactive-requests.ts | 3 + .../fake/smoke/timeline-response.test.ts | 1 + 132 files changed, 2822 insertions(+), 176 deletions(-) create mode 100644 apps/app/src/components/plugin/PluginProviderExtensionStates.tsx create mode 100644 apps/host-daemon/test/command/list-commands-bridge.test.ts create mode 100644 apps/server/src/services/system/provider-model-cache.ts create mode 100644 apps/server/test/public/public-provider-extension-state-action.test.ts create mode 100644 apps/server/test/services/system/provider-model-cache.test.ts create mode 100644 packages/provider-bridge-protocol/src/provider-commands.ts create mode 100644 packages/thread-view/src/extension-state-extraction.ts diff --git a/apps/app/src/components/plugin/PluginProviderExtensionStates.tsx b/apps/app/src/components/plugin/PluginProviderExtensionStates.tsx new file mode 100644 index 0000000000..27b5fee9bb --- /dev/null +++ b/apps/app/src/components/plugin/PluginProviderExtensionStates.tsx @@ -0,0 +1,54 @@ +import type { ThreadTimelineExtensionState } from "@bb/server-contract"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import { sdk } from "@/lib/sdk"; +import { PluginSlotMount } from "./PluginSlotMount"; + +interface PluginProviderExtensionStatesProps { + extensionStates: readonly ThreadTimelineExtensionState[]; + placement: "aboveEditor" | "belowEditor"; + providerId: string; + threadId: string; +} + +/** Generic mount point; payload interpretation and visuals stay in the owner. */ +export function PluginProviderExtensionStates({ + extensionStates, + placement, + providerId, + threadId, +}: PluginProviderExtensionStatesProps) { + const { providerExtensionStates } = usePluginSlots(); + + return extensionStates.flatMap((state) => { + const slot = providerExtensionStates.find( + (candidate) => state.kind === `${candidate.pluginId}/${candidate.name}`, + ); + if (!slot) return []; + return [ + + + sdk.threads.experimental_applyExtensionStateAction({ + threadId, + kind: state.kind, + action, + }) + } + /> + , + ]; + }); +} diff --git a/apps/app/src/components/plugin/PluginSettingsSections.tsx b/apps/app/src/components/plugin/PluginSettingsSections.tsx index 6b367c355f..72e2ea9db6 100644 --- a/apps/app/src/components/plugin/PluginSettingsSections.tsx +++ b/apps/app/src/components/plugin/PluginSettingsSections.tsx @@ -4,6 +4,8 @@ import { } from "@/lib/plugin-slots"; import { PluginSlotMount } from "./PluginSlotMount"; import { ResourceDetailPanel } from "@bb/shared-ui/resource-detail"; +import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; /** * Plugin `settingsSection` slot mounts, rendered on that plugin's canonical @@ -12,30 +14,47 @@ import { ResourceDetailPanel } from "@bb/shared-ui/resource-detail"; */ export function PluginSettingsSections({ pluginId }: { pluginId: string }) { const { settingsSections } = usePluginSlots(); + const hostsQuery = useHosts(); + const systemConfigQuery = useSystemConfig(); const sections = settingsSections.filter( (section) => section.pluginId === pluginId, ); if (sections.length === 0) return null; - return ; + const selectedHost = selectPrimaryHost( + hostsQuery.data, + systemConfigQuery.data?.primaryHostId ?? null, + ); + return ( + + ); } function PluginSettingsSectionList({ sections, + hostId, }: { sections: readonly PluginSettingsSectionSlot[]; + hostId: string | null; }) { return (
{sections.map((section) => { const key = `${section.pluginId}/${section.id}/${section.generation}`; return section.title === undefined ? ( - + ) : (

{section.title}

- +
); })} @@ -45,8 +64,10 @@ function PluginSettingsSectionList({ function PluginSettingsSectionPanel({ section, + hostId, }: { section: PluginSettingsSectionSlot; + hostId: string | null; }) { return ( @@ -60,7 +81,7 @@ function PluginSettingsSectionPanel({ slotKind="settingsSection" slotId={section.id} > - + ); diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index fb71b89ca2..ffd27f2164 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -19,9 +19,11 @@ import type { ThreadTimelineActivePromptMode, } from "@bb/domain"; import type { ComposerView, PluginComposerScope } from "@get-bb/plugin-sdk"; +import type { ThreadTimelineExtensionState } from "@bb/server-contract"; import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; import { isKeyboardFocusTarget } from "@/components/layout/useMobileVisualViewportHeight"; import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners"; +import { PluginProviderExtensionStates } from "@/components/plugin/PluginProviderExtensionStates"; import { PluginComposerHostProvider, PluginComposerViewProvider, @@ -216,6 +218,12 @@ export interface FollowUpPromptBoxProps { suppressPluginComposerCustomizations?: boolean; /** Optional transient draft host exposed to plugin composer hooks. */ pluginComposerHost?: PluginComposerHost | null; + /** Current provider-extension state rendered by its owning plugin. */ + providerExtensionState?: { + extensionStates: readonly ThreadTimelineExtensionState[]; + providerId: string; + threadId: string; + }; /** Active scope used to filter and lifecycle-key plugin banner slots. */ pluginComposerScope?: PluginComposerScope | null; textEffects?: readonly ComposerTextEffectSource[]; @@ -312,6 +320,7 @@ function FollowUpPromptBoxWithComposer({ suppressPluginComposerCustomizations, pluginComposerHost, pluginComposerScope, + providerExtensionState, textEffects, collapseResetKey, focusEndKey, @@ -819,6 +828,7 @@ function FollowUpPromptBoxWithComposer({ hasPluginComposerScope={composerScope !== null} isPrimaryComposer={isPrimaryComposer} pendingInteraction={pendingInteraction} + providerExtensionState={providerExtensionState} showScrollToBottomButton={showScrollToBottomButton} stack={stack} stackRef={stackRef} @@ -834,6 +844,7 @@ interface DefaultFollowUpComposerProps { hasPluginComposerScope: boolean; isPrimaryComposer: boolean; pendingInteraction?: ReactNode; + providerExtensionState?: FollowUpPromptBoxProps["providerExtensionState"]; showScrollToBottomButton: boolean; stack: ReactNode | null; stackRef: RefObject; @@ -846,6 +857,7 @@ function DefaultFollowUpComposer({ hasPluginComposerScope, isPrimaryComposer, pendingInteraction = null, + providerExtensionState, showScrollToBottomButton, stack, stackRef, @@ -867,9 +879,21 @@ function DefaultFollowUpComposer({ ) : ( stack )} + {providerExtensionState ? ( + + ) : null} {pendingInteraction}
{composerElement}
+ {providerExtensionState ? ( + + ) : null} ); diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx index e189f508e5..2e9e9e6edd 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx @@ -19,8 +19,8 @@ function PromptStage({ children }: { children: React.ReactNode }) { // The common fields; each story pairs its own payload with its resolution. function basePendingInteraction(): Omit< ProviderPendingInteraction, - "payload" | "resolution" -> { + "payload" | "resolution" | "turnId" +> & { turnId: string } { return { id: "pi_demo", threadId: "thr_qfk8ksbxkk", diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx index 1febeb08de..887629d029 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx @@ -48,6 +48,7 @@ vi.mock("./useThreadTimelineController.js", () => ({ activeWorkflows: [], activeBackgroundCommands: [], contextWindowUsage: undefined, + extensionStates: [], goal: null, modelFallback: null, hasOlderTimelineRows: false, @@ -101,6 +102,7 @@ function baseTimeline( activeWorkflows: [], activeBackgroundCommands: [], contextWindowUsage: undefined, + extensionStates: [], goal: null, hasOlderTimelineRows: false, isLoadingOlderTimelineRows: false, diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index 3190db9f6c..c3d68dd2cc 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -41,6 +41,7 @@ function makeTimelineResponse(): ThreadTimelineResponse { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index d816de331e..05242cb08d 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -25,6 +25,7 @@ export interface UseThreadTimelineControllerResult { activeWorkflows: ThreadTimelineResponse["activeWorkflows"]; activeBackgroundCommands: ThreadTimelineResponse["activeBackgroundCommands"]; contextWindowUsage: ThreadTimelineResponse["contextWindowUsage"]; + extensionStates: ThreadTimelineResponse["extensionStates"]; goal: ThreadTimelineResponse["goal"]; modelFallback: ThreadTimelineResponse["modelFallback"]; hasOlderTimelineRows: boolean; @@ -200,6 +201,7 @@ export function useThreadTimelineController({ activeWorkflows: latestTimeline?.activeWorkflows ?? [], activeBackgroundCommands: latestTimeline?.activeBackgroundCommands ?? [], contextWindowUsage: latestTimeline?.contextWindowUsage, + extensionStates: latestTimeline?.extensionStates ?? [], goal: latestTimeline?.goal ?? null, modelFallback: latestTimeline?.modelFallback ?? null, hasOlderTimelineRows, diff --git a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx index e354727d8a..f6cdb538e6 100644 --- a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx +++ b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx @@ -134,7 +134,9 @@ function QuestionOptionRow({ {checked ? : null} - {label} + + {label} + {description ? ( {description} @@ -212,12 +214,7 @@ function QuestionInputBlock({ useLayoutEffect(() => { if (!state.otherSelected) return; resizeFreeTextArea(); - }, [ - question.id, - resizeFreeTextArea, - state.otherSelected, - state.otherText, - ]); + }, [question.id, resizeFreeTextArea, state.otherSelected, state.otherText]); const handleFreeTextKeyDown = ( event: KeyboardEvent, @@ -275,7 +272,9 @@ function QuestionInputBlock({ resizeFreeTextArea(event.target); }} onKeyDown={handleFreeTextKeyDown} - placeholder="Type your own answer…" + placeholder={ + question.experimental_placeholder ?? "Type your own answer…" + } className="mt-2 w-full resize-none overflow-y-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:border-ring/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40" style={{ minHeight: `${USER_QUESTION_FREE_TEXT_MIN_HEIGHT}px`, diff --git a/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts b/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts index f725d256e1..14aa71826d 100644 --- a/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts +++ b/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts @@ -35,6 +35,15 @@ const freeTextOnly: PendingInteractionUserQuestionQuestion = { allowFreeText: true, }; +const verbatimText: PendingInteractionUserQuestionQuestion = { + id: "exact", + prompt: "Exact value", + multiSelect: false, + allowFreeText: true, + experimental_responseMode: "verbatim", + experimental_prefill: " initial\nvalue ", +}; + describe("buildUserAnswerResolution", () => { it("returns the selected option for a single-select choice", () => { const state = createInitialFormState([singleSelect]); @@ -51,9 +60,9 @@ describe("buildUserAnswerResolution", () => { state.branch.otherSelected = true; state.branch.otherText = " a custom branch "; - expect(buildUserAnswerResolution([singleSelect], state).answers.branch).toEqual( - { selected: [], freeText: "a custom branch" }, - ); + expect( + buildUserAnswerResolution([singleSelect], state).answers.branch, + ).toEqual({ selected: [], freeText: "a custom branch" }); }); it("omits free text when Other is selected but blank", () => { @@ -61,9 +70,9 @@ describe("buildUserAnswerResolution", () => { state.branch.otherSelected = true; state.branch.otherText = " "; - expect(buildUserAnswerResolution([singleSelect], state).answers.branch).toEqual( - { selected: [] }, - ); + expect( + buildUserAnswerResolution([singleSelect], state).answers.branch, + ).toEqual({ selected: [] }); }); it("keeps both options and free text for multi-select", () => { @@ -72,18 +81,18 @@ describe("buildUserAnswerResolution", () => { state.areas.otherSelected = true; state.areas.otherText = "docs"; - expect(buildUserAnswerResolution([multiSelect], state).answers.areas).toEqual( - { selected: ["app", "cli"], freeText: "docs" }, - ); + expect( + buildUserAnswerResolution([multiSelect], state).answers.areas, + ).toEqual({ selected: ["app", "cli"], freeText: "docs" }); }); it("drops option values that aren't part of the question", () => { const state = createInitialFormState([singleSelect]); state.branch.selected = ["main", "ghost"]; - expect(buildUserAnswerResolution([singleSelect], state).answers.branch).toEqual( - { selected: ["main"] }, - ); + expect( + buildUserAnswerResolution([singleSelect], state).answers.branch, + ).toEqual({ selected: ["main"] }); }); it("captures free text for an options-less question", () => { @@ -92,9 +101,19 @@ describe("buildUserAnswerResolution", () => { expect(state.notes.otherSelected).toBe(true); state.notes.otherText = "ship it"; - expect(buildUserAnswerResolution([freeTextOnly], state).answers.notes).toEqual( - { selected: [], freeText: "ship it" }, - ); + expect( + buildUserAnswerResolution([freeTextOnly], state).answers.notes, + ).toEqual({ selected: [], freeText: "ship it" }); + }); + + it("preserves editor prefill and its submitted replacement byte-for-byte", () => { + const state = createInitialFormState([verbatimText]); + expect(state.exact.otherText).toBe(" initial\nvalue "); + state.exact.otherText = " exact\nvalue "; + + expect( + buildUserAnswerResolution([verbatimText], state).answers.exact, + ).toEqual({ selected: [], experimental_verbatimText: " exact\nvalue " }); }); }); @@ -119,6 +138,16 @@ describe("isQuestionAnswered", () => { ).toBe(true); }); + it("accepts an empty string for a verbatim input", () => { + expect( + isQuestionAnswered(verbatimText, { + selected: [], + otherSelected: true, + otherText: "", + }), + ).toBe(true); + }); + it("is not answered when Other is selected but blank", () => { expect( isQuestionAnswered(singleSelect, { diff --git a/apps/app/src/components/thread/user-questions/user-question-form-state.ts b/apps/app/src/components/thread/user-questions/user-question-form-state.ts index e32872325f..262cafc2d9 100644 --- a/apps/app/src/components/thread/user-questions/user-question-form-state.ts +++ b/apps/app/src/components/thread/user-questions/user-question-form-state.ts @@ -36,7 +36,7 @@ export function createInitialFormState( selected: [], // A question with no options is pure free text — "Other" is implicit. otherSelected: !questionHasOptions(question), - otherText: "", + otherText: question.experimental_prefill ?? "", }; } return state; @@ -50,7 +50,7 @@ export function answerStateFor( formState[question.id] ?? { selected: [], otherSelected: !questionHasOptions(question), - otherText: "", + otherText: question.experimental_prefill ?? "", } ); } @@ -69,6 +69,9 @@ export function isQuestionAnswered( question: PendingInteractionUserQuestionQuestion, state: QuestionAnswerState, ): boolean { + if (question.experimental_responseMode === "verbatim") { + return state.otherSelected; + } if (validSelectedValues(question, state.selected).length > 0) { return true; } @@ -79,6 +82,13 @@ function buildQuestionAnswer( question: PendingInteractionUserQuestionQuestion, state: QuestionAnswerState, ): PendingInteractionUserAnswer { + if (question.experimental_responseMode === "verbatim") { + return { + selected: [], + experimental_verbatimText: state.otherText, + }; + } + const freeText = state.otherText.trim(); const includeFreeText = state.otherSelected && freeText.length > 0; if (question.multiSelect) { diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index a4a849dee4..ff2ed9890c 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -632,6 +632,9 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { dirtySystemExecutionOptionQueries, // Refresh changed or boot-time partial provider rosters. ], }, + "provider-models-changed": { + dirty: [dirtySystemExecutionOptionQueries], + }, } satisfies SystemChangeRegistry; type ThreadChangeFlushPriority = "debounced" | "immediate"; diff --git a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts index 78f36c5f9d..e92b786316 100644 --- a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts @@ -1,6 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { Host } from "@bb/domain"; import type { + HostResponse, ThreadResponse, ThreadWithIncludesResponse, } from "@bb/server-contract"; @@ -11,11 +11,11 @@ import { threadQueryKey, } from "../queries/query-keys"; -type HostList = Host[]; +type HostList = HostResponse[]; type HostListQueryData = HostList | undefined; interface UpsertHostListArgs { - host: Host; + host: HostResponse; hosts: HostListQueryData; } diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 4be042c9d3..7db0ddad6a 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -42,6 +42,7 @@ function makeTimelineResponse(): ThreadTimelineResponse { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, @@ -340,9 +341,9 @@ describe("thread runtime cache owner", () => { expect( queryClient - .getQueryData< - ThreadQueuedMessage[] - >(threadQueuedMessagesQueryKey("thread-1")) + .getQueryData( + threadQueuedMessagesQueryKey("thread-1"), + ) ?.map((queuedMessage) => queuedMessage.id), ).toEqual(["qmsg-existing", "qmsg-server"]); expect( @@ -415,9 +416,9 @@ describe("thread runtime cache owner", () => { }); expect( queryClient - .getQueryData< - ThreadQueuedMessage[] - >(threadQueuedMessagesQueryKey("thread-1")) + .getQueryData( + threadQueuedMessagesQueryKey("thread-1"), + ) ?.map((queuedMessage) => ({ content: queuedMessage.content, groupWithNext: queuedMessage.groupWithNext, @@ -445,9 +446,9 @@ describe("thread runtime cache owner", () => { }); expect( queryClient - .getQueryData< - ThreadQueuedMessage[] - >(threadQueuedMessagesQueryKey("thread-1")) + .getQueryData( + threadQueuedMessagesQueryKey("thread-1"), + ) ?.map((queuedMessage) => queuedMessage.id), ).toEqual(["qmsg-first", "qmsg-edit", "qmsg-last"]); @@ -655,9 +656,9 @@ describe("thread runtime cache owner", () => { expect( queryClient - .getQueryData< - ThreadQueuedMessage[] - >(threadQueuedMessagesQueryKey("thread-1")) + .getQueryData( + threadQueuedMessagesQueryKey("thread-1"), + ) ?.map((queuedMessage) => queuedMessage.id), ).toEqual(["qmsg-2"]); @@ -900,9 +901,9 @@ describe("thread runtime cache owner", () => { expect( queryClient - .getQueryData< - ThreadQueuedMessage[] - >(threadQueuedMessagesQueryKey("thread-1")) + .getQueryData( + threadQueuedMessagesQueryKey("thread-1"), + ) ?.map((queuedMessage) => queuedMessage.id), ).toEqual(["qmsg-3"]); const timeline = queryClient.getQueryData( diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx index ff709ccd5d..a5fd8e9e62 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -79,6 +79,7 @@ function makeBannerTimeline(): ThreadTimelineResponse { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: { sourceSeq: 1, @@ -370,9 +371,9 @@ describe("thread runtime mutations", () => { expect( queryClient - .getQueryData< - ThreadQueuedMessage[] - >(threadQueuedMessagesQueryKey("thread-1")) + .getQueryData( + threadQueuedMessagesQueryKey("thread-1"), + ) ?.map((queuedMessage) => queuedMessage.id), ).toEqual(["qmsg-2"]); }); diff --git a/apps/app/src/hooks/queries/query-helpers.test.ts b/apps/app/src/hooks/queries/query-helpers.test.ts index bd871d38da..96b68cd1ac 100644 --- a/apps/app/src/hooks/queries/query-helpers.test.ts +++ b/apps/app/src/hooks/queries/query-helpers.test.ts @@ -183,6 +183,7 @@ function makeThreadTimelineResponse( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 075fdddf16..d451936660 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -158,6 +158,7 @@ beforeEach(() => { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, @@ -232,6 +233,7 @@ describe("useThreadDetailBootstrap", () => { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx b/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx index c66f4d126d..1c34f1b281 100644 --- a/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx +++ b/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx @@ -42,7 +42,10 @@ const BASE_ARGS = { }; beforeEach(() => { - vi.mocked(sdk.projects.commands).mockResolvedValue({ commands: [] }); + vi.mocked(sdk.projects.commands).mockResolvedValue({ + commands: [], + diagnostics: [], + }); }); afterEach(() => { diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index fbcb1085e1..3f37544c50 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react"; import type { ComposerCustomization, ExperimentalChangesViewRegistration, + ExperimentalProviderExtensionStateRegistration, ExperimentalSidebarNavigationRegistration, PluginDiffRendererRegistration, PluginPendingInteractionRegistration, @@ -67,6 +68,8 @@ export interface PluginRegistrationSet { providerIcons?: readonly PluginProviderIconRegistration[]; /** Optional for the same reason as `threadLists`: bundles built earlier. */ timelineRenderers?: readonly PluginTimelineRendererRegistration[]; + /** Optional for bundles built before provider extension-state rendering. */ + providerExtensionStates?: readonly ExperimentalProviderExtensionStateRegistration[]; } interface PluginSlotBase { @@ -120,6 +123,8 @@ interface PluginProviderIconSlot extends PluginProviderIconRegistration, PluginSlotBase {} export interface PluginTimelineRendererSlot extends PluginTimelineRendererRegistration, PluginSlotBase {} +export interface PluginProviderExtensionStateSlot + extends ExperimentalProviderExtensionStateRegistration, PluginSlotBase {} /** Flattened view across plugins, ordered by plugin id (deterministic). */ export interface PluginSlotSnapshot { @@ -143,6 +148,7 @@ export interface PluginSlotSnapshot { commandPaletteActions: readonly PluginCommandPaletteActionSlot[]; providerIcons: readonly PluginProviderIconSlot[]; timelineRenderers: readonly PluginTimelineRendererSlot[]; + providerExtensionStates: readonly PluginProviderExtensionStateSlot[]; } export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { @@ -166,6 +172,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { commandPaletteActions: [], providerIcons: [], timelineRenderers: [], + providerExtensionStates: [], }; const registrationsByPluginId = new Map(); @@ -196,6 +203,7 @@ const SLOT_KINDS: readonly SlotKind[] = [ "commandPaletteActions", "providerIcons", "timelineRenderers", + "providerExtensionStates", ]; /** @@ -247,6 +255,7 @@ function flattenRegistrations( commandPaletteActions: stamp(set.commandPaletteActions), providerIcons: stamp(set.providerIcons), timelineRenderers: stamp(set.timelineRenderers), + providerExtensionStates: stamp(set.providerExtensionStates), }; } diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx index 050e203293..9b8e5fc4d3 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx @@ -97,6 +97,7 @@ function storyTimeline( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal, modelFallback: null, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index 250054c777..da8cf154c3 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -400,6 +400,7 @@ function buildPromptArea({ childPendingInteractions={[]} childThreadsSection={null} composerEscapeBehavior="blur" + extensionStates={[]} composerFocusRequestNonce={0} contextBannerMergeBase={null} environmentGoneStatus={null} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 2c444e4d58..f89b69b998 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -722,6 +722,7 @@ function buildPromptAreaElement({ composerFocusRequestNonce={0} contextBannerMergeBase={null} environmentGoneStatus={null} + extensionStates={[]} goal={goal} modelFallback={modelFallback} isEnvironmentActionPending={false} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index c1ed3c4865..03c3f0e650 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -198,6 +198,8 @@ interface ThreadDetailPromptAreaProps { activePromptMode: ThreadTimelineActivePromptMode | null; /** Current provider goal from the timeline projection. Null when no goal is active. */ goal: ThreadTimelineGoal | null; + /** Latest plugin-declared provider state, one snapshot per kind. */ + extensionStates: ThreadTimelineResponse["extensionStates"]; /** Active provider fallback; controls the next model selection until another turn is requested. */ modelFallback: ThreadTimelineModelFallback | null; /** @@ -436,6 +438,7 @@ export function ThreadDetailPromptArea({ pendingTodos, activePromptMode, goal, + extensionStates, modelFallback, activeWorkflows, activeBackgroundCommands, @@ -1775,6 +1778,11 @@ export function ThreadDetailPromptArea({ composer={shouldHideComposer ? null : bottomComposerConfig} pluginComposerHost={normalPluginComposerHost} pluginComposerScope={normalPluginComposerHost.scope} + providerExtensionState={{ + extensionStates, + providerId: thread.providerId, + threadId: thread.id, + }} textEffects={promptTextEffects} collapseResetKey={thread.id} focusEndKey={bottomFocusEndKey} diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index e17d3da0bd..f626ef51f4 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -862,6 +862,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { activeWorkflows, activeBackgroundCommands, contextWindowUsage, + extensionStates, goal, hasOlderTimelineRows, isLoadingOlderTimelineRows, @@ -2613,6 +2614,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { pendingTodos={pendingTodos} activePromptMode={activePromptMode} goal={goal} + extensionStates={extensionStates} modelFallback={modelFallback} activeWorkflows={activeWorkflows} activeBackgroundCommands={activeBackgroundCommands} diff --git a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts index 301fb76a77..4bfbf532b7 100644 --- a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts +++ b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts @@ -41,9 +41,11 @@ interface MakeEnvironmentArgs extends Partial { * mismatched pair is a fixture bug and throws. */ type MakePendingInteractionArgs = Partial< - Omit + Omit > & { id: string; + /** A fixture's question is turn-scoped unless a test says otherwise. */ + turnId?: string; providerId: string; threadId: string; payload?: ProviderPendingInteraction["payload"]; @@ -81,6 +83,7 @@ export function makeTimelineResponse( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/cli/src/commands/thread/interactions.ts b/apps/cli/src/commands/thread/interactions.ts index d8d04af483..f91dfec5de 100644 --- a/apps/cli/src/commands/thread/interactions.ts +++ b/apps/cli/src/commands/thread/interactions.ts @@ -210,7 +210,10 @@ function printUserQuestionInteraction( ); const parts = [ ...selectedLabels, - ...(answer.freeText ? [answer.freeText] : []), + ...(answer.freeText !== undefined ? [answer.freeText] : []), + ...(answer.experimental_verbatimText !== undefined + ? [answer.experimental_verbatimText] + : []), ]; console.log( ` ${question.shortLabel ?? question.prompt}: ${parts.join(", ")}`, @@ -475,7 +478,10 @@ function validateAnswerText( if (trimmed.length === 0) { throw new Error(`Question '${question.id}' free text cannot be empty.`); } - if (answer.freeText !== undefined) { + if ( + answer.freeText !== undefined || + answer.experimental_verbatimText !== undefined + ) { throw new Error( `Question '${question.id}' has multiple free-text answers.`, ); @@ -527,7 +533,19 @@ function buildUserAnswerResolution({ ); } const answer = answers[textAnswer.questionId] ?? { selected: [] }; - answer.freeText = validateAnswerText(question, answer, textAnswer.value); + if (question.experimental_responseMode === "verbatim") { + if ( + answer.freeText !== undefined || + answer.experimental_verbatimText !== undefined + ) { + throw new Error( + `Question '${question.id}' has multiple free-text answers.`, + ); + } + answer.experimental_verbatimText = textAnswer.value; + } else { + answer.freeText = validateAnswerText(question, answer, textAnswer.value); + } answers[textAnswer.questionId] = answer; } @@ -535,7 +553,9 @@ function buildUserAnswerResolution({ const answer = answers[question.id]; if ( !answer || - (answer.selected.length === 0 && answer.freeText === undefined) + (answer.selected.length === 0 && + answer.freeText === undefined && + answer.experimental_verbatimText === undefined) ) { throw new Error(`Missing answer for question '${question.id}'.`); } diff --git a/apps/cli/src/commands/thread/pending-todos.test.ts b/apps/cli/src/commands/thread/pending-todos.test.ts index 553904a777..aba562a7b2 100644 --- a/apps/cli/src/commands/thread/pending-todos.test.ts +++ b/apps/cli/src/commands/thread/pending-todos.test.ts @@ -109,6 +109,7 @@ describe("fetchThreadPendingTodos", () => { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos, goal: null, modelFallback: null, diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts index 6cb6bc66d6..95a230b588 100644 --- a/apps/demo-server/src/demo-world.ts +++ b/apps/demo-server/src/demo-world.ts @@ -448,6 +448,7 @@ export class DemoWorld { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts index 87d66aa8e2..74e1337e78 100644 --- a/apps/host-daemon/src/app.test.ts +++ b/apps/host-daemon/src/app.test.ts @@ -250,6 +250,9 @@ function createFakeRuntime(): AgentRuntime { async stopThread() { return { providerCheckpointId: null }; }, + async applyExtensionAction() { + return { applied: true }; + }, async clearThreadGoal() { return { cleared: true }; }, @@ -262,6 +265,9 @@ function createFakeRuntime(): AgentRuntime { selectedOnlyModels: [], }; }, + async listProviderCommands() { + return { supported: false as const }; + }, async providerHealth() { return { supported: false as const }; }, diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts index 6026245d66..0508d26aef 100644 --- a/apps/host-daemon/src/app.ts +++ b/apps/host-daemon/src/app.ts @@ -412,6 +412,13 @@ export async function createHostDaemonApp( }); const interactiveRequestRegistry = new InteractiveRequestRegistry({ + onCancellation: (request, reason) => { + enqueueInteractiveInterrupt({ + providerId: request.providerId, + reason, + threadIds: [request.threadId], + }); + }, registerRequest: (request) => runSessionRequest({ source: "registerInteractiveRequest", @@ -584,9 +591,12 @@ export async function createHostDaemonApp( throw error; } }), - onInteractiveRequest: async (request) => { + onInteractiveRequest: async (request, signal) => { try { - return await interactiveRequestRegistry.registerAndWait(request); + return await interactiveRequestRegistry.registerAndWait( + request, + signal, + ); } catch (error) { if ( error instanceof InteractiveRequestRegistryError && @@ -772,6 +782,27 @@ export async function createHostDaemonApp( (runtime) => runtime.listModels(args), ); }, + providerCustomCall: async (args) => { + await refreshRuntimeShellEnv(); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => { + if (runtime.providerCustomCall === undefined) { + throw new Error( + "Provider runtime does not support custom bridge calls", + ); + } + return runtime.providerCustomCall(args); + }, + ); + }, + listProviderCommands: async (args) => { + await refreshRuntimeShellEnv(); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => runtime.listProviderCommands(args), + ); + }, providerHealth: async (args) => { await refreshRuntimeShellEnv(); return runtimeManager.withProviderMaintenanceRuntime( diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index 1e140dba64..54a53925d9 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -1,5 +1,5 @@ import type { AgentRuntimeBridgeLaunch } from "@bb/agent-runtime"; -import type { AvailableModel } from "@bb/domain"; +import type { AvailableModel, JsonValue } from "@bb/domain"; import type { EventSinkInput } from "./event-sink.js"; import type { HostDaemonCommand, @@ -12,6 +12,7 @@ import type { WorkspaceContext, } from "@bb/host-daemon-contract"; import type { + ExperimentalProviderCommandListResult, ProviderInstallationCommand, ProviderInstallationRunResult, ProviderInstallationStatus, @@ -63,6 +64,17 @@ export interface CommandDispatchOptions { models: AvailableModel[]; selectedOnlyModels: AvailableModel[]; }>; + providerCustomCall: (args: { + providerId: string; + bridgeLaunch: AgentRuntimeBridgeLaunch; + method: string; + input: JsonValue; + }) => Promise; + listProviderCommands: (args: { + providerId: string; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd: string; + }) => Promise; providerHealth: (args: { providerId: string; bridgeLaunch: AgentRuntimeBridgeLaunch; diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts index 6077164c29..c27a5bc369 100644 --- a/apps/host-daemon/src/command-dispatch.test.ts +++ b/apps/host-daemon/src/command-dispatch.test.ts @@ -192,6 +192,7 @@ function createRuntime(): FakeDispatchRuntime { hostedThreadIds.delete(args.threadId); return { providerCheckpointId: null }; }), + applyExtensionAction: vi.fn(async () => ({ applied: true })), clearThreadGoal: vi.fn(async () => ({ cleared: true })), renameThread: vi.fn(async () => undefined), archiveThread: vi.fn(async () => undefined), @@ -200,6 +201,7 @@ function createRuntime(): FakeDispatchRuntime { models: [], selectedOnlyModels: [], })), + listProviderCommands: vi.fn(async () => ({ supported: false as const })), providerHealth: vi.fn(async () => ({ supported: false as const })), providerUsage: vi.fn(async () => ({ supported: false as const })), providerInstallationStatus: vi.fn(async () => { diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index 8242df08a4..df0bccfb1e 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -343,6 +343,22 @@ const commandHandlers: CommandHandlerMap = { release(); } }, + "thread.extension-state.action": async (command, options) => { + const entry = await options.runtimeManager.getOrAwait( + command.environmentId, + ); + if (!entry) { + throw new ExpectedCommandDispatchError( + "unknown_environment", + `No runtime exists for environment ${command.environmentId}`, + ); + } + return entry.runtime.applyExtensionAction({ + threadId: command.threadId, + extensionKind: command.extensionKind, + action: command.action, + }); + }, "thread.stop": async (command, options) => { // Release before the target runtime lookup. A moved thread often has no // runtime in its new environment yet, and the old owner must still stop. @@ -633,6 +649,19 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { bridgeLaunch, }); }, + "provider.custom_call": async (command, options) => { + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + const result = await options.providerCustomCall({ + providerId: command.providerId, + bridgeLaunch, + method: command.method, + input: command.input, + }); + return { result }; + }, "provider.health": async (command, options) => { const bridgeLaunch = await resolveRuntimeBridgeLaunch( command.bridgeLaunch, diff --git a/apps/host-daemon/src/command-handlers/list-commands.ts b/apps/host-daemon/src/command-handlers/list-commands.ts index 36961e3d09..8afa127d80 100644 --- a/apps/host-daemon/src/command-handlers/list-commands.ts +++ b/apps/host-daemon/src/command-handlers/list-commands.ts @@ -10,6 +10,8 @@ import type { import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; import { CommandDispatchError, + resolveRuntimeBridgeLaunch, + type CommandDispatchOptions, type CommandOf, } from "../command-dispatch-support.js"; import { @@ -437,6 +439,7 @@ export async function resolveDeclaredScanRoots( export async function listHostCommands( command: CommandOf<"host.list_commands">, + options: CommandDispatchOptions, ): Promise> { if (command.cwd !== null && !path.isAbsolute(command.cwd)) { throw new CommandDispatchError("invalid_path", "cwd must be absolute"); @@ -448,5 +451,39 @@ export async function listHostCommands( nativeRoots: command.nativeRoots, }); const commands = await discoverProviderCommands({ roots }); - return { commands }; + if (command.cwd === null || command.bridgeLaunch === undefined) { + return { commands, diagnostics: [] }; + } + + // The bridge's own commands are an addition to the scan, never a + // condition of it: a bridge that cannot launch or answer leaves the static + // commands standing and says why in the diagnostics. + let bridgeResult; + try { + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + bridgeResult = await options.listProviderCommands({ + providerId: command.providerId, + bridgeLaunch, + cwd: command.cwd, + }); + } catch (error) { + return { + commands, + diagnostics: [ + `${command.providerId} could not list its commands: ${ + error instanceof Error ? error.message : String(error) + }`, + ], + }; + } + if (!bridgeResult.supported) { + return { commands, diagnostics: [] }; + } + return { + commands: [...commands, ...bridgeResult.commands], + diagnostics: bridgeResult.diagnostics, + }; } diff --git a/apps/host-daemon/src/command-router.ts b/apps/host-daemon/src/command-router.ts index 4fe66b3091..8fe7da3f87 100644 --- a/apps/host-daemon/src/command-router.ts +++ b/apps/host-daemon/src/command-router.ts @@ -72,6 +72,8 @@ export interface CommandRouterOptions { terminalManager?: CommandDispatchOptions["terminalManager"]; eventSink: CommandDispatchOptions["eventSink"]; listModels: CommandDispatchOptions["listModels"]; + providerCustomCall: CommandDispatchOptions["providerCustomCall"]; + listProviderCommands: CommandDispatchOptions["listProviderCommands"]; providerHealth: CommandDispatchOptions["providerHealth"]; providerUsage: CommandDispatchOptions["providerUsage"]; providerInstallationStatus: CommandDispatchOptions["providerInstallationStatus"]; @@ -297,6 +299,8 @@ export class CommandRouter { dataDir: this.options.dataDir, eventSink: this.options.eventSink, listModels: this.options.listModels, + providerCustomCall: this.options.providerCustomCall, + listProviderCommands: this.options.listProviderCommands, providerHealth: this.options.providerHealth, providerUsage: this.options.providerUsage, providerInstallationStatus: this.options.providerInstallationStatus, diff --git a/apps/host-daemon/src/interactive-request-registry.test.ts b/apps/host-daemon/src/interactive-request-registry.test.ts index 660e3a7e8c..2a02b5dfbc 100644 --- a/apps/host-daemon/src/interactive-request-registry.test.ts +++ b/apps/host-daemon/src/interactive-request-registry.test.ts @@ -11,6 +11,7 @@ import { } from "./interactive-request-registry.js"; interface CreateRegistryArgs { + onCancellation?: (request: PendingInteractionCreate, reason: string) => void; registerRequest: ( request: PendingInteractionCreate, ) => Promise; @@ -54,6 +55,7 @@ function createCommandApprovalResolution(): PendingInteractionResolution { function createRegistry(args: CreateRegistryArgs): InteractiveRequestRegistry { return new InteractiveRequestRegistry({ + onCancellation: args.onCancellation, registerRequest: args.registerRequest, }); } @@ -183,6 +185,43 @@ describe("InteractiveRequestRegistry", () => { }); }); + it("cancels a registered request and rejects a late answer", async () => { + const request = createCommandApprovalRequest(); + const cancellations: Array<{ + request: PendingInteractionCreate; + reason: string; + }> = []; + const registry = createRegistry({ + onCancellation: (cancelledRequest, reason) => + cancellations.push({ request: cancelledRequest, reason }), + registerRequest: async () => ({ + outcome: "created", + interactionId: "pint_registry", + status: "pending", + }), + }); + const controller = new AbortController(); + const pending = registry.registerAndWait(request, controller.signal); + await Promise.resolve(); + + controller.abort(new Error("Pi extensions reloaded")); + + await expect(pending).rejects.toThrow("Pi extensions reloaded"); + expect(cancellations).toEqual([ + { request, reason: "Pi extensions reloaded" }, + ]); + expect(() => + registry.resolve({ + interactionId: "pint_registry", + providerId: request.providerId, + providerRequestId: request.providerRequestId, + providerThreadId: request.providerThreadId, + resolution: createCommandApprovalResolution(), + threadId: request.threadId, + }), + ).toThrowError(InteractiveRequestRegistryError); + }); + it("rejects provider waits when the provider exits", async () => { const request = createCommandApprovalRequest(); const registry = createRegistry({ diff --git a/apps/host-daemon/src/interactive-request-registry.ts b/apps/host-daemon/src/interactive-request-registry.ts index c353bc710f..7626108e44 100644 --- a/apps/host-daemon/src/interactive-request-registry.ts +++ b/apps/host-daemon/src/interactive-request-registry.ts @@ -22,6 +22,7 @@ interface InteractiveRequestRegistrationFailure { } interface InteractiveRequestRegistryOptions { + onCancellation?: (request: PendingInteractionCreate, reason: string) => void; onRegistrationFailure?: ( failure: InteractiveRequestRegistrationFailure, ) => void; @@ -105,6 +106,7 @@ export class InteractiveRequestRegistry { async registerAndWait( request: PendingInteractionCreate, + signal?: AbortSignal, ): Promise { const key = buildInteractiveRequestKey(request); const existing = this.pendingEntries.get(key); @@ -130,6 +132,28 @@ export class InteractiveRequestRegistry { request, }; this.pendingEntries.set(key, entry); + const abort = () => { + if (this.pendingEntries.get(key) !== entry) { + return; + } + this.pendingEntries.delete(key); + const cancellationError = + signal?.reason instanceof Error + ? signal.reason + : new Error("Interactive request cancelled"); + entry.reject(cancellationError); + if (entry.interactionId !== null) { + this.options.onCancellation?.(request, cancellationError.message); + } + }; + signal?.addEventListener("abort", abort, { once: true }); + void promise.then( + () => signal?.removeEventListener("abort", abort), + () => signal?.removeEventListener("abort", abort), + ); + if (signal?.aborted) { + abort(); + } try { const response = await this.options.registerRequest(request); @@ -145,6 +169,15 @@ export class InteractiveRequestRegistry { } entry.interactionId = response.interactionId; + if (signal?.aborted) { + this.options.onCancellation?.( + request, + signal.reason instanceof Error + ? signal.reason.message + : "Interactive request cancelled", + ); + return promise; + } if (response.status !== "pending" && response.status !== "resolving") { this.pendingEntries.delete(key); entry.reject( diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index 0651f256b7..fce2711000 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -270,6 +270,7 @@ function createFakeRuntime() { stopThread: vi.fn(async (_args: StopThreadArgs) => ({ providerCheckpointId: null, })), + applyExtensionAction: vi.fn(async () => ({ applied: true })), clearThreadGoal: vi.fn(async () => ({ cleared: true })), renameThread: vi.fn(async (_args: RenameThreadArgs) => undefined), archiveThread: vi.fn(async () => undefined), @@ -278,6 +279,7 @@ function createFakeRuntime() { models: [], selectedOnlyModels: [], })), + listProviderCommands: vi.fn(async () => ({ supported: false as const })), providerHealth: vi.fn(async () => ({ supported: false as const })), providerUsage: vi.fn(async () => ({ supported: false as const })), providerInstallationStatus: vi.fn(async () => { diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts index 21d89ab0cb..157107aeaa 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.test.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts @@ -212,11 +212,13 @@ function createFakeRuntime(): AgentRuntime { runTurn: vi.fn(async () => undefined), steerTurn: vi.fn(async () => steerTurnResult), stopThread: vi.fn(async () => ({ providerCheckpointId: null })), + applyExtensionAction: vi.fn(async () => ({ applied: true })), clearThreadGoal: vi.fn(async () => ({ cleared: true })), renameThread: vi.fn(async () => undefined), archiveThread: vi.fn(async () => undefined), unarchiveThread: vi.fn(async () => undefined), listModels: vi.fn(async () => ({ models: [], selectedOnlyModels: [] })), + listProviderCommands: vi.fn(async () => ({ supported: false as const })), providerHealth: vi.fn(async () => ({ supported: false as const })), providerUsage: vi.fn(async () => ({ supported: false as const })), providerInstallationStatus: vi.fn(async () => { diff --git a/apps/host-daemon/test/command/dispatch-helpers.ts b/apps/host-daemon/test/command/dispatch-helpers.ts index 0e4670237e..1d3cfb840c 100644 --- a/apps/host-daemon/test/command/dispatch-helpers.ts +++ b/apps/host-daemon/test/command/dispatch-helpers.ts @@ -53,6 +53,8 @@ export const unexpectedProjectAttachmentFetch: FetchProjectAttachment = export const unexpectedProviderMaintenance: Pick< CommandDispatchOptions, | "listModels" + | "providerCustomCall" + | "listProviderCommands" | "providerHealth" | "providerUsage" | "providerInstallationStatus" @@ -62,6 +64,12 @@ export const unexpectedProviderMaintenance: Pick< listModels: async () => { throw new Error("Unexpected provider.list_models call"); }, + providerCustomCall: async () => { + throw new Error("Unexpected provider.custom_call call"); + }, + listProviderCommands: async () => { + throw new Error("Unexpected host.list_commands bridge call"); + }, providerHealth: async () => { throw new Error("Unexpected provider.health call"); }, @@ -429,6 +437,9 @@ export function createFakeRuntime() { providerSessionsByThreadId.delete(args.threadId); return { providerCheckpointId: null }; }, + async applyExtensionAction() { + return { applied: true }; + }, async clearThreadGoal() { return { cleared: true }; }, @@ -481,6 +492,9 @@ export function createFakeRuntime() { selectedOnlyModels: [] satisfies AvailableModel[], }; }, + async listProviderCommands() { + return { supported: false as const }; + }, async providerHealth() { return { supported: false as const }; }, diff --git a/apps/host-daemon/test/command/list-commands-bridge.test.ts b/apps/host-daemon/test/command/list-commands-bridge.test.ts new file mode 100644 index 0000000000..66652db356 --- /dev/null +++ b/apps/host-daemon/test/command/list-commands-bridge.test.ts @@ -0,0 +1,128 @@ +import { EMPTY_PROVIDER_RESOLVED_NATIVE_ROOTS } from "@bb/domain"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dispatchOnlineRpcCommand } from "../../src/command-dispatch.js"; +import { + cleanupTempDirs, + createHarness, + DISPATCH_TEST_BRIDGE_LAUNCH, + makeTempDir, +} from "./dispatch-helpers.js"; + +afterEach(cleanupTempDirs); + +/** + * `host.list_commands` with a bridge launch: the static scan of the + * provider's native roots is merged with what the provider's bridge lists + * for the cwd (`command/list`), and the bridge's diagnostics ride along. + * Without a launch, or without a cwd, or when the bridge does not implement + * the method, the static scan stands alone. + */ +describe("host.list_commands bridge merge", () => { + const emptyRoots = { + skills: { user: [], project: [] }, + commands: { user: [], project: [] }, + resolved: EMPTY_PROVIDER_RESOLVED_NATIVE_ROOTS, + }; + + it("merges bridge commands and diagnostics with the static scan", async () => { + const cwd = await makeTempDir("bb-list-commands-bridge-"); + const harness = createHarness({ workspacePath: cwd }); + const options = harness.dispatchOptions(); + options.listProviderCommands = vi.fn(async () => ({ + supported: true as const, + commands: [ + { + name: "project-smoke", + source: "command" as const, + origin: "project" as const, + description: "Project smoke command", + argumentHint: null, + }, + ], + diagnostics: ['Failed to load Pi extension "broken.ts": syntax error'], + })); + + const result = await dispatchOnlineRpcCommand( + { + type: "host.list_commands", + providerId: "pi", + cwd, + nativeRoots: emptyRoots, + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + options, + ); + + expect(result.commands.map((command) => command.name)).toEqual(["project-smoke"]); + expect(result.diagnostics).toEqual(['Failed to load Pi extension "broken.ts": syntax error']); + expect(options.listProviderCommands).toHaveBeenCalledWith({ + providerId: "pi", + bridgeLaunch: expect.objectContaining({ pluginId: "provider-pi" }), + cwd, + }); + }); + + it("keeps the static scan and reports a bridge that fails to answer", async () => { + const cwd = await makeTempDir("bb-list-commands-failing-"); + const harness = createHarness({ workspacePath: cwd }); + const options = harness.dispatchOptions(); + options.listProviderCommands = vi.fn(async () => { + throw new Error("pi exited before its extension reported ready"); + }); + + const result = await dispatchOnlineRpcCommand( + { + type: "host.list_commands", + providerId: "pi", + cwd, + nativeRoots: emptyRoots, + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + options, + ); + + expect(result.commands).toEqual([]); + expect(result.diagnostics).toEqual([ + "pi could not list its commands: pi exited before its extension reported ready", + ]); + }); + + it("keeps the static scan alone without a launch, without a cwd, or for a bridge without the method", async () => { + const cwd = await makeTempDir("bb-list-commands-static-"); + const harness = createHarness({ workspacePath: cwd }); + const options = harness.dispatchOptions(); + const listProviderCommands = vi.fn(async () => ({ supported: false as const })); + options.listProviderCommands = listProviderCommands; + + const withoutLaunch = await dispatchOnlineRpcCommand( + { type: "host.list_commands", providerId: "pi", cwd, nativeRoots: emptyRoots }, + options, + ); + expect(withoutLaunch).toEqual({ commands: [], diagnostics: [] }); + const withoutCwd = await dispatchOnlineRpcCommand( + { + type: "host.list_commands", + providerId: "pi", + cwd: null, + nativeRoots: emptyRoots, + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + options, + ); + expect(withoutCwd).toEqual({ commands: [], diagnostics: [] }); + expect(listProviderCommands).not.toHaveBeenCalled(); + + const unsupported = await dispatchOnlineRpcCommand( + { + type: "host.list_commands", + providerId: "pi", + cwd, + nativeRoots: emptyRoots, + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + options, + ); + expect(unsupported).toEqual({ commands: [], diagnostics: [] }); + expect(listProviderCommands).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/data/interactions/question-form-state.ts b/apps/mobile/src/data/interactions/question-form-state.ts index a4d5519a93..e3ce1fe0cf 100644 --- a/apps/mobile/src/data/interactions/question-form-state.ts +++ b/apps/mobile/src/data/interactions/question-form-state.ts @@ -41,6 +41,9 @@ export interface InteractionFormQuestion { multiSelect: boolean; options: readonly InteractionFormOption[]; allowFreeText: boolean; + experimental_responseMode?: "verbatim"; + experimental_placeholder?: string; + experimental_prefill?: string; } export interface QuestionAnswerState { @@ -68,6 +71,15 @@ export function normalizeUserQuestion( : {}), })), allowFreeText: question.allowFreeText, + ...(question.experimental_responseMode !== undefined + ? { experimental_responseMode: question.experimental_responseMode } + : {}), + ...(question.experimental_placeholder !== undefined + ? { experimental_placeholder: question.experimental_placeholder } + : {}), + ...(question.experimental_prefill !== undefined + ? { experimental_prefill: question.experimental_prefill } + : {}), }; } @@ -114,7 +126,7 @@ function initialAnswerState( selected: [], // A question with no options is pure free text — "Other" is implicit. otherSelected: !questionHasOptions(question), - otherText: "", + otherText: question.experimental_prefill ?? "", }; } @@ -147,6 +159,9 @@ export function isQuestionAnswered( question: InteractionFormQuestion, state: QuestionAnswerState, ): boolean { + if (question.experimental_responseMode === "verbatim") { + return state.otherSelected; + } if (validSelectedValues(question, state.selected).length > 0) return true; return state.otherSelected && state.otherText.trim().length > 0; } @@ -199,6 +214,13 @@ function buildQuestionAnswer( question: InteractionFormQuestion, state: QuestionAnswerState, ): PendingInteractionUserAnswer { + if (question.experimental_responseMode === "verbatim") { + return { + selected: [], + experimental_verbatimText: state.otherText, + }; + } + const freeText = state.otherText.trim(); const includeFreeText = state.otherSelected && freeText.length > 0; if (question.multiSelect) { diff --git a/apps/mobile/src/data/test/fixtures.ts b/apps/mobile/src/data/test/fixtures.ts index c14bfd23eb..60e8522193 100644 --- a/apps/mobile/src/data/test/fixtures.ts +++ b/apps/mobile/src/data/test/fixtures.ts @@ -154,6 +154,7 @@ export function timelineResponse( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts b/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts index 09767f3de4..dbf8bfe4bc 100644 --- a/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts +++ b/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts @@ -33,6 +33,7 @@ function timeline( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/mobile/src/lib/query/realtime-invalidation.ts b/apps/mobile/src/lib/query/realtime-invalidation.ts index 03ac59becb..9264baecd4 100644 --- a/apps/mobile/src/lib/query/realtime-invalidation.ts +++ b/apps/mobile/src/lib/query/realtime-invalidation.ts @@ -314,6 +314,12 @@ export function queryKeysForChangedMessage( allProjectDefaultExecutionOptionsQueryKeyPrefix(), ); } + if (kinds.has("provider-models-changed")) { + keys.push( + allSystemExecutionOptionsQueryKeyPrefix(), + allProjectDefaultExecutionOptionsQueryKeyPrefix(), + ); + } if (kinds.has("plugins-changed")) { // Plugin mention providers / skills come and go with plugins. keys.push( diff --git a/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx b/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx index 972c7126a7..51a7267f16 100644 --- a/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx +++ b/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx @@ -220,9 +220,16 @@ function QuestionInputBlock({ accessibilityLabel={`${question.shortLabel} answer`} value={state.otherText} editable={!disabled} - autoCapitalize="sentences" + // A verbatim answer goes back byte-for-byte (a Pi extension's + // input or editor text); the keyboard must not rewrite it. + autoCapitalize={ + question.experimental_responseMode === "verbatim" ? "none" : "sentences" + } + autoCorrect={question.experimental_responseMode !== "verbatim"} onChangeText={onFreeTextChange} - placeholder="Type your own answer…" + placeholder={ + question.experimental_placeholder ?? "Type your own answer…" + } className="mt-2 max-h-40 bg-surface-raised" testID="question-free-text" /> diff --git a/apps/server/src/internal/extension-payloads.ts b/apps/server/src/internal/extension-payloads.ts index 363f464a87..25c7d284ed 100644 --- a/apps/server/src/internal/extension-payloads.ts +++ b/apps/server/src/internal/extension-payloads.ts @@ -93,7 +93,9 @@ function extensionSiteOf(event: ThreadEvent): ExtensionPayloadSite | null { } } -type ValidationOutcome = { ok: true } | { ok: false; reason: string }; +export type ExtensionValidationOutcome = + | { ok: true } + | { ok: false; reason: string }; /** A Standard Schema issue path is a bare key or a list of keys/`{ key }`s. */ function issuePathSegments(path: StandardSchemaV1Issue["path"]): string[] { @@ -113,7 +115,7 @@ function issuePathSegments(path: StandardSchemaV1Issue["path"]): string[] { async function validateAgainstSchema( schema: StandardSchemaV1, payload: JsonValue, -): Promise { +): Promise { let result: StandardSchemaV1Result; try { result = await schema["~standard"].validate(payload); @@ -167,7 +169,7 @@ async function validateSite( deps: ExtensionPayloadValidationDeps, site: ExtensionPayloadSite, providerId: string | null, -): Promise { +): Promise { const ownership = extensionOwnershipProblem(deps, site, providerId); if (ownership !== null) { return { ok: false, reason: ownership }; @@ -194,6 +196,39 @@ async function validateSite( return validateAgainstSchema(schema, site.payload); } +export async function validateExtensionAction( + deps: ExtensionPayloadValidationDeps, + args: { + providerId: string; + kind: ExtensionKind; + action: JsonValue; + }, +): Promise { + const registration = deps.providerRegistry.get(args.providerId); + const { pluginId, name } = parseExtensionKind(args.kind); + if (registration === null || registration.pluginId !== pluginId) { + return { + ok: false, + reason: `extension kind ${JSON.stringify(args.kind)} is not owned by provider ${JSON.stringify(args.providerId)}`, + }; + } + const schema = registration.extensionKinds[name]?.experimental_action; + if (schema === undefined) { + return { + ok: false, + reason: `extension kind ${JSON.stringify(args.kind)} declares no experimental action schema`, + }; + } + const bytes = Buffer.byteLength(JSON.stringify(args.action)); + if (bytes > EXTENSION_PAYLOAD_MAX_BYTES) { + return { + ok: false, + reason: `action is ${bytes} bytes; the limit is ${EXTENSION_PAYLOAD_MAX_BYTES}`, + }; + } + return validateAgainstSchema(schema, args.action); +} + /** * The rejected event's visible replacement. The thread's provider id is what * `provider/unhandled` is counted under; the kind and payload ride the raw diff --git a/apps/server/src/internal/interactive-requests.ts b/apps/server/src/internal/interactive-requests.ts index 93d604e3f0..366d2d7074 100644 --- a/apps/server/src/internal/interactive-requests.ts +++ b/apps/server/src/internal/interactive-requests.ts @@ -167,13 +167,15 @@ export function registerInternalInteractiveRequestRoutes( ); } - // Daemons must flush provider turn events before every interactive - // registration attempt. This precondition keeps the server from - // accepting turn-scoped interaction state before turn/started exists. - const turnStarted = hasStoredTurnStarted(deps.db, { - threadId: payload.interaction.threadId, - turnId: payload.interaction.turnId, - }); + // Daemons must flush provider turn events before every turn-scoped + // interactive registration attempt. A null turn is an explicitly + // thread-scoped extension question and has no turn/started prerequisite. + const turnStarted = + payload.interaction.turnId === null || + hasStoredTurnStarted(deps.db, { + threadId: payload.interaction.threadId, + turnId: payload.interaction.turnId, + }); if (!turnStarted) { deps.logger.warn( { diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index c783a42683..c0af59dbb9 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -88,6 +88,7 @@ import { providerHasNativeRootSurface, scanProviderNativeRoots, } from "../services/providers/native-roots.js"; +import { resolveBridgeLaunchForProviderId } from "../services/system/provider-bridge-launch.js"; import { assertUsableHostId } from "../services/hosts/primary-host.js"; import { resolveProjectCommandWorkspace, @@ -700,7 +701,7 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { // has no typeahead entries: skip every roundtrip. const registration = deps.providerRegistry.get(query.provider); if (registration === null || !providerHasCommandSurface(registration)) { - return context.json({ commands: [] }); + return context.json({ commands: [], diagnostics: [] }); } const workspace = resolveProjectCommandWorkspace(deps, { @@ -716,13 +717,17 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { // with no roots on either side has nothing for the daemon to scan. const listProviderCommands = async () => { if (!providerHasNativeRootSurface(registration)) { - return { commands: [] }; + return { commands: [], diagnostics: [] }; } return scanProviderNativeRoots(deps, { type: "host.list_commands", registration, hostId: workspace.hostId, cwd: workspace.cwd, + // The bridge lists the commands its own resources register (pi's + // extension commands); a bridge without the method answers + // unsupported and the static scan stands alone. + bridgeLaunch: resolveBridgeLaunchForProviderId(deps, query.provider), }); }; const [result, projectSkillSources, sharedSkills] = await Promise.all([ @@ -745,6 +750,7 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { return context.json( buildCommandListResponse({ commands: result.commands, + diagnostics: result.diagnostics, includeBuiltinCompact: deps.providerRegistry.supportsManualCompaction( query.provider, ), diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts index 55df5f7779..a33466be5f 100644 --- a/apps/server/src/routes/threads/actions.ts +++ b/apps/server/src/routes/threads/actions.ts @@ -31,6 +31,7 @@ import { } from "@bb/domain"; import type { AppDeps } from "../../types.js"; import { ApiError } from "../../errors.js"; +import { validateExtensionAction } from "../../internal/extension-payloads.js"; import { toThreadQueuedMessage } from "../../services/threads/thread-queued-messages.js"; import { requestEnvironmentCleanup, @@ -447,6 +448,35 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void { ); }); + post(routes.experimental_extensionStateAction, async (context, payload) => { + const thread = requirePublicThread(deps.db, context.req.param("id")); + const validation = await validateExtensionAction(deps, { + providerId: thread.providerId, + kind: payload.kind, + action: payload.action, + }); + if (!validation.ok) { + throw new ApiError(400, "invalid_request", validation.reason); + } + const environment = requireThreadHostCommandEnvironment({ + db: deps.db, + thread, + }); + return context.json( + await runLiveHostCommand(deps, { + command: { + type: "thread.extension-state.action", + environmentId: environment.id, + threadId: thread.id, + extensionKind: payload.kind, + action: payload.action, + }, + hostId: environment.hostId, + timeoutMs: LIVE_DAEMON_COMMAND_TIMEOUT_MS, + }), + ); + }); + post(routes.compact, async (context) => { const thread = requirePublicThread(deps.db, context.req.param("id")); await compactThreadContext(deps, thread); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 548deb63f7..985d2938e2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -77,8 +77,11 @@ import { } from "./browser-request-guard.js"; import { callPluginHostRpc, + callPluginProviderBridgeRpc, disposePluginHostWorkers, } from "./services/plugins/plugin-host-rpc.js"; +import { requireBridgeLaunchForProviderId } from "./services/system/provider-bridge-launch.js"; +import { publishProviderModelsChanged } from "./services/system/provider-model-cache.js"; /** * `/api/v1/plugins//http/...` — the plugin-owned wire, whose auth mode is @@ -436,6 +439,31 @@ export function createApp( }), ), callPluginHost: (args) => callPluginHostRpc(deps, args), + callProviderBridge: (args) => { + const registration = deps.providerRegistry.get(args.providerId); + if (registration === null || registration.pluginId !== args.pluginId) { + throw new Error( + `plugin "${args.pluginId}" does not own provider "${args.providerId}"`, + ); + } + return callPluginProviderBridgeRpc(deps, { + ...args, + bridgeLaunch: requireBridgeLaunchForProviderId(deps, args.providerId), + }); + }, + providerModelsChanged: ({ pluginId, providerId, hostId }) => { + const registration = deps.providerRegistry.get(providerId); + if (registration === null || registration.pluginId !== pluginId) { + throw new Error( + `plugin "${pluginId}" does not own provider "${providerId}"`, + ); + } + publishProviderModelsChanged({ + providerModelList: deps.lifecycleDedupers.providerModelList, + notifySystem: (changes) => deps.hub.notifySystem(changes), + hostId, + }); + }, disposePluginHost: (args) => disposePluginHostWorkers(deps, args), // A plugin resolves its providers' native roots from its settings, so a // settings save must reach the next listing, not the cached answer. diff --git a/apps/server/src/services/interactions/pending-interaction-validation.ts b/apps/server/src/services/interactions/pending-interaction-validation.ts index 864997fee9..c28ee257d1 100644 --- a/apps/server/src/services/interactions/pending-interaction-validation.ts +++ b/apps/server/src/services/interactions/pending-interaction-validation.ts @@ -154,7 +154,9 @@ export function pendingInteractionResolutionEquals( leftAnswer !== undefined && rightAnswer !== undefined && stringSetEquals(leftAnswer.selected, rightAnswer.selected) && - leftAnswer.freeText === rightAnswer.freeText + leftAnswer.freeText === rightAnswer.freeText && + leftAnswer.experimental_verbatimText === + rightAnswer.experimental_verbatimText ); }); } @@ -443,6 +445,29 @@ function validateUserQuestionResolution( } } + if (question.experimental_responseMode === "verbatim") { + if ( + answer.experimental_verbatimText === undefined || + answer.freeText !== undefined || + answer.selected.length > 0 + ) { + throw new ApiError( + 400, + "invalid_request", + `Question '${question.id}' requires exactly one verbatim text answer`, + ); + } + continue; + } + + if (answer.experimental_verbatimText !== undefined) { + throw new ApiError( + 400, + "invalid_request", + `Question '${question.id}' does not accept verbatim text answers`, + ); + } + if (!question.allowFreeText && answer.freeText !== undefined) { throw new ApiError( 400, diff --git a/apps/server/src/services/lib/async-ttl-memo.ts b/apps/server/src/services/lib/async-ttl-memo.ts index 9477f74678..d664b77a44 100644 --- a/apps/server/src/services/lib/async-ttl-memo.ts +++ b/apps/server/src/services/lib/async-ttl-memo.ts @@ -5,6 +5,8 @@ */ export interface AsyncTtlMemo { clear(): void; + /** Forget the settled and in-flight entries whose key the predicate selects. */ + deleteWhere(predicate: (key: TKey) => boolean): void; run(key: TKey, task: () => Promise): Promise; } @@ -38,6 +40,14 @@ export function createAsyncTtlMemo({ settledByKey.clear(); pendingByKey.clear(); }, + deleteWhere(predicate) { + for (const key of [...settledByKey.keys()]) { + if (predicate(key)) settledByKey.delete(key); + } + for (const key of [...pendingByKey.keys()]) { + if (predicate(key)) pendingByKey.delete(key); + } + }, run(key, task) { const currentTime = now(); const settled = settledByKey.get(key); diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index 55eac1ab6d..ed9a3a5899 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -486,6 +486,15 @@ export function createPluginApi(options: { hostId: string; signal?: AbortSignal; }) => Promise; + callProviderBridge: (args: { + providerId: string; + contract: PluginRpcContract; + method: string; + input: unknown; + hostId: string; + signal?: AbortSignal; + }) => Promise; + providerModelsChanged: (args: { providerId: string; hostId: string }) => void; /** Registers one validated provider declaration with the server's provider * registry, bound to this plugin's id. Throws on a live id collision. */ registerProvider: (declaration: NormalizedPluginProviderDeclaration) => { @@ -543,6 +552,8 @@ export function createPluginApi(options: { declareSharedPorts, replaceDeclaredSharedPorts, callPluginHost, + callProviderBridge, + providerModelsChanged, registerProvider, registerAiService, isProviderIdTaken, @@ -1408,6 +1419,53 @@ export function createPluginApi(options: { const providers: PluginProviders = { register: providerRegistrations.register, + experimental_client({ providerId, contract }) { + assertLive(); + return { + async call(method, input, callOptions) { + assertLive(); + if (!activated) { + throw new Error( + "provider bridge calls are unavailable during factory registration; call from a handler, service, or timer", + ); + } + if (typeof method !== "string" || contract[method] === undefined) { + throw new Error( + `unknown provider bridge rpc method "${String(method)}"`, + ); + } + if ( + typeof callOptions !== "object" || + callOptions === null || + typeof callOptions.hostId !== "string" || + callOptions.hostId.length === 0 + ) { + throw new Error( + `provider bridge rpc method "${method}" requires a host id`, + ); + } + return callProviderBridge({ + providerId, + contract, + method, + input, + hostId: callOptions.hostId, + ...(callOptions.signal === undefined + ? {} + : { signal: callOptions.signal }), + }); + }, + }; + }, + experimental_modelsChanged(args) { + assertLive(); + if (!activated) { + throw new Error( + "provider model invalidation is unavailable during factory registration", + ); + } + providerModelsChanged(args); + }, }; /** AI-service registrations, staged like providers; each one binds to the diff --git a/apps/server/src/services/plugins/plugin-host-rpc.ts b/apps/server/src/services/plugins/plugin-host-rpc.ts index 5277bc55b8..8388c941db 100644 --- a/apps/server/src/services/plugins/plugin-host-rpc.ts +++ b/apps/server/src/services/plugins/plugin-host-rpc.ts @@ -9,6 +9,7 @@ import type { JsonValue } from "@bb/domain"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import type { WorkSessionDeps } from "../../types.js"; import { callHostOnlineRpc } from "../hosts/online-rpc.js"; +import type { HostDaemonBridgeLaunch } from "@bb/host-daemon-contract"; import type { PluginHostArtifactSnapshot } from "./plugin-service-internal.js"; const HOST_RPC_TRANSPORT_GRACE_MS = 6_000; @@ -138,6 +139,61 @@ export async function callPluginHostRpc( return output; } +export async function callPluginProviderBridgeRpc( + deps: WorkSessionDeps, + args: { + contract: PluginRpcContract; + method: string; + input: unknown; + hostId: string; + providerId: string; + bridgeLaunch: HostDaemonBridgeLaunch; + signal?: AbortSignal; + }, +): Promise { + const method = args.contract[args.method]; + if (method === undefined) { + throw new Error(`unknown provider bridge rpc method "${args.method}"`); + } + if (args.signal?.aborted) throw abortError(); + const input = normalizeJson( + await validateValue(method.input, args.input, "input"), + `provider bridge rpc input for ${args.method}`, + ); + const rpc = callHostOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS + HOST_RPC_TRANSPORT_GRACE_MS, + command: { + type: "provider.custom_call", + providerId: args.providerId, + bridgeLaunch: args.bridgeLaunch, + method: args.method, + input, + }, + }); + const signal = args.signal; + const result = + signal === undefined + ? await rpc + : await new Promise>((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + callback(); + }; + const onAbort = (): void => finish(() => reject(abortError())); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + rpc.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)), + ); + }); + return await validateValue(method.output, result.result, "output"); +} + export async function disposePluginHostWorkers( deps: WorkSessionDeps, args: { pluginId: string; generation: string }, diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts index 781a06d50c..f8b3204bbd 100644 --- a/apps/server/src/services/plugins/plugin-runtime.ts +++ b/apps/server/src/services/plugins/plugin-runtime.ts @@ -1550,6 +1550,18 @@ export function createPluginRuntime(context: PluginRuntimeContext) { } deps.sharedPorts?.replaceDeclarationsForOwner(row.id, declarations); }, + callProviderBridge: (args) => { + if (!deps.callProviderBridge) { + throw new Error("provider bridge rpc transport is unavailable"); + } + return deps.callProviderBridge({ pluginId: row.id, ...args }); + }, + providerModelsChanged: (args) => { + if (!deps.providerModelsChanged) { + throw new Error("provider model invalidation is unavailable"); + } + deps.providerModelsChanged({ pluginId: row.id, ...args }); + }, callPluginHost: (args) => { if (hostArtifactCandidate === null) { throw new Error( diff --git a/apps/server/src/services/plugins/plugin-service-internal.ts b/apps/server/src/services/plugins/plugin-service-internal.ts index deda418269..95d74282a2 100644 --- a/apps/server/src/services/plugins/plugin-service-internal.ts +++ b/apps/server/src/services/plugins/plugin-service-internal.ts @@ -167,6 +167,22 @@ export interface PluginServiceDeps { timeoutMs?: number; artifact: PluginHostArtifactSnapshot; }) => Promise; + /** Generic typed provider-bridge RPC transport supplied by the server. */ + callProviderBridge?: (args: { + pluginId: string; + providerId: string; + contract: import("@get-bb/plugin-sdk").PluginRpcContract; + method: string; + input: unknown; + hostId: string; + signal?: AbortSignal; + }) => Promise; + /** Clears server model memos and notifies clients after a native write. */ + providerModelsChanged?: (args: { + pluginId: string; + providerId: string; + hostId: string; + }) => void; /** Stops this plugin's workers on connected hosts during reload/disable. */ disposePluginHost?: (args: { pluginId: string; diff --git a/apps/server/src/services/providers/native-roots.ts b/apps/server/src/services/providers/native-roots.ts index d7e61af9e2..deda2d9bf8 100644 --- a/apps/server/src/services/providers/native-roots.ts +++ b/apps/server/src/services/providers/native-roots.ts @@ -25,6 +25,7 @@ import { import type { HostDaemonOnlineRpcResultForCommand, HostDaemonRetryableOnlineRpcCommand, + HostDaemonBridgeLaunch, } from "@bb/host-daemon-contract"; import { experimental_nativeRootsHostContract } from "@get-bb/plugin-sdk/host"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; @@ -311,7 +312,15 @@ interface ScanProviderNativeRootsArgs { */ export function scanProviderNativeRoots( deps: ProviderNativeRootsDeps, - args: ScanProviderNativeRootsArgs & { type: "host.list_commands" }, + args: ScanProviderNativeRootsArgs & { + type: "host.list_commands"; + /** + * The provider's bridge launch, so the daemon also asks the bridge for + * the commands its own resources register (`command/list`) and merges + * them with the static scan; null for a provider without a bridge. + */ + bridgeLaunch: HostDaemonBridgeLaunch | null; + }, ): Promise>; export function scanProviderNativeRoots( deps: ProviderNativeRootsDeps, @@ -319,7 +328,11 @@ export function scanProviderNativeRoots( ): Promise>; export async function scanProviderNativeRoots( deps: ProviderNativeRootsDeps, - args: ScanProviderNativeRootsArgs & { type: ProviderNativeRootScanType }, + args: ScanProviderNativeRootsArgs & + ( + | { type: "host.list_commands"; bridgeLaunch: HostDaemonBridgeLaunch | null } + | { type: "host.list_skills" } + ), ): Promise> { const budget = createProviderListingBudget(); const nativeRoots = await resolveProviderNativeRootSet(deps, { @@ -338,7 +351,11 @@ export async function scanProviderNativeRoots( timeoutMs: budget.remainingMs(), command: args.type === "host.list_commands" - ? { type: "host.list_commands", ...scan } + ? { + type: "host.list_commands", + ...scan, + ...(args.bridgeLaunch === null ? {} : { bridgeLaunch: args.bridgeLaunch }), + } : { type: "host.list_skills", ...scan }, }); } 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 8ef528ef7e..5e30ba438a 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 @@ -1308,8 +1308,9 @@ export default definePluginApp((app) => { }); ``` -(The four first-party provider plugins ship no `app.tsx`: bb vendors their -marks itself, so an icon-only bundle would only add fetches at boot.) +Most first-party provider plugins ship no `app.tsx`: bb vendors their marks +itself, so an icon-only bundle would only add fetches at boot. Pi is the +exception because its app bundle renders Pi extension state. A provider plugin's `app.tsx` loads in the same deferred boot pass as every other plugin's, whether or not one of its providers is selected: everything @@ -1328,6 +1329,28 @@ user reorders them and picks a default in Settings → Providers (`bb settings general providerOrder '["my-agent","codex"]'` and `bb settings general defaultProviderId my-agent`). +A provider plugin with native host-local settings can define a Standard Schema +contract and call its own bridge without putting provider fields in shared model +contracts: + +```ts +const bridge = bb.providers.experimental_client({ + providerId: "echo-agent", + contract: nativeSettingsContract, +}); +const saved = await bridge.call("settings/write", input, { hostId }); +bb.providers.experimental_modelsChanged({ + providerId: "echo-agent", + hostId, +}); +``` + +The host id is explicit. Core treats method names and payloads as opaque JSON; +input and output are validated against `contract`. Call only from handlers, +services, or timers. After a preference that changes `model/list`, call +`experimental_modelsChanged` to clear server model memos and refetch client +pickers. Both APIs are experimental; see `docs/api_to_audit.md`. + `experimental_bridgeOptions` must be a plain JSON object no larger than 64 KiB. It is validated and frozen at registration, then carried on every bridge request as provider-scoped static options. Use it for immutable launch facts @@ -1883,9 +1906,12 @@ Slot props contracts (versioned, additive-only): - `homepageSection` → `{ projectId: string | null }` (project in view on the compose surface). Registration: `{ id, title, component }`. -- `settingsSection` → `{}` (deliberately no props in V1). Rendered on the - plugin detail page below the host-rendered declarative settings - form for running, needs-configuration, and degraded plugins. Registration: +- `settingsSection` → `{ experimental_hostId?: string | null }`. The + experimental host id is BB's selected primary Settings host, or null while + none is available; use it for host-local configuration instead of adding a + machine picker inside the editor. Rendered on the plugin detail page below + the host-rendered declarative settings form for running, + needs-configuration, and degraded plugins. Registration: `{ id, title?, description?, component }`; `title` is an optional host-rendered section heading and `description` is optional supporting copy rendered with that heading. Use the existing hooks (`useRpc`, `useRealtime`, @@ -2221,6 +2247,20 @@ openWorkspaceFile }` — register a leaf One registration per provider id per plugin; if two plugins claim one provider id the host keeps the first by plugin id and warns. See the `app.tsx` example under "The icon" above. +- `experimental_providerExtensionState` → render one local provider + extension-state kind above and below the active thread composer. + Registration: `{ name, component }`; `name` is the local `[a-z0-9-]+` name + from this plugin's provider declaration, not the namespaced wire kind. The + component receives `{ threadId, providerId, kind, payload, sourceSeq, +placement, experimental_dispatchAction }`. `placement` is `"aboveEditor" | +"belowEditor"`; return null on the side where the state has nothing to show. + The host resolves `/` and never passes another plugin's + payload. Validate or narrow `payload` immediately, keep provider-specific + rendering here, and use `sourceSeq` only when an effect must distinguish + updates with equal payloads. A kind that declares an + `experimental_action` Standard Schema can call + `experimental_dispatchAction(action)`; actions target only the current + session and are never retried automatically. Host components: diff --git a/apps/server/src/services/system/event-pruning.ts b/apps/server/src/services/system/event-pruning.ts index 2f584750e5..e455d45dd7 100644 --- a/apps/server/src/services/system/event-pruning.ts +++ b/apps/server/src/services/system/event-pruning.ts @@ -5,6 +5,7 @@ import { pruneBackgroundTaskProgressEvents, pruneContextWindowUsageEventsBeforeSequence, pruneResolvedItemDeltas, + pruneSupersededExtensionStateEvents, pruneTokenUsageEventsBeforeSequence, pruneThreadEventsBeforeSequence, } from "@bb/db"; @@ -24,6 +25,7 @@ interface ThreadEventPruningResult { removedAgePrunableEvents: number; removedBackgroundTaskProgressEvents: number; removedResolvedItemDeltas: number; + removedSupersededExtensionStates: number; sequenceCutoff: number; totalRemoved: number; } @@ -44,6 +46,7 @@ type ThreadEventPruningStep = | "prune_context_window_usage" | "prune_generic_age_prunable_events" | "prune_resolved_item_deltas" + | "prune_superseded_extension_states" | "prune_token_usage"; class ThreadEventPruningStepError extends Error { @@ -79,6 +82,7 @@ const AGE_PRUNABLE_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [ const ACTIVE_PRUNE_TRIGGER_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [ ...AGE_PRUNABLE_THREAD_EVENT_TYPES, "item/backgroundTask/progress", + "thread/extensionState/updated", ] as const; const GENERIC_AGE_PRUNABLE_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [ @@ -158,6 +162,13 @@ export function pruneThreadEventHistory( types: GENERIC_AGE_PRUNABLE_THREAD_EVENT_TYPES, }), ); + const removedSupersededExtensionStates = runThreadEventPruningStep( + "prune_superseded_extension_states", + () => + pruneSupersededExtensionStateEvents(deps.db, { + threadId: args.threadId, + }), + ); const removedResolvedItemDeltas = runThreadEventPruningStep( "prune_resolved_item_deltas", () => @@ -178,11 +189,13 @@ export function pruneThreadEventHistory( removedAgePrunableEvents, removedBackgroundTaskProgressEvents, removedResolvedItemDeltas, + removedSupersededExtensionStates, sequenceCutoff, totalRemoved: removedAgePrunableEvents + removedBackgroundTaskProgressEvents + - removedResolvedItemDeltas, + removedResolvedItemDeltas + + removedSupersededExtensionStates, }; } diff --git a/apps/server/src/services/system/provider-model-cache.ts b/apps/server/src/services/system/provider-model-cache.ts new file mode 100644 index 0000000000..31ba44bbec --- /dev/null +++ b/apps/server/src/services/system/provider-model-cache.ts @@ -0,0 +1,24 @@ +import type { SystemChangeKind } from "@bb/domain"; +import type { ProviderModelListMemoValue } from "../../lifecycle-dedupers.js"; +import type { AsyncTtlMemo } from "../lib/async-ttl-memo.js"; + +/** + * A plugin changed a provider's native model preferences on one host: the + * catalogs memoized for that host are stale, the other hosts' are not. The + * memo is keyed by the JSON array `[hostId, ...]` (execution-options.ts). + */ +export function publishProviderModelsChanged(args: { + providerModelList: AsyncTtlMemo; + notifySystem(changes: SystemChangeKind[]): void; + hostId: string; +}): void { + args.providerModelList.deleteWhere((key) => { + try { + const parsed: unknown = JSON.parse(key); + return Array.isArray(parsed) && parsed[0] === args.hostId; + } catch { + return true; + } + }); + args.notifySystem(["provider-models-changed"]); +} diff --git a/apps/server/src/services/threads/provider-command-typeahead.ts b/apps/server/src/services/threads/provider-command-typeahead.ts index ddacb92a18..0cd5b8562b 100644 --- a/apps/server/src/services/threads/provider-command-typeahead.ts +++ b/apps/server/src/services/threads/provider-command-typeahead.ts @@ -107,6 +107,7 @@ function compareCommands(a: ProviderCommand, b: ProviderCommand): number { interface BuildCommandListResponseArgs { commands: HostProviderCommand[]; + diagnostics: string[]; includeBuiltinCompact: boolean; skillCatalog: readonly ResolvedSkillCatalogEntry[]; } @@ -127,5 +128,6 @@ export function buildCommandListResponse( ...args.skillCatalog.map(toSkillCommand), ...args.commands.map(toProviderCommand), ]).sort(compareCommands), + diagnostics: args.diagnostics, }; } diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index af4ed9330a..79e2c76536 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -41,6 +41,7 @@ import { listStoredBufferedTextDeltaRowsByItems, listStoredItemLifecycleRowsByItems, listLatestBackgroundTaskStateRowsByItemIds, + listLatestExtensionStateEventRowsForThread, listLatestThreadStateEventRowsByThreadIds, listLatestOpenBackgroundTaskStateRowsForThread, listStoredTimelineWindowEventRows, @@ -1003,9 +1004,10 @@ function ensureLatestTimelineOpenBackgroundTaskStateRows( /** * Merges the rows that establish head-state banners into the latest window. * - * The timeline response carries tail state (`pendingTodos`, `goal`) that - * describes the head of the thread but is extracted by scanning whatever events - * the window happens to contain. That is fine when the window reaches the start + * The timeline response carries tail state (`pendingTodos`, `goal`, provider + * extension state) that describes the head of the thread but is extracted by + * scanning whatever events the window happens to contain. That is fine when the + * window reaches the start * of the thread, which is what an unbudgeted window does on the threads where * this matters — but an event-budgeted window can begin *after* the turn that * set the goal or wrote the todos, silently dropping the banner mid-session. @@ -1026,6 +1028,9 @@ function ensureLatestTimelineHeadStateRows( threadIds: [args.threadId], kind: LEGACY_CODEX_GOAL_EXTENSION_KIND, }), + ...listLatestExtensionStateEventRowsForThread(db, { + threadId: args.threadId, + }), ...listTodoSnapshotEventRowsForThread(db, { threadId: args.threadId }), ]; if (headStateRows.length === 0) { @@ -1785,6 +1790,8 @@ function buildThreadTimelineInternal( goal: timeline.goal, modelFallback: options.page.kind === "latest" ? timeline.modelFallback : null, + extensionStates: + options.page.kind === "latest" ? timeline.extensionStates : [], contextWindowUsage: options.page.kind === "latest" ? (timeline.contextWindowUsage ?? undefined) @@ -2084,8 +2091,7 @@ export function buildTimelineTurnSummaryDetails( // route actually holds, so the parent expansion spends what is left rather // than a pre-closure estimate of it. The subtraction may go negative, which // is the safe direction: the parent fetch then stays inside its bounds. - const detailsEventDataBytes = - byteLengthOfStoredEventRows(wholeItemEventRows); + const detailsEventDataBytes = byteLengthOfStoredEventRows(wholeItemEventRows); const eventRowsWithParentedChildren = ensureTimelineWindowParentedRows(db, { maxInlineOutputChars: detailsInlineOutputLimit, outOfBoundsChildDataByteLimit: diff --git a/apps/server/test/internal/internal-extension-payloads.test.ts b/apps/server/test/internal/internal-extension-payloads.test.ts index dd0150c15d..6acfcbfa31 100644 --- a/apps/server/test/internal/internal-extension-payloads.test.ts +++ b/apps/server/test/internal/internal-extension-payloads.test.ts @@ -14,7 +14,10 @@ import { } from "@bb/host-daemon-contract"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { EXTENSION_PAYLOAD_MAX_BYTES } from "../../src/internal/extension-payloads.js"; +import { + EXTENSION_PAYLOAD_MAX_BYTES, + validateExtensionAction, +} from "../../src/internal/extension-payloads.js"; import { buildPluginProviderRegistration } from "../../src/services/providers/plugin-provider-registration.js"; import { validatePluginProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; import { internalAuthHeaders } from "../helpers/commands.js"; @@ -89,6 +92,7 @@ async function setup() { goal: { item: z.object({ objective: z.string().min(1) }), state: z.object({ status: z.enum(["active", "done"]) }), + experimental_action: z.object({ type: z.literal("advance") }), }, }, }); @@ -361,6 +365,38 @@ describe("extension payload ingest validation", () => { } }); + it("accepts only bounded actions declared by the thread provider", async () => { + const { harness } = await setup(); + try { + await expect( + validateExtensionAction(harness.deps, { + providerId: PROVIDER_ID, + kind: GOAL_KIND, + action: { type: "advance" }, + }), + ).resolves.toEqual({ ok: true }); + await expect( + validateExtensionAction(harness.deps, { + providerId: PROVIDER_ID, + kind: GOAL_KIND, + action: { type: "other" }, + }), + ).resolves.toMatchObject({ ok: false }); + await expect( + validateExtensionAction(harness.deps, { + providerId: PROVIDER_ID, + kind: GOAL_KIND, + action: { + type: "advance", + data: "x".repeat(EXTENSION_PAYLOAD_MAX_BYTES), + }, + }), + ).resolves.toMatchObject({ ok: false }); + } finally { + await harness.cleanup(); + } + }); + it("stops accepting a kind once its plugin's registration is disposed", async () => { const { harness, session, thread } = await setup(); try { diff --git a/apps/server/test/internal/internal-interactive-requests.test.ts b/apps/server/test/internal/internal-interactive-requests.test.ts index 061c5f1357..108dc576c8 100644 --- a/apps/server/test/internal/internal-interactive-requests.test.ts +++ b/apps/server/test/internal/internal-interactive-requests.test.ts @@ -105,6 +105,9 @@ async function postInteractiveRequest( function registerInteractiveRequest( args: RegisterInteractiveRequestArgs, ): Promise { + if (args.body.interaction.turnId === null) { + throw new Error("Test helper expected a turn-scoped interaction"); + } seedTurnStarted(args.harness.deps, { threadId: args.body.interaction.threadId, turnId: args.body.interaction.turnId, @@ -267,6 +270,55 @@ describe("internal interactive request lifecycle", () => { }); }); + it("persists an explicitly thread-scoped verbatim question without turn/started", async () => { + await withTestHarness(async (harness) => { + const { session, thread } = seedThreadFixture(harness, { + session: { id: "host-thread-scoped-question" }, + thread: { providerId: "pi" }, + }); + + const response = await postInteractiveRequest({ + harness, + body: { + sessionId: session.id, + interaction: { + threadId: thread.id, + turnId: null, + providerId: "pi", + providerThreadId: "provider-thread-question", + providerRequestId: "request-thread-question", + payload: { + kind: "user_question", + questions: [ + { + id: "value", + prompt: "Value", + multiSelect: false, + allowFreeText: true, + experimental_responseMode: "verbatim", + }, + ], + }, + }, + }, + }); + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toMatchObject({ + outcome: "created", + status: "pending", + }); + expect( + harness.deps.pendingInteractions.listThreadInteractions(thread.id), + ).toContainEqual( + expect.objectContaining({ + turnId: null, + payload: expect.objectContaining({ kind: "user_question" }), + }), + ); + }); + }); + it("persists a session-scoped command approval resolution", async () => { await withTestHarness(async (harness) => { const { session, environment, thread } = seedThreadFixture(harness, { diff --git a/apps/server/test/public/public-project-commands.test.ts b/apps/server/test/public/public-project-commands.test.ts index b14af4158f..b7f52346aa 100644 --- a/apps/server/test/public/public-project-commands.test.ts +++ b/apps/server/test/public/public-project-commands.test.ts @@ -9,6 +9,8 @@ import type { import { commandListResponseSchema } from "@bb/server-contract"; import type { ExperimentalNativeRootsResolveAnswer } from "@get-bb/plugin-sdk/host"; import { describe, expect, it, vi } from "vitest"; +import type { HostDaemonBridgeLaunch } from "@bb/host-daemon-contract"; +import { resolveBridgeLaunchForProviderId } from "../../src/services/system/provider-bridge-launch.js"; import { COMMAND_TIMEOUT_MS } from "../../src/constants.js"; import { registerHostRpcResponder } from "../helpers/host-rpc.js"; import { @@ -75,6 +77,7 @@ interface RegisterCommandRpcArgs { hostId: string; sessionId: string; commands: HostProviderCommand[]; + diagnostics?: string[]; skills?: DiscoveredSkill[]; /** What the plugin's `resolveNativeRoots` answers. */ resolved?: ExperimentalNativeRootsResolveAnswer; @@ -121,7 +124,13 @@ function registerCommandRpc( } if (request.command.type === "host.list_commands") { stub.requests.push(request); - return { ok: true, result: { commands: stub.commands } }; + return { + ok: true, + result: { + commands: stub.commands, + diagnostics: args.diagnostics ?? [], + }, + }; } if (request.command.type === "host.list_skills") { stub.skillRequests.push(request); @@ -163,6 +172,19 @@ function legacyCommand( }; } + +/** + * The launch the route attaches so the daemon can ask the provider's bridge + * for its own commands (`command/list`); absent for a provider without one. + */ +function expectedBridgeLaunch( + deps: Parameters[0], + providerId: string, +): { bridgeLaunch?: HostDaemonBridgeLaunch } { + const bridgeLaunch = resolveBridgeLaunchForProviderId(deps, providerId); + return bridgeLaunch === null ? {} : { bridgeLaunch }; +} + describe("public project command typeahead route", () => { it("adds configured shared skills to the provider-neutral catalog", async () => { await withTestHarness( @@ -263,6 +285,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "acp-amp"), type: "host.list_commands", providerId: "acp-amp", cwd: "/tmp/custom-acp-skills", @@ -341,6 +364,7 @@ describe("public project command typeahead route", () => { ]); // Its answer rides the daemon command, defaults filled per side. expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "resolving"), type: "host.list_commands", providerId: "resolving", cwd: "/tmp/resolving-project", @@ -533,6 +557,7 @@ describe("public project command typeahead route", () => { argumentHint: null, }); expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "codex"), type: "host.list_commands", providerId: "codex", cwd: "/tmp/remote-commands-env", @@ -626,6 +651,7 @@ describe("public project command typeahead route", () => { // Exactly one RPC, carrying the requested provider + resolved env cwd. expect(stub.requests.map((request) => request.command)).toEqual([ { + ...expectedBridgeLaunch(harness.deps, "claude-code"), type: "host.list_commands", providerId: "claude-code", cwd: "/tmp/claude-commands-env", @@ -673,6 +699,7 @@ describe("public project command typeahead route", () => { "skill-installer", ]); expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "codex"), type: "host.list_commands", providerId: "codex", cwd: "/tmp/codex-commands-env", @@ -716,6 +743,7 @@ describe("public project command typeahead route", () => { "stories", ]); expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "codex"), type: "host.list_commands", providerId: "codex", cwd: "/tmp/inherited-skills-project", @@ -788,13 +816,13 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); - expect(body).toEqual({ commands: [] }); + expect(body).toEqual({ commands: [], diagnostics: [] }); // No daemon roundtrip for a provider without a command surface. expect(stub.requests).toEqual([]); }); }); - it("lists skills for pi via the shared command surface", async () => { + it("returns Pi extension commands and diagnostics beside existing skills", async () => { await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps, { id: "host-commands-pi", @@ -810,7 +838,19 @@ describe("public project command typeahead route", () => { const stub = registerCommandRpc(harness, { hostId: host.id, sessionId: session.id, - commands: [skill("bb-cli", "user", { description: "Use the bb CLI" })], + commands: [ + skill("bb-cli", "user", { description: "Use the bb CLI" }), + { + name: "project-smoke", + source: "command", + origin: "project", + description: "Project smoke command", + argumentHint: null, + }, + ], + diagnostics: [ + 'Failed to load Pi extension "/tmp/pi-commands-env/.pi/extensions/broken.ts": syntax error', + ], }); const response = await harness.app.request( @@ -822,10 +862,12 @@ describe("public project command typeahead route", () => { expect(body.commands.map((command) => command.name)).toEqual([ "compact", "bb-cli", + "project-smoke", ]); // Pi's roots are the plugin's declaration, forwarded as declared // (the daemon holds no pi skill policy of its own). expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "pi"), type: "host.list_commands", providerId: "pi", cwd: "/tmp/pi-commands-env", @@ -865,6 +907,7 @@ describe("public project command typeahead route", () => { // Falls back to the project source path on the primary host, since the // project has a local-path source even though no environment is given. expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "claude-code"), type: "host.list_commands", providerId: "claude-code", cwd: "/tmp/no-env-project", @@ -913,6 +956,7 @@ describe("public project command typeahead route", () => { ]); // Not the provisioning env path; the project source path on the primary host. expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "claude-code"), type: "host.list_commands", providerId: "claude-code", cwd: "/tmp/provisioning-project", @@ -954,6 +998,7 @@ describe("public project command typeahead route", () => { "user-only", ]); expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "claude-code"), type: "host.list_commands", providerId: "claude-code", cwd: null, @@ -988,6 +1033,7 @@ describe("public project command typeahead route", () => { "home-skill", ]); expect(stub.requests[0]?.command).toEqual({ + ...expectedBridgeLaunch(harness.deps, "codex"), type: "host.list_commands", providerId: "codex", cwd: null, diff --git a/apps/server/test/public/public-project-workspace-routing.test.ts b/apps/server/test/public/public-project-workspace-routing.test.ts index 14d358b869..56faacedc2 100644 --- a/apps/server/test/public/public-project-workspace-routing.test.ts +++ b/apps/server/test/public/public-project-workspace-routing.test.ts @@ -1,6 +1,7 @@ import { createProjectSource } from "@bb/db"; import type { HostProviderCommand } from "@bb/host-daemon-contract"; import { describe, expect, it } from "vitest"; +import { resolveBridgeLaunchForProviderId } from "../../src/services/system/provider-bridge-launch.js"; import { registerHostRpcResponder } from "../helpers/host-rpc.js"; import { declaredNativeRootSet } from "../helpers/provider-registry.js"; import { readJson } from "../helpers/json.js"; @@ -70,7 +71,10 @@ describe("public project workspace routing", () => { }; } if (request.command.type === "host.list_commands") { - return { ok: true, result: { commands: [primaryCommand] } }; + return { + ok: true, + result: { commands: [primaryCommand], diagnostics: [] }, + }; } if (request.command.type === "plugin.host.call") { return { ok: true, result: { output: { skills: [], commands: [] } } }; @@ -116,7 +120,10 @@ describe("public project workspace routing", () => { }; } if (request.command.type === "host.list_commands") { - return { ok: true, result: { commands: [remoteCommand] } }; + return { + ok: true, + result: { commands: [remoteCommand], diagnostics: [] }, + }; } if (request.command.type === "plugin.host.call") { return { ok: true, result: { output: { skills: [], commands: [] } } }; @@ -190,6 +197,9 @@ describe("public project workspace routing", () => { (request) => request.command.type === "host.list_commands", )?.command, ).toEqual({ + ...(resolveBridgeLaunchForProviderId(harness.deps, "codex") === null + ? {} + : { bridgeLaunch: resolveBridgeLaunchForProviderId(harness.deps, "codex") }), type: "host.list_commands", providerId: "codex", cwd: "/remote/project", diff --git a/apps/server/test/public/public-provider-extension-state-action.test.ts b/apps/server/test/public/public-provider-extension-state-action.test.ts new file mode 100644 index 0000000000..aded88619d --- /dev/null +++ b/apps/server/test/public/public-provider-extension-state-action.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { validatePluginProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import { buildPluginProviderRegistration } from "../../src/services/providers/plugin-provider-registration.js"; +import { registerHostRpcResponder } from "../helpers/host-rpc.js"; +import { readJson } from "../helpers/json.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, +} from "../helpers/seed.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +const PLUGIN_ID = "provider-action-test"; +const PROVIDER_ID = "action-test"; +const KIND = `${PLUGIN_ID}/terminal` as const; + +function registerProvider( + harness: Parameters[0], +) { + harness.deps.providerRegistry.register({ + ...buildPluginProviderRegistration({ + available: true, + pluginId: PLUGIN_ID, + declaration: validatePluginProviderDeclaration({ + id: PROVIDER_ID, + displayName: "Action test", + maintenance: { health: false, usage: false, installation: false }, + capabilities: { + supportsServiceTier: false, + supportsNativeUserQuestion: false, + fork: "none", + supportsManualCompaction: false, + supportsThreadArchive: false, + supportsThreadRename: false, + permissionModes: ["full"], + reasoningLevels: ["medium"], + }, + composerActions: [], + extensionKinds: { + terminal: { + state: z.unknown(), + experimental_action: z.object({ type: z.literal("cancel") }), + }, + }, + }), + readSettings: () => ({}), + }), + pluginId: PLUGIN_ID, + iconNames: new Set(), + }); +} + +describe("provider extension-state actions", () => { + it("validates and forwards an action to the current host runtime", async () => { + await withTestHarness(async (harness) => { + registerProvider(harness); + const { host, session } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + providerId: PROVIDER_ID, + status: "active", + }); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: ({ command }) => { + expect(command).toEqual({ + type: "thread.extension-state.action", + environmentId: environment.id, + threadId: thread.id, + extensionKind: KIND, + action: { type: "cancel" }, + }); + return { ok: true, result: { applied: true } }; + }, + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/extension-state/action`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: KIND, action: { type: "cancel" } }), + }, + ); + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toEqual({ applied: true }); + expect(responder.requests).toHaveLength(1); + }); + }); + + it("rejects an undeclared action before host dispatch", async () => { + await withTestHarness(async (harness) => { + registerProvider(harness); + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + providerId: PROVIDER_ID, + status: "active", + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/extension-state/action`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: KIND, action: { type: "input" } }), + }, + ); + + expect(response.status).toBe(400); + }); + }); +}); diff --git a/apps/server/test/public/public-thread-interactions.test.ts b/apps/server/test/public/public-thread-interactions.test.ts index e450411275..7ae4ab721b 100644 --- a/apps/server/test/public/public-thread-interactions.test.ts +++ b/apps/server/test/public/public-thread-interactions.test.ts @@ -48,6 +48,9 @@ function registerPendingInteraction( lifecycle: PendingInteractionLifecycle, interaction: PendingInteractionCreate, ) { + if (interaction.turnId === null) { + throw new Error("Test helper expected a turn-scoped interaction"); + } seedTurnStarted(deps, { threadId: interaction.threadId, turnId: interaction.turnId, diff --git a/apps/server/test/services/pending-interactions.test.ts b/apps/server/test/services/pending-interactions.test.ts index 711241f751..08836365ba 100644 --- a/apps/server/test/services/pending-interactions.test.ts +++ b/apps/server/test/services/pending-interactions.test.ts @@ -35,6 +35,9 @@ function registerPendingInteraction( lifecycle: PendingInteractionLifecycle, interaction: PendingInteractionCreate, ) { + if (interaction.turnId === null) { + throw new Error("Test helper expected a turn-scoped interaction"); + } seedTurnStarted(deps, { threadId: interaction.threadId, turnId: interaction.turnId, diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index c152962028..a06cf61298 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -5,6 +5,7 @@ import * as pluginSdkApp from "@get-bb/plugin-sdk/app"; import { type BbPluginApi, type ExperimentalChangesViewProps, + type ExperimentalProviderExtensionStateProps, type ExperimentalSidebarNavigationProps, type PluginAppBuilder, type PluginAppSlots, @@ -184,6 +185,7 @@ type SlotPropsByName = { // registration type is the documented surface. experimental_providerIcon: PluginProviderIconRegistration; experimental_timelineRenderer: PluginTimelineRendererProps; + experimental_providerExtensionState: ExperimentalProviderExtensionStateProps; }; type MissingSlot = Exclude; @@ -237,7 +239,7 @@ void _assertAllContentScriptRegistrationFieldsListed; const FRONTEND_SLOT_PROP_FIELDS = { homepageSection: ["projectId"], - settingsSection: [], + settingsSection: ["experimental_hostId"], navPanel: ["subPath"], threadPanelAction: ["threadId", "params"], experimental_newThreadPanelAction: ["projectId", "params"], @@ -300,6 +302,15 @@ const FRONTEND_SLOT_PROP_FIELDS = { "thread", "Original", ], + experimental_providerExtensionState: [ + "threadId", + "providerId", + "kind", + "payload", + "sourceSeq", + "placement", + "experimental_dispatchAction", + ], } as const satisfies { [S in keyof SlotPropsByName]: readonly (keyof SlotPropsByName[S])[]; }; diff --git a/apps/server/test/services/system/provider-model-cache.test.ts b/apps/server/test/services/system/provider-model-cache.test.ts new file mode 100644 index 0000000000..5d71c244c0 --- /dev/null +++ b/apps/server/test/services/system/provider-model-cache.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLifecycleDedupers } from "../../../src/lifecycle-dedupers.js"; +import { publishProviderModelsChanged } from "../../../src/services/system/provider-model-cache.js"; + +const empty = { models: [], selectedOnlyModels: [] }; + +describe("provider model cache invalidation", () => { + it("drops the named host's settled catalogs, keeps the others, and notifies picker clients", async () => { + const { providerModelList } = createLifecycleDedupers(); + const hostOneKey = JSON.stringify(["host-1", "session-1", { providerId: "pi" }]); + const hostTwoKey = JSON.stringify(["host-2", "session-2", { providerId: "pi" }]); + const firstProbe = vi.fn(async () => empty); + await providerModelList.run(hostOneKey, firstProbe); + await providerModelList.run(hostOneKey, firstProbe); + await providerModelList.run(hostTwoKey, firstProbe); + expect(firstProbe).toHaveBeenCalledTimes(2); + const notifySystem = vi.fn(); + + publishProviderModelsChanged({ providerModelList, notifySystem, hostId: "host-1" }); + + const secondProbe = vi.fn(async () => empty); + await providerModelList.run(hostOneKey, secondProbe); + await providerModelList.run(hostTwoKey, secondProbe); + // host-1 was refetched; host-2's catalog was still good. + expect(secondProbe).toHaveBeenCalledOnce(); + expect(notifySystem).toHaveBeenCalledWith(["provider-models-changed"]); + }); +}); diff --git a/apps/server/test/services/threads/provider-command-typeahead.test.ts b/apps/server/test/services/threads/provider-command-typeahead.test.ts index 19df870826..e0dd7bbb61 100644 --- a/apps/server/test/services/threads/provider-command-typeahead.test.ts +++ b/apps/server/test/services/threads/provider-command-typeahead.test.ts @@ -27,6 +27,7 @@ describe("buildCommandListResponse", () => { argumentHint: "", }, ], + diagnostics: [], includeBuiltinCompact: true, skillCatalog: [], }); @@ -45,6 +46,7 @@ describe("buildCommandListResponse", () => { it("includes plugin provenance on canonical skill rows", () => { const response = buildCommandListResponse({ commands: [], + diagnostics: [], includeBuiltinCompact: true, skillCatalog: [ { @@ -77,6 +79,7 @@ describe("buildCommandListResponse", () => { skill("bb-cli", { description: "Data-dir override" }), skill("bb-cli", { description: "Built-in default" }), ], + diagnostics: [], includeBuiltinCompact: true, skillCatalog: [], }); @@ -97,6 +100,7 @@ describe("buildCommandListResponse", () => { it("omits the built-in compact row for unsupported providers", () => { const response = buildCommandListResponse({ commands: [], + diagnostics: [], includeBuiltinCompact: false, skillCatalog: [], }); diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts index 141af64fec..663dd81846 100644 --- a/apps/server/test/services/threads/timeline-cache.test.ts +++ b/apps/server/test/services/threads/timeline-cache.test.ts @@ -27,6 +27,7 @@ function makeResponse(rowCount: number): ThreadTimelineResponse { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/apps/server/test/services/threads/timeline-head-state.test.ts b/apps/server/test/services/threads/timeline-head-state.test.ts index f9f4ee4064..63ceba0d8d 100644 --- a/apps/server/test/services/threads/timeline-head-state.test.ts +++ b/apps/server/test/services/threads/timeline-head-state.test.ts @@ -49,7 +49,8 @@ function setup(): { db: DbConnection; thread: Thread } { } /** - * Turn 1 establishes head state (goal, todos, a still-running workflow), then + * Turn 1 establishes head state (goal, provider state, todos, a running + * workflow), then * `turns - 1` further turns bury it far above any budgeted window. */ function seedThreadWithEarlyHeadState( @@ -128,6 +129,28 @@ function seedThreadWithEarlyHeadState( // The plan snapshot is a grammar v3 planSteps item (the bridge folds // TodoWrite / update_plan into it); the head-state backfill finds it by // kind through the plan-steps index, never by a tool name. + events.push({ + threadId: thread.id, + sequence: (sequence += 1), + type: "thread/extensionState/updated", + scope: threadScope(), + providerThreadId, + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ + kind: "provider-test/ui", + payload: { + terminal: { + id: "surface-1", + columns: 80, + rows: 24, + output: "\u001b[32mready\u001b[0m", + acceptsInput: true, + }, + }, + }), + }); events.push({ threadId: thread.id, sequence: (sequence += 1), @@ -209,7 +232,7 @@ const baseOptions = { }; describe("timeline head state under a budgeted window", () => { - it("keeps goal, todos, and a running workflow when the budget excludes the turn that set them", () => { + it("keeps provider state, goal, todos, and running work outside the budget", () => { // Head-state banners describe the head of the thread but are extracted by // scanning the window. A budgeted window starts well after turn 1 here, so // without thread-scoped lookups these silently disappear mid-session. @@ -240,6 +263,21 @@ describe("timeline head state under a budgeted window", () => { expect(unbudgeted.goal).not.toBeNull(); expect(budgeted.goal).toEqual(unbudgeted.goal); + expect(unbudgeted.extensionStates).toContainEqual({ + kind: "provider-test/ui", + payload: { + terminal: { + id: "surface-1", + columns: 80, + rows: 24, + output: "\u001b[32mready\u001b[0m", + acceptsInput: true, + }, + }, + sourceSeq: expect.any(Number), + }); + expect(budgeted.extensionStates).toEqual(unbudgeted.extensionStates); + expect(unbudgeted.activeWorkflows).toHaveLength(1); expect(budgeted.activeWorkflows).toHaveLength(1); }); @@ -275,6 +313,7 @@ describe("timeline head state under a budgeted window", () => { }); expect(budgeted.pendingTodos).toBeNull(); expect(budgeted.goal).toBeNull(); + expect(budgeted.extensionStates).toEqual([]); expect(budgeted.activeWorkflows).toHaveLength(0); }); }); diff --git a/apps/server/test/services/threads/timeline-output-truncation.test.ts b/apps/server/test/services/threads/timeline-output-truncation.test.ts index 018de78ef7..8f64d03ade 100644 --- a/apps/server/test/services/threads/timeline-output-truncation.test.ts +++ b/apps/server/test/services/threads/timeline-output-truncation.test.ts @@ -22,6 +22,7 @@ function response(rows: TimelineRow[]): ThreadTimelineResponse { activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 4553baffe5..3fc1fd2c60 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -537,6 +537,22 @@ stays, decide whether a bare path is the right shape or whether a plugin should get named, read-only accessors for the bb-managed files it may read — a path invites writes into bb's directory, which `bb.storage` exists to prevent. +## Thread-scoped provider interactions (`experimental_interactionRequestParamsSchema` / `ExperimentalInteractionRequestParams.experimental_scope`, `BRIDGE_NOTIFICATION_METHODS.experimentalInteractionCancel`, `experimental_interactionCancelNotificationSchema`, `PendingInteractionUserQuestionQuestion.experimental_responseMode`, `PendingInteractionUserQuestionQuestion.experimental_placeholder`, and `PendingInteractionUserAnswer.experimental_verbatimText`) + +**What it does.** Lets a provider bridge raise a canonical `user_question` +outside an active model turn and preserve a single text answer byte-for-byte. +Pi uses it for extension-command `ctx.ui.select`, `ctx.ui.confirm`, and +`ctx.ui.input` calls: option values remain internal canonical ids while the Pi +bridge maps the selected id back to the exact extension option, and verbatim +text retains whitespace and empty strings. + +**Audit before stabilizing.** Confirm which providers need thread-scoped +questions; confirm per-dialog timeout and signal cancellation should keep using +the same session-owned cancellation notification; verify option and text bounds +against every client; and confirm verbatim input remains a question mode rather than earning +a separate core interaction kind. Recheck that stop, reload, replacement, +failed startup, and daemon/process loss interrupt persistence and reject late +responses before removing the prefixes. ## Bridge record mode (`experimental_recordProviderChildIo` and `experimental_isProviderBridgeRecording`) @@ -577,6 +593,12 @@ whether `retryable` should be per kind (only `sessionArchived` and `rateLimited` read it today) and whether the runtime should bound the `rateLimited` ladder from the hint rather than from a constant. +## Provider bridge command discovery (`BRIDGE_REQUEST_METHODS.experimentalProviderCommandList` and the `experimental_providerCommand*` schemas/types) + +**What it does.** Adds the optional, sessionless `command/list` provider-bridge request. It carries an explicit cwd and the provider's static options so the bridge can load the same trusted, project-bound resources as a real session. A supported result returns command metadata and non-fatal diagnostics together: one broken resource must not hide commands from healthy resources. Core currently uses this for Pi extension commands and prompt templates while the daemon keeps its existing static skill scan; Pi's resources load inside the `pi --mode rpc` catalog child the bridge spawns per cwd, never in the bridge process. The only diagnostics a bridge produces today are its own failures to answer (pi not installed, the catalog child failing to start); pi reports a broken extension on its stderr, which the runtime logs. + +**Audit before stabilizing.** Confirm which providers need executable command discovery rather than static files, whether providers should declare support before core starts a bridge, whether string diagnostics need structured severity and source fields, whether command argument completion metadata belongs on this boundary, and whether repeated sessionless loads need a provider-owned cache or disposal lifecycle. + ## Provider maintenance toolkit (`experimental_resolveExecutablePath`, `experimental_readCliVersion`, `experimental_commandOutput`, `experimental_versionFrom`, `experimental_compareVersions`, `experimental_formatCommand`, `experimental_npmCommand`, `experimental_npmGlobalInstallCommand`, `experimental_npmLatestVersion`, `experimental_probeNpmGlobalPackage`, `experimental_npmGlobalInstallSource`, `experimental_installationVerification`, `experimental_downloadedInstallerCommand`, `experimental_clampPercent`) (`@get-bb/plugin-sdk/provider-bridge`) **What it does.** The host-local probes and install-action plumbing behind a @@ -726,6 +748,42 @@ malformed runtime targets remain inert in both the app and SDK test runtime. 7. Confirm `PluginFileOpenerSource.experimental_hostId` can become a stable required `hostId` field without breaking older opener implementations. +## Provider bridge RPC and model invalidation (`bb.providers.experimental_client` and `bb.providers.experimental_modelsChanged`) + +**What it does.** Lets a provider plugin call a typed, provider-owned method on +its own bridge on an explicit host. Core validates the shared Standard Schema +contract and JSON transport but does not interpret the method or payload. +After a native preference write, the owning plugin can clear model-catalog +memos and broadcast `provider-models-changed` so pickers refetch immediately. +Pi's global model editor is the first consumer. + +**Audit before stabilizing.** + +1. Confirm custom bridge methods should remain one fixed `provider/custom` + envelope rather than joining the shared provider vocabulary. +2. Confirm ownership checks, payload limits, timeouts, cancellation behavior, + and non-retryable writes against another provider plugin. +3. Decide whether model invalidation should target one host/provider cache key + instead of clearing the small process-wide memo. +4. Confirm SDK plugin RPC remains the right end-user typed extension point for + provider-native settings instead of adding provider fields to core SDK model + responses. + +## Settings host context (`PluginSettingsSectionProps.experimental_hostId`) + +**What it does.** Gives a plugin Settings section BB's selected primary host so +host-local editors do not add their own machine picker. It is null while no +host is available and never supplies a cwd or workspace path. + +**Audit before stabilizing.** + +1. Confirm primary-host selection is sufficient or replace it with one shared + Settings-level host picker before stabilizing the prop. +2. Verify host changes remount or refetch every host-local Settings section and + that offline/no-host states remain explicit. +3. Keep target selection in host chrome; do not let each plugin invent machine + selectors inside its editor. + ## Host plugin foundation (`bb.hosts.experimental_client`, `ExperimentalHostClient.experimental_onWorkerExit`, `ExperimentalHostClient.experimental_onSignal`, `ExperimentalHostRpcContext.experimental_retainWorker`, `experimental_defineHostEntry`, and `experimental_createHostEntryHarness`) **Kept experimental (2026-08-22).** signals and watches have no consumer (decide whether to delete them or keep them experimental separately from calls), none of the lifetime/limit numbers has been measured against a plugin other than keep-awake, and the artifact-contract names (`experimental_apiVersion`, `experimental_signals`, the injected context members) are read by the daemon from installed artifacts, so renaming them needs a dual-name window plus a protocol bump. @@ -1021,6 +1079,26 @@ bridge as provider-scoped static options. Core does not interpret its keys. installed-only provider, and that targeted requests may continue resolving a registered provider even while discovery says it is absent. +## Provider extension-state rendering and actions (`app.slots.experimental_providerExtensionState`, `ExperimentalProviderExtensionStateRegistration`, `ExperimentalProviderExtensionStateProps.experimental_dispatchAction`, `PluginProviderExtensionKindDeclaration.experimental_action`, `ThreadsArea.experimental_applyExtensionStateAction`) + +**What it does.** A provider plugin registers a renderer for one local state +kind. The host resolves the persisted namespaced kind through the owning plugin +id and mounts that renderer above and below the active thread composer with the +latest validated payload, source sequence, thread, provider, and placement. +The renderer's `experimental_dispatchAction` callback sends a non-retried JSON +action only to that same namespaced kind in the thread's current provider +session; the backend declaration's `experimental_action` Standard Schema +validates it before dispatch. The host never interprets provider payloads or +actions; `provider-pi` owns their translation and rendering. + +**Audit before stabilizing.** Confirm that the two composer placements cover +provider state that is not transcript content, that mounting one component at +both placements is preferable to separate renderer members, and that title and +editor side effects belong in the renderer rather than a narrower host action +surface. Audit whether `{ applied: boolean }` is enough action feedback and +whether every action should remain non-retryable. Confirm mobile's declarative +fallback before dropping the prefix. + ## Persistent plugin responsive drawer (`experimental_ResponsiveDrawer`, `ExperimentalResponsiveDrawerProps`) **What it does.** Gives a plugin the host's persistent, non-modal bottom drawer. @@ -1033,6 +1111,20 @@ The backdrop and focus stack contain interaction without applying `inert` or plugins need animation-end or realization callbacks, and whether arbitrary content-height control should remain a class-name escape hatch. +## Verbatim interaction editor prefill (`PendingInteractionUserQuestionQuestion.experimental_prefill`) + +**What it does.** Seeds the existing verbatim user-question textarea with up to +4,096 characters. It is accepted only with `experimental_responseMode: +"verbatim"`; the submitted value continues through +`experimental_verbatimText` unchanged, including empty strings, leading spaces, +and newlines. Pi maps `ctx.ui.editor(title, prefill)` onto this existing +interaction path. + +**Audit before stabilizing.** Confirm 4,096 characters is enough for extension +editors, whether prefill and response limits should be UTF-8 bytes instead of +JavaScript characters, and whether editor requests eventually deserve a core +interaction kind distinct from a verbatim question. + ## `@get-bb/plugin-sdk/provider-bridge` (the provider-bridge authoring surface) **Kept experimental (2026-08-22).** `experimental_defineProviderBridge` / `experimental_apiVersion` are an artifact↔daemon contract (the bootstrap refuses anything but version 1 by name), and the deprecation window between independently-updating artifacts and daemons (item 4) is undecided. diff --git a/docs/provider-bridge-protocol.md b/docs/provider-bridge-protocol.md index 62b46d46a1..ba1476d8f0 100644 --- a/docs/provider-bridge-protocol.md +++ b/docs/provider-bridge-protocol.md @@ -64,6 +64,14 @@ Hygiene rules (each traces to incident #853): - Anything written to stdout that is not protocol traffic is ignored by the reader; bridges must guard stdout against stray writes. +A provider plugin may define typed native settings operations behind the +additive `provider/custom` request. Its payload is `{method, input}` and its +result is `{result}`; both values are JSON. The owning plugin shares a Standard +Schema contract between its server entry and bridge, while core treats the +method and payload as opaque. Calls are host-explicit and ownership-checked. +This is for provider-native behavior such as Pi's global `enabledModels`, not +for adding provider fields to shared model or execution contracts. + ## Versioning and capabilities `initialize` exchanges `{protocolVersion, capabilities}` in both directions. @@ -96,6 +104,19 @@ fact may only _narrow_ what the provider's declaration advertises (a declared fork affordance can turn out unavailable for this agent), never widen it. +The optional sessionless `command/list` method loads cwd-bound provider +resources and returns command metadata plus non-fatal diagnostics. Its explicit +cwd prevents project resources from leaking across workspaces. Core currently +uses it for Pi extension commands and prompt templates: the Pi bridge asks the +`pi --mode rpc` catalog child it keeps per cwd (`get_commands`), so pi loads +the resources under its own project-trust policy; the daemon retains its +static skill scan and never imports Pi extensions itself. The diagnostics a +bridge returns are its own failures to answer (pi not installed, the catalog +child failing to start); a broken extension is pi's stderr, which the runtime +logs. Bridges that do not implement the method answer `METHOD_NOT_FOUND`; the +runtime treats that as unsupported, and the daemon keeps its static scan when +the bridge errors, with the error as a diagnostic. + The sessionless `provider/health`, `provider/usage`, `provider/installation/status`, and `provider/installation/run` methods are different: their support is declared by each provider through @@ -352,6 +373,26 @@ undeclared kind, or a schema miss is persisted as a `provider/unhandled` in the same batch slot, never dropped and never stored unvalidated. +Pi projects its serializable extension UI through one +`provider-pi/extension-ui` state kind, built from the `extension_ui_request` +lines pi's RPC mode emits for `ctx.ui.setStatus`, `setWidget`, `notify`, +`setTitle`, and `setEditorText`. Each snapshot is bounded to 16 statuses, 16 +widgets (32 lines each and 12 KiB combined text), eight notifications, and +bounded title/editor text; generic ingest adds the 64 KiB payload cap. The +server replays at most one latest row per kind (32 kinds maximum), and +maintenance prunes superseded snapshots. Pi emits `null` when a live session +stops or is replaced, so app reload restores only the current session's state. +Pi's RPC mode has no `ctx.ui.custom()` (component factories are ignored) and +no working-message control, so neither appears in the snapshot. + +The optional `extension/action` request is the reverse path for interactive +state. Core carries only `{ threadId, providerThreadId, extensionKind, action }`; +the provider declaration validates `action` (its `experimental_action` schema) +before the server sends it, and the bridge translates it. Requests are never +retried because input may not be idempotent. A kind that declares no action +schema accepts no actions: the server rejects them before any bridge is asked. +No first-party provider declares one today. + ## Identifiers Three identifier families, three owners: @@ -369,6 +410,27 @@ provider-native ids as vouched join keys on deltas; the assembler translates in both directions, so a bridge does zero id translation — including for a provider that mints its own turn ids (codex). +## Interaction requests + +A bridge asks for user input through the generic `interaction/request` JSON-RPC +request. Its payload and response are the canonical pending-interaction shapes; +provider-native dialog protocols do not cross this boundary. By default, +`turnId: null` means the runtime must resolve the active turn. The experimental +`experimental_scope: "thread"` form instead means that no turn exists and is +valid only for a `user_question` payload. Pi uses that form when an extension +command calls `ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`, or +`ctx.ui.editor` before Pi accepts the command as model-turn input. Editor +requests use the existing verbatim user question with +`experimental_prefill`; the response remains `experimental_verbatimText`, so +leading spaces, empty lines, and trailing whitespace are not normalized. + +Thread-scoped questions are still owned by the live provider session. Stop, +reload, replacement, failed startup, and process shutdown cancel its outstanding +requests through the generic `interaction/cancel` notification. The runtime +aborts the matching host registration, and the host interrupts the persisted +interaction. Request ids are never reused by Pi, so a late response cannot +resolve a successor session's question. + ## Turn lifecycle State machine per thread, owned by the runtime's assembler, fed by the @@ -550,7 +612,7 @@ on the same two provider lanes, each message wrapped as `{ "bbChannel": }`, so a replay can route it back onto the fds. Layout: `//.ndjson`, with `_process` for lines that -belong to no thread (`initialize`, `model/list`, provider health, and the +belong to no thread (`initialize`, `model/list`, `command/list`, provider health, and the children those spawn). The four directions are `runtime→bridge`, `bridge→runtime`, `provider→bridge`, and `bridge→provider`. One entry per line: `{ "ts", "run", "seq", "dir", "line" }`. `seq` is one counter across diff --git a/docs/provider-plugin-api.md b/docs/provider-plugin-api.md index e7cc1b43a7..595cddeb58 100644 --- a/docs/provider-plugin-api.md +++ b/docs/provider-plugin-api.md @@ -73,7 +73,7 @@ bb.providers.register({ ], serviceTiers: undefined, // optional; open list, model/list is precise composerActions: ["plan"], // "plan" | "goal" - extensionKinds: {}, // "": { item?: Schema, state?: Schema } + extensionKinds: {}, // "": { item?: Schema, state?: Schema, experimental_action?: Schema } models: { fallback: [], scope: "host" }, // cold-cache placeholder; scope is // "host" | "workspace" (default): how far one // model/list answer travels diff --git a/packages/agent-runtime/src/bridge-protocol-adapter.test.ts b/packages/agent-runtime/src/bridge-protocol-adapter.test.ts index f0bc3ab224..6bb4fda015 100644 --- a/packages/agent-runtime/src/bridge-protocol-adapter.test.ts +++ b/packages/agent-runtime/src/bridge-protocol-adapter.test.ts @@ -150,6 +150,28 @@ describe("handshake gating", () => { ).toMatchObject({ kind: "noop" }); }); + it("forwards opaque provider extension actions without interpreting them", () => { + const adapter = makeAdapter(); + expect( + adapter.buildCommandPlan({ + type: "extension/action", + threadId: "thr_1", + providerThreadId: "p_1", + extensionKind: "provider-pi/extension-ui", + action: { type: "cancel", surfaceId: "surface-1" }, + }), + ).toEqual({ + kind: "request", + method: "extension/action", + params: { + threadId: "thr_1", + providerThreadId: "p_1", + extensionKind: "provider-pi/extension-ui", + action: { type: "cancel", surfaceId: "surface-1" }, + }, + }); + }); + it("moves approval policy ownership per the handshake", () => { const adapter = makeAdapter(); expect(adapter.approvalEnforcedBy).toBe("runtime"); @@ -157,6 +179,17 @@ describe("handshake gating", () => { expect(adapter.approvalEnforcedBy).toBe("provider"); }); + it("routes cwd-bound command discovery with provider context", () => { + const adapter = makeAdapter(); + expect( + adapter.buildCommandPlan({ type: "command/list", cwd: "/workspace" }), + ).toEqual({ + kind: "request", + method: "command/list", + params: { providerId: "fake-bridge", cwd: "/workspace" }, + }); + }); + it("routes declared sessionless maintenance methods with provider context", () => { const adapter = makeAdapter(); expect( @@ -511,6 +544,28 @@ describe("inbound request decoding", () => { ).toBeNull(); }); + it("decodes interaction cancellation notifications", () => { + const adapter = makeAdapter(); + + expect( + adapter.decodeInteractiveCancellation({ + jsonrpc: "2.0", + method: "interaction/cancel", + params: { + requestId: "pi-interaction-1", + providerThreadId: "p_1", + threadId: "t_1", + reason: "Pi extensions reloaded", + }, + }), + ).toEqual({ + requestId: "pi-interaction-1", + providerThreadId: "p_1", + threadId: "t_1", + reason: "Pi extensions reloaded", + }); + }); + it("decodes canonical interaction requests with the domain payload", () => { const adapter = makeAdapter(); const decoded = adapter.decodeInteractiveRequest?.({ diff --git a/packages/agent-runtime/src/bridge-protocol-adapter.ts b/packages/agent-runtime/src/bridge-protocol-adapter.ts index 4dc478b93c..5b300bdfa8 100644 --- a/packages/agent-runtime/src/bridge-protocol-adapter.ts +++ b/packages/agent-runtime/src/bridge-protocol-adapter.ts @@ -20,13 +20,14 @@ import type { ThreadEvent, } from "@bb/domain"; import { PROVIDER_FORK_VALUES } from "@bb/domain"; -import { pendingInteractionPayloadSchema } from "@bb/domain"; import { BRIDGE_INBOUND_REQUEST_METHODS, BRIDGE_NOTIFICATION_METHODS, BRIDGE_REQUEST_METHODS, bridgeCapabilitiesSchema, initializeResultSchema, + experimental_interactionCancelNotificationSchema, + interactionRequestParamsSchema, negotiateGrammarVersion, PROVIDER_BRIDGE_PROTOCOL_VERSION, THREAD_DELTA_NOTIFICATION_METHOD, @@ -44,6 +45,7 @@ import type { ProviderExecutionContext, } from "./provider-adapter.js"; import type { + DecodedInteractiveCancellation, DecodedInteractiveRequest, DecodedToolCallRequest, ProviderCommandPlan, @@ -106,6 +108,9 @@ export interface BridgeProtocolAdapter { decodeInteractiveRequest( request: ProviderInboundRequest, ): DecodedInteractiveRequest | null; + decodeInteractiveCancellation( + event: ProviderRuntimeEvent, + ): DecodedInteractiveCancellation | null; buildInteractiveResponse( args: BuildInteractiveResponseArgs, ): ProviderInteractiveResponse; @@ -174,19 +179,6 @@ const errorNotificationParamsSchema = z }) .passthrough(); -const interactionRequestParamsSchema = z.object({ - providerThreadId: z.string().min(1), - threadId: z.string().min(1).optional(), - turnId: z.union([z.string().min(1), z.null()]), - payload: pendingInteractionPayloadSchema, - /** - * The request's ids are provider-native (a thread/delta bridge holds no bb - * ids): translate the turn id and approval-subject item ids through the - * delta assembler's maps so the app sees the timeline's own ids. - */ - providerNativeIds: z.boolean().optional(), -}); - /** The provider-native-id marker on a normalized tool-call request. */ const providerNativeIdsParamsSchema = z .object({ providerNativeIds: z.boolean().optional() }) @@ -296,6 +288,24 @@ export function createBridgeProtocolAdapter( : {}), }, }; + case "provider/custom": + return { + kind: "request", + method: BRIDGE_REQUEST_METHODS.experimentalCustomCall, + params: { method: command.method, input: command.input }, + }; + case "command/list": + return { + kind: "request", + method: BRIDGE_REQUEST_METHODS.experimentalProviderCommandList, + params: { + providerId: options.id, + cwd: command.cwd, + ...(options.staticProviderOptions !== undefined + ? { providerOptions: options.staticProviderOptions } + : {}), + }, + }; case "provider/health": return { kind: "request", @@ -489,6 +499,17 @@ export function createBridgeProtocolAdapter( ), }, }; + case "extension/action": + return { + kind: "request", + method: BRIDGE_REQUEST_METHODS.experimentalExtensionAction, + params: { + threadId: command.threadId, + providerThreadId: command.providerThreadId, + extensionKind: command.extensionKind, + action: command.action, + }, + }; case "thread/stop": return { kind: "request", @@ -810,11 +831,28 @@ export function createBridgeProtocolAdapter( method: request.method, providerThreadId: decoded.providerThreadId, turnId, + scope: + decoded.experimental_scope === "thread" ? "thread" : "active_turn", payload, ...(threadId ? { threadId } : {}), }; }, + decodeInteractiveCancellation( + event: ProviderRuntimeEvent, + ): DecodedInteractiveCancellation | null { + if ( + event.method !== + BRIDGE_NOTIFICATION_METHODS.experimentalInteractionCancel + ) { + return null; + } + const parsed = experimental_interactionCancelNotificationSchema.safeParse( + event.params, + ); + return parsed.success ? parsed.data : null; + }, + buildInteractiveResponse( args: BuildInteractiveResponseArgs, ): ProviderInteractiveResponse { diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 70061e13f7..1f7d0525ca 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -10,6 +10,8 @@ export type { AgentRuntimeSkillRoot, EnsureProviderArgs, ListModelsArgs, + ListProviderCommandsArgs, + ProviderCustomCallArgs, ReapedIdleProviderSession, RenameThreadArgs, ResumeThreadArgs, diff --git a/packages/agent-runtime/src/permission-matrix.test.ts b/packages/agent-runtime/src/permission-matrix.test.ts index 0a12c6269a..298e2486aa 100644 --- a/packages/agent-runtime/src/permission-matrix.test.ts +++ b/packages/agent-runtime/src/permission-matrix.test.ts @@ -459,6 +459,7 @@ async function runCell( }; const rawRequest = interactionRequest(requestId, payload); handleRuntimeProviderRequest({ + interactiveRequestAbortControllers: new Map(), getActiveTurnId: () => "turn-1", getThreadExecutionOptions: () => executionOptions, onInteractiveRequest, diff --git a/packages/agent-runtime/src/provider-adapter.ts b/packages/agent-runtime/src/provider-adapter.ts index 8349638bf3..63d9541508 100644 --- a/packages/agent-runtime/src/provider-adapter.ts +++ b/packages/agent-runtime/src/provider-adapter.ts @@ -9,8 +9,10 @@ import type { ClientTurnRequestId, DynamicTool, + ExtensionKind, InstructionMode, JsonObject, + JsonValue, PromptInput, PromptMode, ReasoningLevel, @@ -66,6 +68,8 @@ export type AdapterCommand = skillRoots: readonly AgentRuntimeSkillRoot[]; } | { type: "model/list"; cwd?: string } + | { type: "provider/custom"; method: string; input: JsonValue } + | { type: "command/list"; cwd: string } | { type: "provider/health"; cwd?: string } | { type: "provider/usage"; cwd?: string } | { @@ -127,6 +131,13 @@ export type AdapterCommand = clientRequestId: ClientTurnRequestId; options: ProviderExecutionContext; } + | { + type: "extension/action"; + threadId: string; + providerThreadId: string; + extensionKind: ExtensionKind; + action: JsonValue; + } | { type: "thread/stop"; threadId: string; diff --git a/packages/agent-runtime/src/runtime-provider-requests.ts b/packages/agent-runtime/src/runtime-provider-requests.ts index e999aa4cf5..88a1439935 100644 --- a/packages/agent-runtime/src/runtime-provider-requests.ts +++ b/packages/agent-runtime/src/runtime-provider-requests.ts @@ -44,6 +44,7 @@ interface RuntimeProviderRequestArgs { } interface HandleRuntimeProviderRequestArgs extends RuntimeProviderRequestArgs { + interactiveRequestAbortControllers: Map; getActiveTurnId: (threadId: string) => string | null; getThreadExecutionOptions: ( threadId: string, @@ -80,7 +81,7 @@ type ProviderRequestTurnIdWireValue = | UnresolvedProviderRequestTurnId | InvalidProviderRequestTurnId; -function scopeProviderRequestId( +export function scopeProviderRequestId( scope: string, requestId: string | number, ): string { @@ -263,13 +264,16 @@ function handleInteractiveProviderRequest( } const buildInteractiveResponse = args.providerProcess.adapter.buildInteractiveResponse; - const resolvedTurnId = resolveRuntimeProviderRequestTurnId({ - ...args, - requestKind: "interactive request", - resolvedThreadId, - turnId: interactiveReq.turnId, - }); - if (resolvedTurnId === null) { + const resolvedTurnId = + interactiveReq.scope === "thread" + ? null + : resolveRuntimeProviderRequestTurnId({ + ...args, + requestKind: "interactive request", + resolvedThreadId, + turnId: interactiveReq.turnId, + }); + if (resolvedTurnId === null && interactiveReq.scope !== "thread") { return true; } const resolvedInteractiveReq = { @@ -346,8 +350,14 @@ function handleInteractiveProviderRequest( return true; } + const cancellationKey = scopeProviderRequestId( + args.providerProcess.interactiveRequestScope, + interactiveReq.requestId, + ); + const abortController = new AbortController(); + args.interactiveRequestAbortControllers.set(cancellationKey, abortController); void args - .onInteractiveRequest(scopedInteractiveReq) + .onInteractiveRequest(scopedInteractiveReq, abortController.signal) .then((resolution) => { const result = buildInteractiveResponse({ request: resolvedInteractiveReq, @@ -374,6 +384,14 @@ function handleInteractiveProviderRequest( id: args.parsedId, message: err instanceof Error ? err.message : String(err), }); + }) + .finally(() => { + if ( + args.interactiveRequestAbortControllers.get(cancellationKey) === + abortController + ) { + args.interactiveRequestAbortControllers.delete(cancellationKey); + } }); return true; } diff --git a/packages/agent-runtime/src/runtime.command-contract.test.ts b/packages/agent-runtime/src/runtime.command-contract.test.ts index 300f95d402..146c219dd7 100644 --- a/packages/agent-runtime/src/runtime.command-contract.test.ts +++ b/packages/agent-runtime/src/runtime.command-contract.test.ts @@ -101,6 +101,154 @@ describe("createAgentRuntime command contracts", () => { return { record, runtime }; } + it("treats command discovery as unsupported when an older bridge lacks the optional method", async () => { + const { runtime } = createContractRuntime(); + + try { + await expect( + runtime.listProviderCommands({ providerId: "fake", cwd: tmpDir }), + ).resolves.toEqual({ supported: false }); + } finally { + await runtime.shutdown(); + } + }); + + it("recreates an idle session with fresh config and the same provider conversation", async () => { + const { record, runtime } = createContractRuntime(); + + try { + const started = await runtime.startThread({ + environmentId: "env-1", + threadId: "t-reload", + projectId: "p1", + providerId: "fake", + options: fullRuntimeOptions, + instructions: "stale instructions", + }); + const result = await runtime.reloadThread({ + environmentId: "env-1", + threadId: "t-reload", + projectId: "p1", + providerId: "fake", + providerThreadId: started.providerThreadId, + options: { ...fullRuntimeOptions, model: "fresh-model" }, + instructions: "fresh instructions", + }); + + expect(result).toEqual({ + status: "reloaded", + providerThreadId: started.providerThreadId, + }); + expect(runtime.getProviderSession("t-reload")).toEqual({ + providerId: "fake", + providerThreadId: started.providerThreadId, + }); + expect( + record + .read() + .filter((entry) => + ["thread/stop", "thread/resume", "turn/start"].includes( + entry.method, + ), + ), + ).toEqual([ + { + method: "thread/stop", + params: { + threadId: "t-reload", + providerThreadId: started.providerThreadId, + intent: "release", + activeTurnId: null, + }, + }, + expect.objectContaining({ + method: "thread/resume", + params: expect.objectContaining({ + threadId: "t-reload", + providerThreadId: started.providerThreadId, + options: expect.objectContaining({ + instructions: "fresh instructions", + model: "fresh-model", + }), + }), + }), + ]); + } finally { + await runtime.shutdown(); + } + }); + + it("rejects reload while a turn is active or waiting to start", async () => { + const activeEvents: ThreadEvent[] = []; + const active = createContractRuntime({ + onEvent: (event) => activeEvents.push(event), + }); + const pending = createContractRuntime({ + launch: { scripted: { swallowTurnStart: true } }, + }); + + try { + const activeSession = await active.runtime.startThread({ + environmentId: "env-1", + threadId: "t-active-reload", + projectId: "p1", + providerId: "fake", + options: fullRuntimeOptions, + }); + await active.runtime.runTurn({ + threadId: "t-active-reload", + input: [promptTextInput({ text: "delay:1000" })], + clientRequestId: "creq_222222224t", + options: fullRuntimeOptions, + }); + await waitForThreadTurnStarted({ + events: activeEvents, + runtime: active.runtime, + threadId: "t-active-reload", + }); + await expect( + active.runtime.reloadThread({ + environmentId: "env-1", + threadId: "t-active-reload", + projectId: "p1", + providerId: "fake", + providerThreadId: activeSession.providerThreadId, + options: fullRuntimeOptions, + }), + ).resolves.toEqual({ status: "rejected", reason: "active-turn" }); + + const pendingSession = await pending.runtime.startThread({ + environmentId: "env-1", + threadId: "t-pending-reload", + projectId: "p1", + providerId: "fake", + options: fullRuntimeOptions, + }); + await pending.runtime.runTurn({ + threadId: "t-pending-reload", + input: [promptTextInput({ text: "never starts" })], + clientRequestId: "creq_222222224u", + options: fullRuntimeOptions, + }); + await expect( + pending.runtime.reloadThread({ + environmentId: "env-1", + threadId: "t-pending-reload", + projectId: "p1", + providerId: "fake", + providerThreadId: pendingSession.providerThreadId, + options: fullRuntimeOptions, + }), + ).resolves.toEqual({ + status: "rejected", + reason: "pending-turn-start", + }); + } finally { + await active.runtime.shutdown(); + await pending.runtime.shutdown(); + } + }); + it("passes runtime workspace-write roots to the provider as provider options", async () => { const additionalWorkspaceWriteRoots = [ "/repo/.git/worktrees/bb13", diff --git a/packages/agent-runtime/src/runtime.interactive-requests.test.ts b/packages/agent-runtime/src/runtime.interactive-requests.test.ts index 7af1ac2e42..84e7772d7b 100644 --- a/packages/agent-runtime/src/runtime.interactive-requests.test.ts +++ b/packages/agent-runtime/src/runtime.interactive-requests.test.ts @@ -111,6 +111,7 @@ async function answerDirectRequest(args: { } try { handleRuntimeProviderRequest({ + interactiveRequestAbortControllers: new Map(), getActiveTurnId: args.getActiveTurnId ?? (() => "bb-turn-1"), getThreadExecutionOptions: args.getThreadExecutionOptions ?? (() => undefined), @@ -226,6 +227,59 @@ describe("createAgentRuntime interactive requests", () => { expect(onInteractiveRequest).not.toHaveBeenCalled(); }); + it("routes an explicitly thread-scoped user question without an active turn", async () => { + const onInteractiveRequest = vi.fn( + async (): Promise => ({ + kind: "user_answer", + answers: { + value: { selected: [], experimental_verbatimText: " exact " }, + }, + }), + ); + const answer = await answerDirectRequest({ + rawRequest: { + jsonrpc: "2.0", + id: 82, + method: "interaction/request", + params: { + providerThreadId: "prov-1", + threadId: "t1", + turnId: null, + experimental_scope: "thread", + payload: { + kind: "user_question", + questions: [ + { + id: "value", + prompt: "Value", + multiSelect: false, + allowFreeText: true, + experimental_responseMode: "verbatim", + }, + ], + }, + }, + }, + getActiveTurnId: () => null, + onInteractiveRequest, + }); + + expect(answer).toMatchObject({ + jsonrpc: "2.0", + id: 82, + result: { + kind: "user_answer", + answers: { + value: { selected: [], experimental_verbatimText: " exact " }, + }, + }, + }); + expect(onInteractiveRequest).toHaveBeenCalledWith( + expect.objectContaining({ threadId: "t1", turnId: null }), + expect.any(AbortSignal), + ); + }); + it("denies interactive requests when permission escalation is deny", async () => { const requests: string[] = []; const events: ThreadEvent[] = []; @@ -506,6 +560,7 @@ describe("createAgentRuntime interactive requests", () => { expect.objectContaining({ payload: expect.objectContaining({ kind: "secrets/secret-request" }), }), + expect.any(AbortSignal), ); expect(answer).toMatchObject({ jsonrpc: "2.0", diff --git a/packages/agent-runtime/src/runtime.tool-calls.test.ts b/packages/agent-runtime/src/runtime.tool-calls.test.ts index b576faedda..cb090a0d9f 100644 --- a/packages/agent-runtime/src/runtime.tool-calls.test.ts +++ b/packages/agent-runtime/src/runtime.tool-calls.test.ts @@ -207,6 +207,7 @@ describe("createAgentRuntime tool calls", () => { try { handleRuntimeProviderRequest({ + interactiveRequestAbortControllers: new Map(), getActiveTurnId: () => null, getThreadExecutionOptions: () => undefined, onInteractiveRequest: async () => ({ @@ -273,6 +274,7 @@ describe("createAgentRuntime tool calls", () => { try { handleRuntimeProviderRequest({ + interactiveRequestAbortControllers: new Map(), getActiveTurnId: () => "turn-1", getThreadExecutionOptions: () => undefined, onInteractiveRequest: async () => ({ diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 769feb760b..0a71c504d5 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -12,6 +12,9 @@ import type { import type { AdapterCommand } from "./provider-adapter.js"; import { BRIDGE_JSON_RPC_ERRORS, + experimental_extensionActionResultSchema, + experimental_providerCustomCallResultSchema, + experimental_providerCommandListResultSchema, providerHealthResultSchema, providerInstallationRunResultSchema, providerInstallationStatusSchema, @@ -40,6 +43,7 @@ import { } from "./execution-options.js"; import { handleRuntimeProviderRequest, + scopeProviderRequestId, type ResolveRuntimeProviderRequestThreadIdArgs, type RuntimeProviderRequestKind, } from "./runtime-provider-requests.js"; @@ -323,6 +327,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { * joins this set — that restart waits for the thread's next turn by design. */ const threadsRetryingBridgeRestartOnIdle = new Set(); + const interactiveRequestAbortControllers = new Map(); const idleProviderSessionSinceMsByThreadId = new Map(); // Accepted turn dispatches awaiting the provider's turn/started. The // watchdog makes a stalled entry visible instead of silently hung (#1156's @@ -1422,6 +1427,21 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { } function handleProviderNotification(args: RuntimeParsedMessageArgs): void { + const interactiveCancellation = + args.proc.adapter.decodeInteractiveCancellation(args.parsed); + if (interactiveCancellation) { + const key = scopeProviderRequestId( + args.proc.interactiveRequestScope, + interactiveCancellation.requestId, + ); + const controller = interactiveRequestAbortControllers.get(key); + if (controller) { + interactiveRequestAbortControllers.delete(key); + controller.abort(new Error(interactiveCancellation.reason)); + } + return; + } + const sourceThreadId = getJsonRpcStringParam(args.parsed, "threadId"); if ( sourceThreadId !== undefined && @@ -1481,6 +1501,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { if (parsedLine.kind === "request") { handleRuntimeProviderRequest({ + interactiveRequestAbortControllers, getActiveTurnId: (threadId) => turnState.getActiveTurnId(threadId), getThreadExecutionOptions: (threadId) => threadRuntimeConfigs.get(threadId)?.options, @@ -2390,6 +2411,34 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); }, + async applyExtensionAction({ threadId, extensionKind, action }) { + return runThreadOperation({ + threadId, + work: async () => { + const providerId = + threadIdentityRegistry.resolveProviderForThread(threadId); + const proc = requireProviderProcessForThread(threadId); + const command: AdapterCommand = { + type: "extension/action", + threadId, + providerThreadId: requireProviderThreadId(threadId), + extensionKind, + action, + }; + const plan = requireProviderRequestPlan({ + commandType: command.type, + plan: proc.adapter.buildCommandPlan(command), + providerId, + }); + return sendCommand({ + proc, + message: plan, + resultSchema: experimental_extensionActionResultSchema, + }); + }, + }); + }, + async clearThreadGoal({ threadId }) { return runThreadOperation({ threadId, @@ -2527,6 +2576,57 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { return proc.adapter.parseModelListResult(result); }, + async providerCustomCall({ providerId, bridgeLaunch, method, input }) { + await runtime.ensureProvider({ providerId, bridgeLaunch }); + const proc = providerProcesses.requireProviderProcess({ + processKey: resolveProviderProcessKey({ bridgeLaunch, providerId }), + providerId, + }); + const command = requireProviderRequestPlan({ + commandType: "provider/custom", + plan: proc.adapter.buildCommandPlan({ + type: "provider/custom", + method, + input, + }), + providerId, + }); + const result = await sendCommand({ + proc, + message: command, + resultSchema: experimental_providerCustomCallResultSchema, + }); + return result.result; + }, + + async listProviderCommands({ providerId, bridgeLaunch, cwd }) { + await runtime.ensureProvider({ providerId, bridgeLaunch }); + const proc = providerProcesses.requireProviderProcess({ + processKey: resolveProviderProcessKey({ bridgeLaunch, providerId }), + providerId, + }); + const plan = requireProviderRequestPlan({ + commandType: "command/list", + plan: proc.adapter.buildCommandPlan({ type: "command/list", cwd }), + providerId, + }); + try { + return await sendCommand({ + proc, + message: plan, + resultSchema: experimental_providerCommandListResultSchema, + }); + } catch (error) { + if ( + error instanceof JsonRpcResponseError && + error.code === BRIDGE_JSON_RPC_ERRORS.METHOD_NOT_FOUND + ) { + return { supported: false }; + } + throw error; + } + }, + async providerHealth({ providerId, bridgeLaunch, cwd }) { await runtime.ensureProvider({ providerId, bridgeLaunch }); const proc = providerProcesses.requireProviderProcess({ diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts index 9140696c90..e20173389a 100644 --- a/packages/agent-runtime/src/test/runtime-test-harness.ts +++ b/packages/agent-runtime/src/test/runtime-test-harness.ts @@ -182,6 +182,7 @@ type LaunchBearingMethod = | "unarchiveThread" | "reloadThread" | "listModels" + | "listProviderCommands" | "providerHealth" | "providerUsage" | "providerInstallationStatus" @@ -235,6 +236,8 @@ export function withBridgeLaunch( runtime.providerInstallationStatus({ bridgeLaunch, ...args }), providerInstallationRun: (args) => runtime.providerInstallationRun({ bridgeLaunch, ...args }), + listProviderCommands: (args) => + runtime.listProviderCommands({ bridgeLaunch, ...args }), }; } diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts index bbf9eff1ef..93117464b9 100644 --- a/packages/agent-runtime/src/types.ts +++ b/packages/agent-runtime/src/types.ts @@ -3,8 +3,10 @@ import type { AvailableModel, ClientTurnRequestId, DynamicTool, + ExtensionKind, InstructionMode, JsonObject, + JsonValue, PendingInteractionCreate, PendingInteractionResolution, PromptInput, @@ -16,6 +18,7 @@ import type { ToolCallResponse, } from "@bb/domain"; import type { + ExperimentalProviderCommandListResult, ProviderHealthResult, ProviderInstallationRunResult, ProviderInstallationStatus, @@ -115,6 +118,7 @@ export interface AgentRuntimeOptions { * The runtime converts provider-native requests into bb's shared pending-interaction contract. */ onInteractiveRequest?: ( request: PendingInteractionCreate, + signal?: AbortSignal, ) => Promise; /** Called on provider stderr lines. */ @@ -310,6 +314,12 @@ interface SteerTurnStaleResult { export type SteerTurnResult = SteerTurnAppliedResult | SteerTurnStaleResult; +export interface ApplyExtensionActionArgs { + threadId: string; + extensionKind: ExtensionKind; + action: JsonValue; +} + export interface StopThreadArgs { threadId: string; } @@ -377,6 +387,19 @@ export interface ListModelsArgs { cwd?: string; } +export interface ProviderCustomCallArgs { + providerId: string; + bridgeLaunch: AgentRuntimeBridgeLaunch; + method: string; + input: JsonValue; +} + +export interface ListProviderCommandsArgs { + providerId: string; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd: string; +} + interface ProviderMaintenanceArgs { providerId: string; bridgeLaunch: AgentRuntimeBridgeLaunch; @@ -422,6 +445,10 @@ export interface AgentRuntime { */ stopThread(args: StopThreadArgs): Promise; + applyExtensionAction( + args: ApplyExtensionActionArgs, + ): Promise<{ applied: boolean }>; + clearThreadGoal(args: ClearThreadGoalArgs): Promise<{ cleared: boolean }>; renameThread(args: RenameThreadArgs): Promise; @@ -435,6 +462,12 @@ export interface AgentRuntime { selectedOnlyModels: AvailableModel[]; }>; + providerCustomCall?(args: ProviderCustomCallArgs): Promise; + + listProviderCommands( + args: ListProviderCommandsArgs, + ): Promise; + providerHealth( args: ProviderMaintenanceArgs, ): Promise; diff --git a/packages/client-core/src/timeline/timelineRowSignatures.ts b/packages/client-core/src/timeline/timelineRowSignatures.ts index 9bedfbfab8..37e0e8e5cb 100644 --- a/packages/client-core/src/timeline/timelineRowSignatures.ts +++ b/packages/client-core/src/timeline/timelineRowSignatures.ts @@ -313,6 +313,7 @@ function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string { questionId, answer.selected.join("\u001d"), answer.freeText, + answer.experimental_verbatimText, ]), ) .join("\u001e") diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index 353da9243a..8be2d393fa 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -115,6 +115,7 @@ function makeTimelineResponse( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null, diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 717d823d47..ebe77e165e 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1109,6 +1109,10 @@ export interface ListLatestThreadStateEventRowsByThreadIdsArgs { kind: string; } +export interface ListLatestExtensionStateEventRowsForThreadArgs { + threadId: string; +} + export interface ListOpenTurnInputAcceptedRowsByThreadIdsArgs { threadIds: readonly string[]; } @@ -1286,6 +1290,10 @@ export interface PruneBackgroundTaskProgressEventsArgs { threadId: string; } +export interface PruneSupersededExtensionStateEventsArgs { + threadId: string; +} + export interface ListOpenBackgroundTaskItemRowsForHostArgs { hostId: string; } @@ -1450,6 +1458,35 @@ export function listLatestThreadStateEventRowsByThreadIds( }); } +export function listLatestExtensionStateEventRowsForThread( + db: DbQueryConnection, + args: ListLatestExtensionStateEventRowsForThreadArgs, +): StoredEventRow[] { + const extensionStateType = + "thread/extensionState/updated" satisfies ThreadEventType; + return db + .select(storedEventRowFields) + .from(events) + .where(sql`${events}.rowid IN ( + SELECT latest_extension_state.rowid + FROM ${events} AS latest_extension_state + WHERE latest_extension_state.thread_id = ${args.threadId} + AND latest_extension_state.type = ${extensionStateType} + AND latest_extension_state.sequence = ( + SELECT MAX(candidate.sequence) + FROM ${events} AS candidate + WHERE candidate.thread_id = latest_extension_state.thread_id + AND candidate.type = ${extensionStateType} + AND json_extract(candidate.data, '$.kind') = + json_extract(latest_extension_state.data, '$.kind') + ) + ORDER BY latest_extension_state.sequence DESC + LIMIT 32 + )`) + .orderBy(events.sequence) + .all(); +} + export function listOpenTurnInputAcceptedRowsByThreadIds( db: DbQueryConnection, args: ListOpenTurnInputAcceptedRowsByThreadIdsArgs, @@ -3562,6 +3599,28 @@ function pruneLatestRowsForContextWindowUsageBeforeSequence( return result.changes; } +export function pruneSupersededExtensionStateEvents( + db: DbConnection, + args: PruneSupersededExtensionStateEventsArgs, +): number { + const eventType = "thread/extensionState/updated" satisfies ThreadEventType; + const result = db.run(sql` + DELETE FROM ${events} + WHERE ${events.threadId} = ${args.threadId} + AND ${events.type} = ${eventType} + AND EXISTS ( + SELECT 1 + FROM ${events} AS newer + WHERE newer.thread_id = ${events.threadId} + AND newer.type = ${eventType} + AND newer.sequence > ${events.sequence} + AND json_extract(newer.data, '$.kind') = + json_extract(${events.data}, '$.kind') + ) + `); + return result.changes; +} + export function pruneContextWindowUsageEventsBeforeSequence( db: DbConnection, args: PruneContextWindowUsageEventsBeforeSequenceArgs, diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 4cadc15b1a..e24c66a997 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -279,6 +279,7 @@ export { listStoredTurnStartedRowsByTurnIdsUpToSequence, getLatestThreadInterruptedReason, listLatestThreadStateEventRowsByThreadIds, + listLatestExtensionStateEventRowsForThread, listLatestBackgroundTaskStateRowsByItemIds, listLatestOpenBackgroundTaskStateRowsForThread, listTodoSnapshotEventRowsForThread, @@ -289,6 +290,7 @@ export { listThreadTurnInterruptionEventStates, MissingStoredTurnStartedError, pruneBackgroundTaskProgressEvents, + pruneSupersededExtensionStateEvents, pruneContextWindowUsageEventsBeforeSequence, pruneTokenUsageEventsBeforeSequence, pruneResolvedItemDeltas, diff --git a/packages/db/src/data/pending-interactions.ts b/packages/db/src/data/pending-interactions.ts index b55509197f..811cfb3dd1 100644 --- a/packages/db/src/data/pending-interactions.ts +++ b/packages/db/src/data/pending-interactions.ts @@ -22,7 +22,7 @@ export type CreatePendingInteractionInput = providerId: string; providerRequestId: string; providerThreadId: string; - turnId: string; + turnId: string | null; }) | (CreatePendingInteractionInputBase & { originKind: "plugin"; diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index 1465b3418a..55f26ba2f1 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -29,6 +29,7 @@ import { listContextWindowUsageRows, listCompletedTurnsByThreadIds, listEvents, + listLatestExtensionStateEventRowsForThread, listLatestThreadStateEventRowsByThreadIds, listRecentStoredEventRows, listStoredConversationOutlineEventRows, @@ -51,6 +52,7 @@ import { listThreadTurnInterruptionEventStates, pruneBackgroundTaskProgressEvents, pruneContextWindowUsageEventsBeforeSequence, + pruneSupersededExtensionStateEvents, pruneTokenUsageEventsBeforeSequence, pruneResolvedItemDeltas, pruneThreadEventsBeforeSequence, @@ -1879,6 +1881,47 @@ describe("events", () => { expect(rowsByThreadId.get(otherThread.id)?.sequence).toBe(2); }); + it("bounds extension-state replay to the latest snapshot of each kind", () => { + const { db, thread } = setup(); + const uiUpdates = Array.from({ length: 40 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + type: "thread/extensionState/updated" as const, + ...threadEventFields, + providerThreadId: "provider-thread-state", + data: JSON.stringify({ + kind: "provider-pi/extension-ui", + payload: { revision: index + 1 }, + }), + })); + insertEvents(db, noopNotifier, [ + ...uiUpdates, + { + threadId: thread.id, + sequence: 41, + type: "thread/extensionState/updated", + ...threadEventFields, + providerThreadId: "provider-thread-state", + data: JSON.stringify({ + kind: "provider-pi/other", + payload: { revision: 1 }, + }), + }, + ]); + + expect( + listLatestExtensionStateEventRowsForThread(db, { + threadId: thread.id, + }).map((row) => row.sequence), + ).toEqual([40, 41]); + expect( + pruneSupersededExtensionStateEvents(db, { threadId: thread.id }), + ).toBe(39); + expect( + listEvents(db, { threadId: thread.id }).map((row) => row.sequence), + ).toEqual([40, 41]); + }); + it("batches latest goal lookups above the SQLite variable limit", () => { const { db } = setup(); const threadIds = Array.from( diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts index fdf781b057..8946d8ae8e 100644 --- a/packages/domain/src/change-kinds.ts +++ b/packages/domain/src/change-kinds.ts @@ -61,6 +61,7 @@ export const SYSTEM_CHANGE_KINDS = [ "config-changed", "plugins-changed", "provider-registrations-changed", + "provider-models-changed", ] as const; export type SystemChangeKind = (typeof SYSTEM_CHANGE_KINDS)[number]; diff --git a/packages/domain/src/pending-interactions.ts b/packages/domain/src/pending-interactions.ts index 30fee05e3e..c1863a4eac 100644 --- a/packages/domain/src/pending-interactions.ts +++ b/packages/domain/src/pending-interactions.ts @@ -216,7 +216,7 @@ export type ApprovalPendingInteractionPayload = z.infer< >; export const USER_QUESTION_MAX_QUESTIONS = 4; -export const USER_QUESTION_MAX_OPTIONS = 4; +export const USER_QUESTION_MAX_OPTIONS = 100; export const USER_QUESTION_MAX_SELECTED = 4; export const USER_QUESTION_MAX_FREE_TEXT_LENGTH = 4096; @@ -296,8 +296,48 @@ export const pendingInteractionUserQuestionQuestionSchema = z ) .optional(), allowFreeText: z.boolean(), + /** + * Preserve a free-text answer byte-for-byte, including an empty string. + * Omission keeps the normal trimmed, non-blank user-question semantics. + */ + experimental_responseMode: z.literal("verbatim").optional(), + /** Placeholder for a verbatim text input. */ + experimental_placeholder: z.string().optional(), + /** Initial byte-preserved value for a verbatim multi-line editor. */ + experimental_prefill: z + .string() + .max( + USER_QUESTION_MAX_FREE_TEXT_LENGTH, + `User question prefill cannot exceed ${USER_QUESTION_MAX_FREE_TEXT_LENGTH} characters`, + ) + .optional(), }) .superRefine((question, context) => { + if ( + question.experimental_responseMode === "verbatim" && + (!question.allowFreeText || + question.multiSelect || + (question.options?.length ?? 0) > 0) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Verbatim user questions must be single-value free-text questions without options", + path: ["experimental_responseMode"], + }); + } + + if ( + question.experimental_prefill !== undefined && + question.experimental_responseMode !== "verbatim" + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Question prefill requires verbatim response semantics", + path: ["experimental_prefill"], + }); + } + const optionValues = new Set(); question.options?.forEach((option, index) => { if (optionValues.has(option.value)) { @@ -476,15 +516,33 @@ export type ApprovalPendingInteractionResolution = z.infer< typeof approvalPendingInteractionResolutionSchema >; -export const pendingInteractionUserAnswerSchema = z.object({ - selected: z - .array(z.string().min(1)) - .max( - USER_QUESTION_MAX_SELECTED, - `User question selected choices cannot exceed ${USER_QUESTION_MAX_SELECTED}`, - ), - freeText: pendingInteractionUserQuestionFreeTextSchema.optional(), -}); +export const pendingInteractionUserAnswerSchema = z + .object({ + selected: z + .array(z.string().min(1)) + .max( + USER_QUESTION_MAX_SELECTED, + `User question selected choices cannot exceed ${USER_QUESTION_MAX_SELECTED}`, + ), + freeText: pendingInteractionUserQuestionFreeTextSchema.optional(), + /** Exact text returned for a question with verbatim response semantics. */ + experimental_verbatimText: z + .string() + .max( + USER_QUESTION_MAX_FREE_TEXT_LENGTH, + `User question verbatim text cannot exceed ${USER_QUESTION_MAX_FREE_TEXT_LENGTH} characters`, + ) + .optional(), + }) + .refine( + (answer) => + answer.freeText === undefined || + answer.experimental_verbatimText === undefined, + { + message: "User answers cannot contain both free text and verbatim text", + path: ["experimental_verbatimText"], + }, + ); export type PendingInteractionUserAnswer = z.infer< typeof pendingInteractionUserAnswerSchema >; @@ -611,14 +669,25 @@ const pendingInteractionPluginOriginSchema = z.object({ rendererId: z.string().min(1), }); -export const pendingInteractionCreateSchema = z.object({ - threadId: z.string().min(1), - turnId: z.string().min(1), - providerId: z.string().min(1), - providerThreadId: z.string().min(1), - providerRequestId: z.string().min(1), - payload: pendingInteractionPayloadSchema, -}); +export const pendingInteractionCreateSchema = z + .object({ + threadId: z.string().min(1), + /** Null only for a user question raised outside a provider turn. */ + turnId: z.string().min(1).nullable(), + providerId: z.string().min(1), + providerThreadId: z.string().min(1), + providerRequestId: z.string().min(1), + payload: pendingInteractionPayloadSchema, + }) + .refine( + (interaction) => + interaction.turnId !== null || + interaction.payload.kind === "user_question", + { + message: "Only user questions may be scoped outside a provider turn", + path: ["turnId"], + }, + ); export type PendingInteractionCreate = z.infer< typeof pendingInteractionCreateSchema >; @@ -659,6 +728,11 @@ export type ApprovalPendingInteraction = z.infer< const userQuestionPendingInteractionSchema = providerPendingInteractionBaseSchema.extend({ + /** + * A user question may be thread-scoped: a provider extension (Pi's + * `ctx.ui.select/confirm/input`) can ask outside any provider turn. + */ + turnId: z.string().min(1).nullable(), payload: userQuestionPendingInteractionPayloadSchema, resolution: userQuestionPendingInteractionResolutionSchema.nullable(), }); diff --git a/packages/domain/src/thread-event-scope.ts b/packages/domain/src/thread-event-scope.ts index 7d75515075..d01d58415a 100644 --- a/packages/domain/src/thread-event-scope.ts +++ b/packages/domain/src/thread-event-scope.ts @@ -209,7 +209,11 @@ const threadEventScopeDefinitionByType = { "A provider interaction belongs to the turn that raised it; a plugin may raise one outside any turn.", }, "system/permissionGrant/lifecycle": { policy: "turn" }, - "system/userQuestion/lifecycle": { policy: "turn" }, + "system/userQuestion/lifecycle": { + policy: "thread-or-turn", + rationale: + "Provider questions normally belong to a model turn; extension-command questions can block before that command opens a turn.", + }, "system/thread-provisioning": { policy: "thread", rationale: diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 991b9b9845..ab3a3bd1ae 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -2,6 +2,7 @@ import { availableModelSchema, discoveredWorkspacePropertiesSchema, dynamicToolSchema, + extensionKindSchema, instructionModeSchema, pendingInteractionResolutionSchema, permissionModeSchema, @@ -392,6 +393,15 @@ export const threadStopCommandSchema = hostDaemonThreadTargetSchema }) .strict(); +export const threadExtensionStateActionCommandSchema = + hostDaemonThreadTargetSchema + .extend({ + type: z.literal("thread.extension-state.action"), + extensionKind: extensionKindSchema, + action: jsonValueSchema, + }) + .strict(); + const threadGoalClearCommandSchema = hostDaemonThreadTargetSchema .extend({ type: z.literal("thread.goal.clear"), @@ -733,6 +743,12 @@ const hostListCommandsCommandSchema = z providerId: z.string().min(1), cwd: z.string().min(1).nullable(), nativeRoots: providerNativeRootSetSchema, + /** + * Omission requests static skill/command scanning only. With a launch the + * daemon also asks the provider's bridge for its own commands (`command/list`) + * and merges them with the static scan. + */ + bridgeLaunch: hostDaemonBridgeLaunchSchema.optional(), }) .strict(); @@ -950,6 +966,16 @@ const providerListModelsCommandSchema = z.object({ cwd: z.string().min(1).optional(), }); +const providerCustomCallCommandSchema = z + .object({ + type: z.literal("provider.custom_call"), + providerId: z.string().min(1), + bridgeLaunch: hostDaemonBridgeLaunchSchema, + method: z.string().min(1), + input: jsonValueSchema, + }) + .strict(); + const providerHealthCommandSchema = z .object({ type: z.literal("provider.health"), @@ -1346,6 +1372,7 @@ const pluginHostDisposeResultSchema = z // full raw set across all roots and the server owns de-dup/sort/limit. const commandListResultSchema = z.object({ commands: z.array(hostProviderCommandSchema), + diagnostics: z.array(z.string()), }); // Like `commandListResultSchema`: the daemon returns the full raw set across @@ -1413,6 +1440,10 @@ const providerListModelsResultSchema = z.object({ selectedOnlyModels: z.array(availableModelSchema), }); +const providerCustomCallResultSchema = z + .object({ result: jsonValueSchema }) + .strict(); + const threadStartResultSchema = z.object({ providerThreadId: z.string().min(1), }); @@ -1602,6 +1633,15 @@ export const hostDaemonCommandRegistry = { flushEventsBeforeResult: true, envLane: null, }), + "thread.extension-state.action": defineHostDaemonCommandDescriptor({ + type: "thread.extension-state.action", + schema: threadExtensionStateActionCommandSchema, + resultSchema: z.object({ applied: z.boolean() }).strict(), + transport: "settled", + retryable: false, + flushEventsBeforeResult: true, + envLane: null, + }), "thread.goal.clear": defineHostDaemonCommandDescriptor({ type: "thread.goal.clear", schema: threadGoalClearCommandSchema, @@ -1971,6 +2011,15 @@ export const hostDaemonCommandRegistry = { flushEventsBeforeResult: false, envLane: null, }), + "provider.custom_call": defineHostDaemonCommandDescriptor({ + type: "provider.custom_call", + schema: providerCustomCallCommandSchema, + resultSchema: providerCustomCallResultSchema, + transport: "onlineRpc", + retryable: false, + flushEventsBeforeResult: false, + envLane: null, + }), "provider.health": defineHostDaemonCommandDescriptor({ type: "provider.health", schema: providerHealthCommandSchema, diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 0f08447c5d..5eb7f7a348 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -333,12 +333,24 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 171 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 172 as const; // Version 171 adds `thread.reload` (server → daemon): recreate an idle // thread's provider session from the thread's current config, answered with // the provider thread id. An older daemon rejects the unknown command, so the // bump is what moves an enrolled machine forward. +// Version 172 carries provider extension UI over the wire. Server → daemon: +// `provider.custom_call` (a plugin's own bridge RPC on a host), +// `thread.extension-state.action` (an app action for a provider's extension +// state), `host.list_commands` with an optional `bridgeLaunch` so the daemon +// also asks the provider's bridge for its own commands (`command/list`) and +// returns the required `diagnostics` list beside the scan, and +// `interactive.resolve` resolutions that may carry +// `experimental_verbatimText`. Daemon → server: an interactive-request +// registration may carry `turnId: null` for a user question a provider raised +// outside any turn, and persisted `extension.state` snapshots ride the +// thread-delta path. An older daemon rejects the unknown commands and the +// launch field and returns no diagnostics list. /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index 6dc41ac729..ea4a51eaa2 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -443,6 +443,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion( onlineRpcResponseSuccessSchemaFor("host.read_file_relative"), onlineRpcResponseSuccessSchemaFor("host.write_file"), onlineRpcResponseSuccessSchemaFor("provider.list_models"), + onlineRpcResponseSuccessSchemaFor("provider.custom_call"), onlineRpcResponseSuccessSchemaFor("provider.health"), onlineRpcResponseSuccessSchemaFor("provider.installation.status"), onlineRpcResponseSuccessSchemaFor("provider.installation.run"), @@ -458,6 +459,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion( commandRpcResponseSuccessSchemaFor("thread.reload"), commandRpcResponseSuccessSchemaFor("turn.submit"), commandRpcResponseSuccessSchemaFor("thread.stop"), + commandRpcResponseSuccessSchemaFor("thread.extension-state.action"), commandRpcResponseSuccessSchemaFor("thread.goal.clear"), commandRpcResponseSuccessSchemaFor("thread.plan.cancel"), commandRpcResponseSuccessSchemaFor("thread.rename"), diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 537c6e1022..ff1e109f64 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -176,6 +176,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { "plugin.host.call": { output: { ok: true } }, "plugin.host.cancel": { cancelled: true }, "plugin.host.dispose": { disposed: true }, + "provider.custom_call": { result: { ok: true } }, "connect-tunnel.ensure-identity": { label: "sawyer-air", baseDomain: "getbb.app", @@ -238,6 +239,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { argumentHint: null, }, ], + diagnostics: [], }, "host.list_skills": { skills: [ @@ -459,6 +461,7 @@ const SETTLED_RESPONSE_RESULT_FIXTURES: SettledResponseResultFixtures = { appliedAs: "new-turn", }, "thread.stop": { providerCheckpointId: null }, + "thread.extension-state.action": { applied: true }, "thread.goal.clear": { cleared: true }, "thread.plan.cancel": { cancelled: true }, "thread.rename": {}, @@ -642,6 +645,8 @@ const INTENTIONAL_OPTIONAL_HOST_DAEMON_FIELDS: Record = { "environment.provision only includes checkout instructions for unmanaged workspaces that requested a branch mutation.", "hostDaemonCommandSchema.targetPath": "project.clone omits targetPath when the daemon should derive its default checkout location for the project.", + "hostDaemonOnlineRpcCommandSchema.bridgeLaunch": + "host.list_commands omits bridgeLaunch for providers that use only static skill and command scanning.", "hostDaemonOnlineRpcCommandSchema.expectedSha256": "host.write_file may omit expectedSha256 for unconditional writes; a hash is the compare-and-swap guard and null means create-only.", "hostDaemonOnlineRpcCommandSchema.mode": @@ -1013,6 +1018,11 @@ describe("host-daemon command schemas", () => { // completeness over the host wire. Older daemons cannot safely enforce or // interpret those fields, so enrolled machines must update before serving // workspace status and diff requests. + // Version 165 lets host.list_commands carry a provider bridge launch and + // return non-fatal diagnostics (with thread.reload, provider.custom_call, + // thread.extension-state.action, and thread-scoped interactions). Older + // daemons neither recognize the launch field nor return the required + // diagnostic list. // Version 118 rejects successful provider update results when the daemon // cannot verify a version change. Older daemons can report a no-op Claude // update as successful, so enrolled machines must update for honest results. @@ -1047,7 +1057,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(171); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(172); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts b/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts index 6059a7f517..c8d08790ee 100644 --- a/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts +++ b/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts @@ -129,7 +129,7 @@ describe("provider declaration target-state fields", () => { validatePluginProviderDeclaration( declaration({ extensionKinds: { goal: {} } }), ), - ).toThrow(/item schema, a state schema, or both/u); + ).toThrow(/item schema, a state schema, or an experimental action schema/u); expect(() => validatePluginProviderDeclaration( declaration({ diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index 328d162c76..dae23142f3 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -25,6 +25,7 @@ type ExpectedBbPluginApiKey = const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "BbPluginApi", + "ExperimentalProviderBridgeClient", "PluginAgents", "PluginAiServiceDeclaration", "PluginAiServiceKind", diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 2b6f4c564f..6468cae759 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1,5 +1,6 @@ import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from "react"; import type { + ExtensionKind, PermissionMode, PromptInput, ProviderInfo, @@ -36,12 +37,15 @@ export interface PluginHomepageSectionProps { projectId: string | null; } -/** - * Props passed to a `settingsSection` component. - * - * Deliberately empty in V1; versioned additive like the other slot props. - */ -export interface PluginSettingsSectionProps {} +/** Props passed to a `settingsSection` component. */ +export interface PluginSettingsSectionProps { + /** + * The host selected by BB's Settings context. Plugins use this for + * host-local configuration without adding their own machine picker. + * Null while no host is available. Experimental: see docs/api_to_audit.md. + */ + experimental_hostId?: string | null; +} /** Props passed to a `navPanel` component (it owns its whole route). */ export interface PluginNavPanelProps { @@ -1338,6 +1342,43 @@ export interface PluginTimelineRendererRegistration { component: ComponentType; } +/** + * Current plugin-declared provider state projected from the latest persisted + * `extension.state` snapshot. The host mounts the renderer on both sides of + * the composer so the owning plugin can honor native placement semantics. + * Experimental: see docs/api_to_audit.md. + */ +export interface ExperimentalProviderExtensionStateProps { + threadId: string; + providerId: string; + kind: ExtensionKind; + payload: JsonValue; + sourceSeq: number; + placement: "aboveEditor" | "belowEditor"; + /** + * Validate and dispatch one opaque action to this state kind in the current + * provider session. The promise never retries: callers decide whether an + * action is safe to repeat. Experimental: see docs/api_to_audit.md. + */ + experimental_dispatchAction(action: JsonValue): Promise<{ applied: boolean }>; +} + +/** + * Render one local provider extension-state kind. `name` is the local name + * declared by this plugin's backend; the host prefixes the owning plugin id + * and never passes another plugin's payload to the component. + * Experimental: see docs/api_to_audit.md. + */ +export interface ExperimentalProviderExtensionStateRegistration { + name: string; + component: ComponentType; +} + +/** + * Host-owned non-modal drawer. Content realization starts after two animation + * frames and remains mounted after the first open. Experimental: see + * docs/api_to_audit.md. + */ export interface ExperimentalResponsiveDrawerProps { open: boolean; onOpenChange(open: boolean): void; @@ -1441,6 +1482,13 @@ export interface PluginAppSlots { experimental_timelineRenderer( registration: PluginTimelineRendererRegistration, ): void; + /** + * Render current plugin-declared provider state beside the composer. + * Experimental: see docs/api_to_audit.md. + */ + experimental_providerExtensionState( + registration: ExperimentalProviderExtensionStateRegistration, + ): void; } export interface PluginAppComposer { diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index cd5adf9e5a..545496671a 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -12,7 +12,9 @@ import type { JsonValue } from "./json-value.js"; import type { PluginRpcContract, PluginRpcHandlers, + PluginRpcResult, StandardSchemaV1, + StandardSchemaV1InferInput, } from "./rpc-contract.js"; import type { ExperimentalHostClient, @@ -653,13 +655,16 @@ export interface PluginProviderOptionDescriptor { * Payload schemas for one extension kind this provider emits, keyed by the * kind's local name (the server prefixes the plugin id to form the * namespaced `"/"`). `item` validates `item.open` payloads - * with `type: "extension"`, `state` validates `extension.state` payloads; - * each is optional so a kind can be item-only or state-only. Schemas are - * Standard Schema v1 validators (zod 4 schemas qualify). + * with `type: "extension"`, `state` validates `extension.state` payloads, + * and `experimental_action` validates app-to-bridge actions for that state. + * Each is optional so a kind declares only the surfaces it supports. Schemas + * are Standard Schema v1 validators (zod 4 schemas qualify). */ export interface PluginProviderExtensionKindDeclaration { item?: StandardSchemaV1; state?: StandardSchemaV1; + /** Experimental: see docs/api_to_audit.md. */ + experimental_action?: StandardSchemaV1; } /** @@ -988,6 +993,16 @@ export interface PluginAgents { * registration; `bb.agents` keeps `configure`, `registerTool`, and * `contributeInstructions`. */ +export interface ExperimentalProviderBridgeClient< + Contract extends PluginRpcContract, +> { + call( + method: MethodName, + input: StandardSchemaV1InferInput, + options: { readonly hostId: string; readonly signal?: AbortSignal }, + ): Promise>; +} + export interface PluginProviders { /** * Register an agent provider this plugin contributes (see @@ -1005,6 +1020,25 @@ export interface PluginProviders { register(declaration: PluginProviderDeclaration): { dispose(): void; }; + /** + * Create a typed client for one provider this plugin owns. Calls route to + * that provider's bridge on an explicit host; core validates JSON framing + * but never interprets the provider-owned method or payload. + * Experimental: see docs/api_to_audit.md. + */ + experimental_client(args: { + providerId: string; + contract: Contract; + }): ExperimentalProviderBridgeClient; + /** + * Clear cached model catalogs and notify clients after this plugin changes + * native model preferences on one host. + * Experimental: see docs/api_to_audit.md. + */ + experimental_modelsChanged(args: { + providerId: string; + hostId: string; + }): void; } // --------------------------------------------------------------------------- diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index 7943b3c85f..d5a53f410b 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -406,9 +406,7 @@ function normalizeProviderBridgeOptions( return current; } if (typeof current !== "object") { - throw new Error( - `provider "${providerId}" ${label}${path} must be JSON`, - ); + throw new Error(`provider "${providerId}" ${label}${path} must be JSON`); } if (active.has(current)) { throw new Error( @@ -449,9 +447,7 @@ function normalizeProviderBridgeOptions( Array.isArray(normalized) || typeof normalized !== "object" ) { - throw new Error( - `provider "${providerId}" ${label} must be an object`, - ); + throw new Error(`provider "${providerId}" ${label} must be an object`); } if ( Buffer.byteLength(JSON.stringify(normalized), "utf8") > @@ -632,14 +628,15 @@ function validateProviderExtensionKinds( Array.isArray(declaration) ) { throw new Error( - `provider "${providerId}" extensionKinds.${name} must be { item?, state? }`, + `provider "${providerId}" extensionKinds.${name} must be { item?, state?, experimental_action? }`, ); } const item = Reflect.get(declaration, "item"); const state = Reflect.get(declaration, "state"); - if (item === undefined && state === undefined) { + const action = Reflect.get(declaration, "experimental_action"); + if (item === undefined && state === undefined && action === undefined) { throw new Error( - `provider "${providerId}" extensionKinds.${name} must declare an item schema, a state schema, or both`, + `provider "${providerId}" extensionKinds.${name} must declare an item schema, a state schema, or an experimental action schema`, ); } if (item !== undefined && !isStandardSchema(item)) { @@ -652,9 +649,15 @@ function validateProviderExtensionKinds( `provider "${providerId}" extensionKinds.${name}.state must be a Standard Schema v1 validator`, ); } + if (action !== undefined && !isStandardSchema(action)) { + throw new Error( + `provider "${providerId}" extensionKinds.${name}.experimental_action must be a Standard Schema v1 validator`, + ); + } normalized[name] = Object.freeze({ ...(item === undefined ? {} : { item }), ...(state === undefined ? {} : { state }), + ...(action === undefined ? {} : { experimental_action: action }), }); } return Object.freeze(normalized); diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 663394ac4d..544840e81c 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -1,6 +1,7 @@ import type { ComposerCustomization, ExperimentalChangesViewRegistration, + ExperimentalProviderExtensionStateRegistration, ExperimentalSidebarNavigationRegistration, PluginAppDefinition, PluginContentScriptRegistration, @@ -82,6 +83,7 @@ function rejectStaleNavPanelKeys(kind: string, registration: object): void { } } } +const PROVIDER_EXTENSION_STATE_NAME_PATTERN = /^[a-z0-9-]+$/u; /** Validated registrations produced by one plugin app setup execution. */ export interface CollectedPluginAppRegistrations { @@ -105,6 +107,7 @@ export interface CollectedPluginAppRegistrations { commandPaletteActions: PluginCommandPaletteActionRegistration[]; providerIcons: PluginProviderIconRegistration[]; timelineRenderers: PluginTimelineRendererRegistration[]; + providerExtensionStates: ExperimentalProviderExtensionStateRegistration[]; contentScripts: PluginContentScriptRegistration[]; } @@ -140,6 +143,7 @@ export function collectPluginAppRegistrations( commandPaletteActions: [], providerIcons: [], timelineRenderers: [], + providerExtensionStates: [], contentScripts: [], }; const seenIds = { @@ -163,6 +167,7 @@ export function collectPluginAppRegistrations( commandPaletteAction: new Set(), providerIcon: new Set(), timelineRenderer: new Set(), + providerExtensionState: new Set(), contentScript: new Set(), }; @@ -575,6 +580,20 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + experimental_providerExtensionState(registration) { + const kind = "slots.experimental_providerExtensionState"; + const name = requireNonEmptyString(kind, "name", registration?.name); + if (!PROVIDER_EXTENSION_STATE_NAME_PATTERN.test(name)) { + throw new Error( + `${kind}: "name" must match ${String(PROVIDER_EXTENSION_STATE_NAME_PATTERN)}, got ${JSON.stringify(name)}`, + ); + } + requireUniqueId(kind, seenIds.providerExtensionState, name); + collected.providerExtensionStates.push({ + name, + component: requireComponent(kind, registration.component), + }); + }, }, composer: { customize(registration) { diff --git a/packages/plugin-sdk/src/provider-bridge.ts b/packages/plugin-sdk/src/provider-bridge.ts index 8d77292be7..db7c421304 100644 --- a/packages/plugin-sdk/src/provider-bridge.ts +++ b/packages/plugin-sdk/src/provider-bridge.ts @@ -86,10 +86,19 @@ export { providerRecoveryHintSchema, providerRecoveryNotificationSchema, bridgeErrorDataSchema, + interactionRequestParamsSchema as experimental_interactionRequestParamsSchema, + experimental_interactionCancelNotificationSchema, threadDeltaNotificationParamsSchema, threadDeltaSchema, initializeParamsSchema, modelListParamsSchema, + experimental_providerCustomCallParamsSchema, + experimental_providerCustomCallResultSchema, + experimental_providerCommandListParamsSchema, + experimental_providerCommandListResultSchema, + experimental_providerCommandSchema, + experimental_extensionActionParamsSchema, + experimental_extensionActionResultSchema, providerHealthResultSchema, providerHealthSchema, providerInstallationActionKindSchema, @@ -124,6 +133,8 @@ export type { BridgeExecutionOptions, BridgeGrammarVersions, BridgeSteerMode, + ExperimentalInteractionCancelNotification, + InteractionRequestParams as ExperimentalInteractionRequestParams, DeltaBackgroundTaskShape, DeltaDelegationShape, DeltaExtensionShape, @@ -142,6 +153,11 @@ export type { ProviderRecoveryHint, ProviderRecoveryNotification, BridgeErrorData, + ExperimentalExtensionActionParams, + ExperimentalExtensionActionResult, + ExperimentalProviderCommand, + ExperimentalProviderCommandListParams, + ExperimentalProviderCommandListResult, ProviderHealth, ProviderHealthResult, ProviderInstallationAction, @@ -295,6 +311,7 @@ export { pendingInteractionNetworkPermissionsSchema, pendingInteractionRequestedPermissionProfileSchema, pendingInteractionResolutionSchema, + USER_QUESTION_MAX_FREE_TEXT_LENGTH, providerInteractionOutcomeSchema, userQuestionInteractionOutcomeSchema, permissionEscalationValues, diff --git a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts index 9ce00d1a9e..9ffb0c7107 100644 --- a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts +++ b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts @@ -1448,18 +1448,16 @@ describe("providers.register", () => { it("clears registrations on dispose", async () => { const { bb, harness } = createFakePluginHost(); - bb.providers.register( - agentDeclaration({ id: "my-second-agent" }), - ); + bb.providers.register(agentDeclaration({ id: "my-second-agent" })); expect( harness.registrations.providerRegistrations.map((entry) => entry.id), ).toEqual(["my-second-agent"]); await harness.dispose(); expect(harness.registrations.providerRegistrations).toEqual([]); - expect(() => - bb.providers.register(agentDeclaration()), - ).toThrow("used a stale API handle"); + expect(() => bb.providers.register(agentDeclaration())).toThrow( + "used a stale API handle", + ); }); }); diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index dcab5e66eb..055d8f01c8 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -39,6 +39,7 @@ import { type PluginPendingInteractionRegistration, type PluginProviderIconRegistration, type PluginTimelineRendererRegistration, + type ExperimentalProviderExtensionStateRegistration, type PluginRealtimeConnectionState, type PluginRpcClient, type PluginSdkApp, @@ -916,6 +917,7 @@ export interface CapturedPluginApp { messageActions: PluginMessageActionRegistration[]; providerIcons: PluginProviderIconRegistration[]; timelineRenderers: PluginTimelineRendererRegistration[]; + providerExtensionStates: ExperimentalProviderExtensionStateRegistration[]; contentScripts: PluginContentScriptRegistration[]; } diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index 1f485f3e2a..3c19a7b0e5 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -223,6 +223,10 @@ export interface ExperimentalFakeHostRpcCall { signal?: AbortSignal; } +export interface ExperimentalFakeProviderBridgeRpcCall extends ExperimentalFakeHostRpcCall { + providerId: string; +} + export type ExperimentalFakeInvocationDecision = | { allowed: true } | { allowed: false; reason: string }; @@ -273,6 +277,12 @@ export interface FakePluginInspectionState { }>; /** Calls made through bb.hosts.experimental_client, after input validation. */ readonly experimental_hostRpcCalls: readonly ExperimentalFakeHostRpcCall[]; + /** Calls made through bb.providers.experimental_client. */ + readonly experimental_providerBridgeRpcCalls: readonly ExperimentalFakeProviderBridgeRpcCall[]; + readonly experimental_providerModelChanges: readonly { + providerId: string; + hostId: string; + }[]; readonly pendingInteractions: readonly (PluginInteractionRequest & { id: string; })[]; @@ -452,6 +462,10 @@ export interface CreateFakePluginHostOptions { experimental_callHostRpc?: ( call: ExperimentalFakeHostRpcCall, ) => unknown | Promise; + /** Deterministic stand-in for a provider bridge on one host. */ + experimental_callProviderBridgeRpc?: ( + call: ExperimentalFakeProviderBridgeRpcCall, + ) => unknown | Promise; } export interface FakePluginHost { @@ -1681,6 +1695,9 @@ function createFakePluginHostInternal( const sharedPortDeclarations: FakePluginHarness["sharedPortDeclarations"] = []; const hostRpcCalls: ExperimentalFakeHostRpcCall[] = []; + const providerBridgeRpcCalls: ExperimentalFakeProviderBridgeRpcCall[] = []; + const providerModelChanges: Array<{ providerId: string; hostId: string }> = + []; const hostWorkerExitSubscriptions: FakeHostWorkerExitSubscription[] = []; const hostSignalSubscriptions: FakeHostSignalSubscription[] = []; const hosts: PluginHosts = { @@ -1855,6 +1872,65 @@ function createFakePluginHostInternal( register(declaration) { return registerProviderDeclaration(declaration); }, + experimental_client({ providerId, contract }) { + return { + async call(method, input, callOptions) { + assertLive(); + const methodContract = contract[method]; + if (methodContract === undefined) { + throw new Error(`unknown provider bridge rpc method "${method}"`); + } + // The same gates the real host applies: a plugin may only reach + // its own providers' bridges, and only on a named host. + if (!providerRegistrations.some((entry) => entry.id === providerId)) { + throw new Error( + `plugin "${pluginId}" does not own provider "${providerId}"`, + ); + } + if (typeof callOptions?.hostId !== "string" || callOptions.hostId.length === 0) { + throw new Error( + `provider bridge rpc method "${method}" requires a host id`, + ); + } + const validatedInput = await validateRpcValue( + methodContract.input, + input, + "input", + ); + const call = { + providerId, + method, + input: jsonRoundTrip(validatedInput, "provider bridge rpc input"), + hostId: callOptions.hostId, + ...(callOptions.signal === undefined + ? {} + : { signal: callOptions.signal }), + }; + providerBridgeRpcCalls.push(call); + if (options.experimental_callProviderBridgeRpc === undefined) { + throw new Error( + `fake plugin host has no experimental_callProviderBridgeRpc stub for "${String(method)}"`, + ); + } + const rawOutput = + await options.experimental_callProviderBridgeRpc(call); + return await validateRpcValue( + methodContract.output, + jsonRoundTrip(rawOutput, "provider bridge rpc output"), + "output", + ); + }, + }; + }, + experimental_modelsChanged(args) { + assertLive(); + if (!providerRegistrations.some((entry) => entry.id === args.providerId)) { + throw new Error( + `plugin "${pluginId}" does not own provider "${args.providerId}"`, + ); + } + providerModelChanges.push({ ...args }); + }, }; const bb: BbPluginApi = { @@ -1934,6 +2010,8 @@ function createFakePluginHostInternal( needsConfigurationMessages, sharedPortDeclarations, experimental_hostRpcCalls: hostRpcCalls, + experimental_providerBridgeRpcCalls: providerBridgeRpcCalls, + experimental_providerModelChanges: providerModelChanges, sdk: sdkHarness, registrations: { settingsDescriptors, diff --git a/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts b/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts index 303522aef3..ed78b8865f 100644 --- a/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts +++ b/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts @@ -58,6 +58,13 @@ export interface DecodedToolCallRequest { threadId?: string; } +export interface DecodedInteractiveCancellation { + requestId: string | number; + providerThreadId: string; + reason: string; + threadId?: string; +} + export interface DecodedInteractiveRequest { requestId: string | number; method: string; @@ -68,6 +75,8 @@ export interface DecodedInteractiveRequest { * malformed adapter output. */ turnId: string | null; + /** `thread` only for a user question that is not owned by a provider turn. */ + scope: "active_turn" | "thread"; payload: PendingInteractionPayload; threadId?: string; } diff --git a/packages/provider-bridge-protocol/src/bridge-requests.ts b/packages/provider-bridge-protocol/src/bridge-requests.ts index 886039e0b6..13f1642795 100644 --- a/packages/provider-bridge-protocol/src/bridge-requests.ts +++ b/packages/provider-bridge-protocol/src/bridge-requests.ts @@ -54,6 +54,12 @@ export const interactionRequestParamsSchema = z threadId: z.string().min(1).optional(), turnId: z.union([z.string().min(1), z.null()]), payload: pendingInteractionPayloadSchema, + /** + * Explicitly scopes a request to the thread when no provider turn exists. + * Omission preserves the established meaning of `turnId: null`: resolve + * the active turn at the runtime boundary. + */ + experimental_scope: z.literal("thread").optional(), /** * The request's turn id and approval-subject item ids are in the * provider's native id space (a `thread/delta` bridge holds no bb ids): @@ -64,7 +70,20 @@ export const interactionRequestParamsSchema = z */ providerNativeIds: z.boolean().optional(), }) - .passthrough(); + .passthrough() + .superRefine((request, context) => { + if ( + request.experimental_scope === "thread" && + (request.turnId !== null || request.payload.kind !== "user_question") + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Thread-scoped interactions must be user questions with a null turn id", + path: ["experimental_scope"], + }); + } + }); export type InteractionRequestParams = z.infer< typeof interactionRequestParamsSchema diff --git a/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json b/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json index 70a5c5588e..6d42570409 100644 --- a/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json +++ b/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json @@ -297,8 +297,11 @@ "retryable": "required" }, "requestMethods": [ + "command/list", + "extension/action", "initialize", "model/list", + "provider/custom", "provider/health", "provider/installation/run", "provider/installation/status", @@ -318,6 +321,7 @@ ], "notificationMethods": [ "error", + "interaction/cancel", "provider/raw", "provider/recovery", "session/replaced", diff --git a/packages/provider-bridge-protocol/src/index.ts b/packages/provider-bridge-protocol/src/index.ts index 8a02fdf5ac..1bf75debf4 100644 --- a/packages/provider-bridge-protocol/src/index.ts +++ b/packages/provider-bridge-protocol/src/index.ts @@ -15,6 +15,7 @@ export * from "./version.js"; export * from "./handshake.js"; export * from "./execution-options.js"; export * from "./provider-maintenance.js"; +export * from "./provider-commands.js"; export * from "./requests.js"; export * from "./notifications.js"; export * from "./bridge-requests.js"; diff --git a/packages/provider-bridge-protocol/src/notifications.ts b/packages/provider-bridge-protocol/src/notifications.ts index 517e68c9a1..6826d77b61 100644 --- a/packages/provider-bridge-protocol/src/notifications.ts +++ b/packages/provider-bridge-protocol/src/notifications.ts @@ -13,6 +13,7 @@ export const BRIDGE_NOTIFICATION_METHODS = { sessionReplaced: "session/replaced", providerRaw: "provider/raw", providerRecovery: "provider/recovery", + experimentalInteractionCancel: "interaction/cancel", error: "error", } as const; @@ -92,6 +93,18 @@ export type ProviderRecoveryNotification = z.infer< typeof providerRecoveryNotificationSchema >; +export const experimental_interactionCancelNotificationSchema = z + .object({ + requestId: z.union([z.string(), z.number()]), + providerThreadId: z.string().min(1), + threadId: z.string().min(1).optional(), + reason: z.string().min(1), + }) + .passthrough(); +export type ExperimentalInteractionCancelNotification = z.infer< + typeof experimental_interactionCancelNotificationSchema +>; + export const errorNotificationSchema = z .object({ threadId: z.string().min(1).optional(), diff --git a/packages/provider-bridge-protocol/src/provider-commands.ts b/packages/provider-bridge-protocol/src/provider-commands.ts new file mode 100644 index 0000000000..696d7799d6 --- /dev/null +++ b/packages/provider-bridge-protocol/src/provider-commands.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +/** + * Sessionless provider command discovery. The cwd is required because command + * resources can be project-scoped and must never inherit the bridge process's + * ambient working directory. + */ +export const experimental_providerCommandListParamsSchema = z + .object({ + providerId: z.string().min(1), + cwd: z.string().min(1), + providerOptions: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +export type ExperimentalProviderCommandListParams = z.infer< + typeof experimental_providerCommandListParamsSchema +>; + +export const experimental_providerCommandSchema = z + .object({ + name: z.string().min(1), + source: z.enum(["skill", "command"]), + origin: z.enum(["project", "user"]), + description: z.string().nullable(), + argumentHint: z.string().nullable(), + }) + .passthrough(); + +export type ExperimentalProviderCommand = z.infer< + typeof experimental_providerCommandSchema +>; + +/** + * Partial discovery is successful: diagnostics describe broken resources while + * commands from healthy resources remain available. + */ +export const experimental_providerCommandListResultSchema = + z.discriminatedUnion("supported", [ + z.object({ supported: z.literal(false) }).passthrough(), + z + .object({ + supported: z.literal(true), + commands: z.array(experimental_providerCommandSchema), + diagnostics: z.array(z.string()), + }) + .passthrough(), + ]); + +export type ExperimentalProviderCommandListResult = z.infer< + typeof experimental_providerCommandListResultSchema +>; diff --git a/packages/provider-bridge-protocol/src/requests.ts b/packages/provider-bridge-protocol/src/requests.ts index 3518b8e423..65a6f718c7 100644 --- a/packages/provider-bridge-protocol/src/requests.ts +++ b/packages/provider-bridge-protocol/src/requests.ts @@ -2,7 +2,9 @@ import { availableModelSchema, clientTurnRequestIdSchema, dynamicToolSchema, + extensionKindSchema, instructionModeSchema, + jsonValueSchema, promptInputSchema, } from "@bb/domain"; import { z } from "zod"; @@ -18,6 +20,8 @@ import { bridgeExecutionOptionsSchema } from "./execution-options.js"; export const BRIDGE_REQUEST_METHODS = { initialize: "initialize", modelList: "model/list", + experimentalCustomCall: "provider/custom", + experimentalProviderCommandList: "command/list", providerHealth: "provider/health", providerUsage: "provider/usage", providerInstallationStatus: "provider/installation/status", @@ -31,6 +35,7 @@ export const BRIDGE_REQUEST_METHODS = { threadArchive: "thread/archive", threadUnarchive: "thread/unarchive", threadGoalClear: "thread/goal/clear", + experimentalExtensionAction: "extension/action", turnStart: "turn/start", turnSteer: "turn/steer", skillsConfigure: "skills/configure", @@ -49,6 +54,17 @@ export const modelListParamsSchema = z .object({ cwd: z.string().min(1).optional() }) .passthrough(); +export const experimental_providerCustomCallParamsSchema = z + .object({ + method: z.string().min(1), + input: jsonValueSchema, + }) + .strict(); + +export const experimental_providerCustomCallResultSchema = z + .object({ result: jsonValueSchema }) + .strict(); + export const threadStartParamsSchema = z .object({ ...sessionConstructionFields, @@ -104,6 +120,24 @@ export const threadArchiveParamsSchema = threadRefParams; export const threadUnarchiveParamsSchema = threadRefParams; export const threadGoalClearParamsSchema = threadRefParams; +export const experimental_extensionActionParamsSchema = threadRefParams + .extend({ + extensionKind: extensionKindSchema, + action: jsonValueSchema, + }) + .passthrough(); + +export const experimental_extensionActionResultSchema = z + .object({ applied: z.boolean() }) + .passthrough(); + +export type ExperimentalExtensionActionParams = z.infer< + typeof experimental_extensionActionParamsSchema +>; +export type ExperimentalExtensionActionResult = z.infer< + typeof experimental_extensionActionResultSchema +>; + export const threadNameSetParamsSchema = z .object({ threadId: z.string().min(1), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 50b43787f6..5b7039aa80 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -17,6 +17,8 @@ import { import type { CreateQueuedMessageRequest, CreateThreadRequest, + ExperimentalExtensionStateActionRequest, + ExperimentalExtensionStateActionResponse, ExperimentalThreadReloadResponse, EditMessageRequest, EditMessageResponse, @@ -126,6 +128,8 @@ export type ThreadDeleteResult = { ok: true }; export type ThreadSendResult = SendMessageResponse; export type ThreadEditMessageResult = EditMessageResponse; export type ThreadStopResult = { ok: true }; +export type ExperimentalThreadExtensionStateActionResult = + ExperimentalExtensionStateActionResponse; export type ExperimentalThreadReloadResult = ExperimentalThreadReloadResponse; export type ThreadCompactResult = { ok: true }; export type ThreadBannerActionResult = { ok: true }; @@ -433,6 +437,11 @@ export interface ThreadTabsArea { update(args: ThreadTabsUpdateArgs): Promise; } +export interface ExperimentalThreadExtensionStateActionArgs extends ExperimentalExtensionStateActionRequest { + threadId: string; + signal?: AbortSignal; +} + export interface ThreadsArea { archive(args: ThreadActionArgs): Promise; archiveAll(args: ThreadActionArgs): Promise; @@ -448,6 +457,11 @@ export interface ThreadsArea { ): Promise; delete(args: ThreadDeleteArgs): Promise; editMessage(args: ThreadEditMessageArgs): Promise; + /** Experimental: dispatch one validated action to current provider state. */ + experimental_applyExtensionStateAction( + args: ExperimentalThreadExtensionStateActionArgs, + ): Promise; + /** Experimental: recreate an idle provider session from current config. */ experimental_reload( args: ThreadStatusArgs, ): Promise; @@ -1086,6 +1100,15 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ); return experimental_threadReloadResponseSchema.parse(body); }, + async experimental_applyExtensionStateAction(input) { + return transport.readJson( + transport.api.v1.threads[":id"]["extension-state"].action.$post({ + param: { id: input.threadId }, + json: { kind: input.kind, action: input.action }, + ...signalRequestArgs(input.signal), + }), + ); + }, async stop(input) { await transport.readVoid( transport.api.v1.threads[":id"].stop.$post({ diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 8bead7f212..107e9bb301 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -361,6 +361,7 @@ type ExpectedThreadsKey = | "delete" | "editMessage" | "events" + | "experimental_applyExtensionStateAction" | "experimental_reload" | "fork" | "get" diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts index 17ab5cec77..9eaa87e7e9 100644 --- a/packages/server-contract/src/api/projects.ts +++ b/packages/server-contract/src/api/projects.ts @@ -350,6 +350,7 @@ export function providerCommandSectionRank(cmd: { export const commandListResponseSchema = z.object({ commands: z.array(providerCommandSchema), + diagnostics: z.array(z.string()), }); export type CommandListResponse = z.infer; diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 8549103e08..9d3f7800b7 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -3,6 +3,7 @@ import { activeThinkingSchema, callerExecutionInputSourceSchema, environmentSchema, + extensionKindSchema, jsonValueSchema, pendingInteractionResolutionSchema, pendingInteractionSchema, @@ -833,6 +834,36 @@ export type ExperimentalThreadReloadResponse = z.infer< typeof experimental_threadReloadResponseSchema >; +export const experimental_extensionStateActionRequestSchema = z + .object({ + kind: extensionKindSchema, + action: jsonValueSchema, + }) + .strict(); +export type ExperimentalExtensionStateActionRequest = z.infer< + typeof experimental_extensionStateActionRequestSchema +>; + +export const experimental_extensionStateActionResponseSchema = z + .object({ applied: z.boolean() }) + .strict(); +export type ExperimentalExtensionStateActionResponse = z.infer< + typeof experimental_extensionStateActionResponseSchema +>; + +const THREAD_TIMELINE_EXTENSION_STATE_MAX = 32; + +export const threadTimelineExtensionStateSchema = z + .object({ + kind: extensionKindSchema, + payload: jsonValueSchema, + sourceSeq: z.number().int().nonnegative(), + }) + .strict(); +export type ThreadTimelineExtensionState = z.infer< + typeof threadTimelineExtensionStateSchema +>; + export const threadTimelineResponseSchema = z.object({ rows: z.array(timelineRowSchema), activePromptMode: threadTimelineActivePromptModeSchema.nullable(), @@ -843,6 +874,10 @@ export const threadTimelineResponseSchema = z.object({ pendingTodos: threadTimelinePendingTodosSchema.nullable(), goal: threadTimelineGoalSchema.nullable(), modelFallback: threadTimelineModelFallbackSchema.nullable(), + /** Latest persisted plugin state per declared kind, bounded by declaration. */ + extensionStates: z + .array(threadTimelineExtensionStateSchema) + .max(THREAD_TIMELINE_EXTENSION_STATE_MAX), contextWindowUsage: threadContextWindowUsageSchema.optional(), timelinePage: timelinePageMetadataSchema, /** Thread high-water event sequence this window reflects; bumps on append. */ diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 2aab33004f..fb056795f9 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -167,6 +167,8 @@ import type { TerminalResizeRequest, ThreadArchiveAllResponse, ThreadChildSummaryResponse, + ExperimentalExtensionStateActionRequest, + ExperimentalExtensionStateActionResponse, ExperimentalThreadReloadResponse, ThreadEventWaitQuery, ThreadEventsQuery, @@ -239,6 +241,7 @@ import { environmentDiffQuerySchema, environmentPathsQuerySchema, environmentStatusQuerySchema, + experimental_extensionStateActionRequestSchema, hostDirectoryQuerySchema, hostCloneDefaultPathQuerySchema, hostFileListRequestSchema, @@ -1081,6 +1084,14 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse(), }), + experimental_extensionStateAction: defineRoute({ + path: "/threads/:id/extension-state/action", + method: "post", + request: jsonRequest( + experimental_extensionStateActionRequestSchema, + ), + response: jsonResponse(), + }), compact: defineRoute({ path: "/threads/:id/compact", method: "post", diff --git a/packages/templates/src/templates/bb-guide-projects.md b/packages/templates/src/templates/bb-guide-projects.md index e8b892a2d9..eaa1b32b92 100644 --- a/packages/templates/src/templates/bb-guide-projects.md +++ b/packages/templates/src/templates/bb-guide-projects.md @@ -46,7 +46,8 @@ Discovery: The machine/host and environment selectors are mutually exclusive. An environment selects its owning machine and workspace; otherwise an explicit machine selects that machine's project source. Omitting both intentionally - falls back to the primary machine's project source. + falls back to the primary machine's project source. The result includes + non-fatal discovery diagnostics alongside commands that loaded successfully. Attachments: diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index 03942f8853..8a56965230 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -1,5 +1,6 @@ import type { ThreadContextWindowUsage, + ThreadTimelineExtensionState, TimelineActivityIntent, TimelineConversationAttachments, TimelineFileChange, @@ -66,6 +67,7 @@ import { type PlanCommand, } from "./active-prompt-mode-extraction.js"; import { extractThreadTimelineGoal } from "./goal-snapshot-extraction.js"; +import { extractThreadTimelineExtensionStates } from "./extension-state-extraction.js"; import { extractThreadTimelineModelFallback } from "./model-fallback-extraction.js"; import { extractThreadTimelinePendingTodos } from "./todo-snapshot-extraction.js"; import { buildTimelineErrorDisplay } from "./error-display.js"; @@ -132,6 +134,7 @@ export interface ThreadTimelineFromEventsResult { contextWindowUsage: ThreadContextWindowUsage | null; goal: ThreadTimelineGoal | null; modelFallback: ThreadTimelineModelFallback | null; + extensionStates: ThreadTimelineExtensionState[]; pendingTodos: ThreadTimelinePendingTodos | null; rows: TimelineRow[]; } @@ -1440,6 +1443,9 @@ export function buildThreadTimelineFromEvents( modelFallback: !args.options.isLatestPage ? null : extractThreadTimelineModelFallback(args.events), + extensionStates: !args.options.isLatestPage + ? [] + : extractThreadTimelineExtensionStates(args.events), pendingTodos: !args.options.isLatestPage ? null : extractThreadTimelinePendingTodos( diff --git a/packages/thread-view/src/extension-state-extraction.ts b/packages/thread-view/src/extension-state-extraction.ts new file mode 100644 index 0000000000..7e252ec294 --- /dev/null +++ b/packages/thread-view/src/extension-state-extraction.ts @@ -0,0 +1,23 @@ +import type { ThreadTimelineExtensionState } from "@bb/server-contract"; +import type { ThreadEventWithMeta } from "./build-event-projection.js"; +import { getOrderedThreadEvents } from "./group-event-projection-turns.js"; + +const THREAD_TIMELINE_EXTENSION_STATE_MAX = 32; + +/** Latest snapshot wins independently for each plugin-declared state kind. */ +export function extractThreadTimelineExtensionStates( + events: readonly ThreadEventWithMeta[], +): ThreadTimelineExtensionState[] { + const latestByKind = new Map(); + for (const { event, meta } of getOrderedThreadEvents(events)) { + if (event.type !== "thread/extensionState/updated") continue; + latestByKind.set(event.kind, { + kind: event.kind, + payload: event.payload, + sourceSeq: meta.seq, + }); + } + return [...latestByKind.values()] + .sort((left, right) => left.kind.localeCompare(right.kind)) + .slice(0, THREAD_TIMELINE_EXTENSION_STATE_MAX); +} diff --git a/packages/thread-view/src/timeline-row-title.ts b/packages/thread-view/src/timeline-row-title.ts index 1d6bdbf3d6..65702c1e45 100644 --- a/packages/thread-view/src/timeline-row-title.ts +++ b/packages/thread-view/src/timeline-row-title.ts @@ -1256,6 +1256,9 @@ function singleQuestionAnswerSummary( if (answer.freeText) { parts.push(answer.freeText); } + if (answer.experimental_verbatimText !== undefined) { + parts.push(answer.experimental_verbatimText); + } const text = parts.join(", "); return text.length > 0 ? text : null; } diff --git a/plugins/provider-codex/src/interactive-requests.test.ts b/plugins/provider-codex/src/interactive-requests.test.ts index 5a6a1a7d92..c57c19ee14 100644 --- a/plugins/provider-codex/src/interactive-requests.test.ts +++ b/plugins/provider-codex/src/interactive-requests.test.ts @@ -48,6 +48,7 @@ describe("decodeCodexInteractiveRequest", () => { method: "item/commandExecution/requestApproval", providerThreadId: "t1", turnId: "turn-1", + scope: "active_turn", payload: { kind: "approval", subject: { @@ -93,6 +94,7 @@ describe("decodeCodexInteractiveRequest", () => { method: "item/commandExecution/requestApproval", providerThreadId: "t1", turnId: "turn-1", + scope: "active_turn", payload: { kind: "approval", subject: { @@ -379,6 +381,7 @@ describe("decodeCodexInteractiveRequest", () => { method: "item/fileChange/requestApproval", providerThreadId: "t1", turnId: "turn-file-change", + scope: "active_turn", payload: { kind: "approval", subject: { @@ -417,6 +420,7 @@ describe("decodeCodexInteractiveRequest", () => { method: "item/fileChange/requestApproval", providerThreadId: "t1", turnId: "turn-file-change", + scope: "active_turn", payload: { kind: "approval", subject: { @@ -455,6 +459,7 @@ describe("decodeCodexInteractiveRequest", () => { method: "item/permissions/requestApproval", providerThreadId: "t1", turnId: "turn-permissions", + scope: "active_turn", payload: { kind: "approval", subject: { diff --git a/plugins/provider-codex/src/interactive-requests.ts b/plugins/provider-codex/src/interactive-requests.ts index a78c0a1f52..556f3ebcee 100644 --- a/plugins/provider-codex/src/interactive-requests.ts +++ b/plugins/provider-codex/src/interactive-requests.ts @@ -119,6 +119,7 @@ export function decodeCodexInteractiveRequest( method: request.method, providerThreadId: parsed.data.threadId, turnId: parsed.data.turnId, + scope: "active_turn", payload: { kind: "approval", subject: { @@ -161,6 +162,7 @@ export function decodeCodexInteractiveRequest( method: request.method, providerThreadId: parsed.data.threadId, turnId: parsed.data.turnId, + scope: "active_turn", payload: { kind: "approval", subject: { @@ -192,6 +194,7 @@ export function decodeCodexInteractiveRequest( method: request.method, providerThreadId: parsed.data.threadId, turnId: parsed.data.turnId, + scope: "active_turn", payload: { kind: "approval", subject: { diff --git a/tests/integration/fake/smoke/timeline-response.test.ts b/tests/integration/fake/smoke/timeline-response.test.ts index 64dce3699c..ef823a61d7 100644 --- a/tests/integration/fake/smoke/timeline-response.test.ts +++ b/tests/integration/fake/smoke/timeline-response.test.ts @@ -49,6 +49,7 @@ function makeTimelineResponse( activeThinking: null, activeWorkflows: [], activeBackgroundCommands: [], + extensionStates: [], pendingTodos: null, goal: null, modelFallback: null,