diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 36329b254..62e9b7440 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -294,6 +294,21 @@ active registered types appear there; plugin activation remains owned by Settings → Plugins → Manage plugins. Provider defaults are versioned device preferences, not agent-record migrations; blank per-agent values inherit them. +Code that agents need while they run, such as an agent type's runtime, tools, +wakes and Attention sources, belongs in the plugin's optional `agent` export: +`export const agent = { inject, apply(ctx) }`, with its own `inject`. `apply` +stays the plugin's UI. Both parts now start in the same page, under the same +plugin identity; the plugin is active once both are, and fails if either does. +This separation lets agents later run apart from the UI page. + +A harness plugin registers its type from its `agent` entry and presents it +from `apply`: `ctx.agents.present(typeId, (exposed) => ({ icon, Peek, Create, +CreateModel, tabs, provider: { DialogBody, DefaultControl } }))`. The type's +readers see one type with both halves. `exposed` is the type's `expose`: the +handles its UI reaches of the runtime. Each is JSON, a store (`snapshot` and +`subscribe`) or a call returning a promise, so it can later cross to another +page. Claude is the first harness split this way. + A plugin, bundled or installed, can add a tab to the settings of every agent running on this device, whatever its type, with `ctx.agents.registerTab({ id, title, component })`. The component receives @@ -353,7 +368,7 @@ plugin is replaced, so ask for it when acting rather than keeping it. Like any t Plugins can discover active Attention source registrations through `ctx.agents.attentionProviders()`. Each entry includes its `provider` key and optional `description` and JSON-object `configExample` supplied to `addAttentionSource`. The catalog omits executable callbacks and inactive registrations; discovery tools belong in external plugins. -A source may also `present` its watches on the Agent Attention page. Given a watch and its display name, it returns the watch's kind and, optionally, an icon, a target with a subtitle, an external link, and a details component that replaces the name in the expanded row. When a provider has no `present`, or it throws, Buzz shows the watch by its name. The GitHub plugin presents pull request watches this way, linking to its comment author settings. +A plugin's UI entry may also present its source's watches on the Agent Attention page with `ctx.agents.presentSource(sourceId, present)`. The source runs in the agent entry, but how its watches look is a screen concern, so it stays with the screens. Given a watch and its display name, `present` returns the watch's kind and, optionally, an icon, a target with a subtitle, an external link, and a details component that replaces the name in the expanded row. When a source has no presentation, or it throws, Buzz shows the watch by its name. The GitHub plugin presents pull request watches this way, linking to its comment author settings. Each Attention source `run` receives `spawn(processId, options)` for commands using that watch's owning agent. The host binds agent identity, watch destination and run lifetime; plugins cannot select another agent or obtain the raw authenticated connection. Declare the process in `host.processes` and request `host.mcp`, plus `host.files` for tools reading local paths. Options supply arguments, working directory, environment and output callbacks. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 98fd08707..58019a375 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -29,6 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", value: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), }, }); let services; diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index c530b1c9c..383d27eea 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -292,6 +292,7 @@ function setup( session: vi.fn(async () => {}), register: vi.fn(), addTool: vi.fn(), + present: vi.fn(), tools: () => [], create: vi.fn(async () => { // `shown: false`: it was made for a community no longer selected. @@ -373,6 +374,7 @@ function setup( }), attentionProviders: () => [], presentWatch: () => undefined, + presentSource: () => {}, addAttentionSource: vi.fn(), storage: localStore(), resumeInterrupted: vi.fn(), diff --git a/src/bundled/agents/ProviderDialog.tsx b/src/bundled/agents/ProviderDialog.tsx index 96761eb77..f480357e2 100644 --- a/src/bundled/agents/ProviderDialog.tsx +++ b/src/bundled/agents/ProviderDialog.tsx @@ -82,7 +82,7 @@ function ManagedProviderDialog({ finalFocus={() => finalFocus.current} > - + {Body && } ); diff --git a/src/bundled/claude-code/index.test.ts b/src/bundled/claude-code/index.test.ts index 7f7261618..53d9b0db5 100644 --- a/src/bundled/claude-code/index.test.ts +++ b/src/bundled/claude-code/index.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { afterEach, expect, it, vi } from "vitest"; -import type { AgentType } from "../../features/agents/service"; import { ClaudeRuntime } from "./runtime"; -import { apply } from "./index"; +import * as claude from "./index"; +import { appliedType } from "../../features/agents/plugin-testing"; vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => true })); afterEach(() => vi.restoreAllMocks()); @@ -26,18 +26,12 @@ it("observes saved sessions with the live status of running ones", () => { { key: "room/r", busy: true }, { key: "dm", busy: false }, ]); - let type: AgentType | undefined; - apply({ + const type = appliedType(claude, { pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { request: vi.fn() }, - agents: { - register: (registered: AgentType) => { - type = registered; - }, - relay: () => undefined, - }, + agents: { relay: () => undefined }, effect: vi.fn(), - } as never); + }); expect(type?.sessions?.list(agent)).toEqual([ { channelId: "room", root: "r", at: 3, status: "Working" }, { channelId: "dm", at: 2, status: "Idle" }, @@ -48,18 +42,12 @@ it("observes saved sessions with the live status of running ones", () => { it("shares a Claude agent's model as the claude runtime and imports it back", () => { vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - let type: AgentType | undefined; - apply({ + const type = appliedType(claude, { pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { request: vi.fn() }, - agents: { - register: (registered: AgentType) => { - type = registered; - }, - relay: () => undefined, - }, + agents: { relay: () => undefined }, effect: vi.fn(), - } as never); + }); const shared = type?.exportShared?.({ model: "opus", workspace: "/private" }); expect(shared).toEqual({ runtime: "claude", model: "opus" }); expect(type?.importShared?.(shared ?? { model: "" })).toMatchObject({ diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 4c4ef9160..49f413578 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -19,11 +19,65 @@ export { TYPE_ID }; /** How often Buzz's copy of Claude looks for a new release. */ const UPDATE_MS = 6 * 60 * 60_000; -export const inject = ["agents", "host"]; +// Only the desktop app can start `claude`, and setup and file uploads run +// `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. +const supported = () => isTauri() && /Mac|Linux/i.test(navigator.platform); + +/** What the type's UI reaches of its runtime. */ +export type ClaudeExposed = Parameters[0]; + +export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { - // Only the desktop app can start `claude`, and setup and file uploads run - // `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. - if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; + if (!supported()) return; + ctx.agents.present(TYPE_ID, (exposed) => { + const { + Create, + CreateModel, + DefaultControl, + ProviderDialogBody, + SettingsTab, + ActivityTab, + } = createTabs(exposed); + return { + icon: ClaudeLogoIcon, + Create, + CreateModel, + importShared: (source) => + source.runtime === "claude" && + (!source.provider || source.provider === "anthropic") + ? { ...DEFAULT_CONFIG, model: source.model } + : undefined, + exportShared: (saved) => ({ + runtime: "claude", + model: config(saved).model, + }), + provider: { DialogBody: ProviderDialogBody, DefaultControl }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + splitSettings: true, + component: SettingsTab, + }, + { + id: "activity", + title: "Activity", + role: "activity", + component: ActivityTab, + }, + ], + }; + }); +}; + +// The runtime, which runs where the agents do. +export const agent: NonNullable = { + inject: ["agents", "host"], + apply: (ctx) => applyAgent(ctx), +}; +function applyAgent(ctx: Parameters[0]) { + if (!supported()) return; const { agents, host } = ctx; const typeKey = `${ctx.pluginOwner?.id ?? "buzz.claude-code"}/${TYPE_ID}`; const spawn = host.spawn && withScript(host.spawn.bind(host)); @@ -88,37 +142,18 @@ export const apply: PluginModule["apply"] = (ctx) => { }); } - const { - Create, - CreateModel, - DefaultControl, - ProviderDialogBody, - SettingsTab, - ActivityTab, - } = createTabs({ - setup, - defaults: providerDefaults, - runtime, - }); agents.register({ id: TYPE_ID, hooks: { events: CLAUDE_HOOK_EVENTS }, title: "Claude", - icon: ClaudeLogoIcon, description: "Claude on this computer. Each thread it is mentioned in is one continuing session.", defaults: () => ({ config: DEFAULT_CONFIG }), - Create, - CreateModel, - importShared: (source) => - source.runtime === "claude" && - (!source.provider || source.provider === "anthropic") - ? { ...DEFAULT_CONFIG, model: source.model } - : undefined, - exportShared: (saved) => ({ - runtime: "claude", - model: config(saved).model, - }), + expose: { + setup, + defaults: providerDefaults, + runtime, + } satisfies ClaudeExposed, adopt: (carry, saved) => ({ ...saved, instructions: carry.instructions ?? saved.instructions, @@ -171,8 +206,6 @@ export const apply: PluginModule["apply"] = (ctx) => { ? () => void setup.run("login") : undefined; }, - DialogBody: ProviderDialogBody, - DefaultControl, }, sessions: { list: (key) => { @@ -195,22 +228,7 @@ export const apply: PluginModule["apply"] = (ctx) => { }, subscribe: (listener) => runtime.subscribe(listener), }, - tabs: [ - { - id: "settings", - title: "Settings", - role: "settings", - splitSettings: true, - component: SettingsTab, - }, - { - id: "activity", - title: "Activity", - role: "activity", - component: ActivityTab, - }, - ], // A mention during the first download waits for it, within its deadline. ...claudeRun(runtime, (signal) => setup.installed(signal)), }); -}; +} diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 2432aea68..2537bfcc7 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -38,7 +38,8 @@ import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelayData } from "../../features/relay/service"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; import { manifestProcesses } from "./claude-testing"; -import { apply, inject } from "./index"; +import * as claude from "./index"; +import { appliedType } from "../../features/agents/plugin-testing"; import { type Config, DEFAULT_CONFIG } from "./runtime"; import { ClaudeSetup } from "./setup"; import { ClaudeDefaults } from "./defaults"; @@ -145,7 +146,6 @@ function applied({ /** Whether Claude lists its models when asked. */ catalog?: { listed: boolean; ready?: Promise }; } = {}) { - let type: AgentType | undefined; const home = mkdtempSync(join(tmpdir(), "claude-tabs-")); homes.push(home); for (const [path, content] of Object.entries(files)) { @@ -256,9 +256,6 @@ function applied({ }, agents: { storage, - register: (registered: AgentType) => { - type = registered; - }, snapshot: () => ({ status: "ready", agents: [], running: [] }), relay: () => channels && @@ -272,7 +269,7 @@ function applied({ }, effect: vi.fn(), }; - apply(ctx as never); + const type = appliedType(claude, ctx); return { type, spawn, home }; } function installed(options?: Parameters[0]) { @@ -286,8 +283,9 @@ function tab(type: AgentType, index: number) { return component; } function providerSetup(type: AgentType) { - if (!type.provider) throw new Error("no provider contribution"); - return type.provider.DialogBody; + const body = type.provider?.DialogBody; + if (!body) throw new Error("no provider contribution"); + return body; } async function choose(label: string, option: string) { const user = userEvent.setup(); @@ -652,7 +650,8 @@ it("shows the channel's current kind for a session another build started", async }); it("is an agent type on the desktop app", () => { - expect(inject).toEqual(["agents", "host"]); + expect(claude.inject).toEqual(["agents"]); + expect(claude.agent.inject).toEqual(["agents", "host"]); const { type } = installed(); expect(type).toMatchObject({ id: "claude-code", diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 1bceaab5a..b24fac3ab 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -50,6 +50,29 @@ import { import type { ClaudeModel } from "./claude"; import type { ClaudeSetup } from "./setup"; import type { ClaudeDefaults } from "./defaults"; + +// What the tabs reach of the runtime: stores and calls returning promises, +// as an agent type's `expose` allows. +type SetupHandle = Pick< + ClaudeSetup, + | "snapshot" + | "subscribe" + | "check" + | "loadModels" + | "run" + | "answer" + | "cancel" +>; +type DefaultsHandle = Pick; +type ActivityHandle = Pick< + ClaudeRuntime, + | "subscribe" + | "sessions" + | "conversations" + | "find" + | "transcript" + | "channelLabel" +>; import { type ClaudeTranscript, TURN_LIMIT } from "./transcript"; const message = (error: unknown) => @@ -139,7 +162,7 @@ function ClaudeSignIn({ setup, disabled, }: { - setup: ClaudeSetup; + setup: SetupHandle; disabled: boolean; }) { const [open, setOpen] = useState(false); @@ -180,7 +203,7 @@ function ClaudeSetupPanel({ disabled = false, variant = "full", }: { - setup: ClaudeSetup; + setup: SetupHandle; disabled?: boolean; variant?: "full" | "compact"; }) { @@ -325,9 +348,9 @@ export function createTabs({ defaults, runtime, }: Readonly<{ - setup: ClaudeSetup; - defaults: ClaudeDefaults; - runtime: ClaudeRuntime; + setup: SetupHandle; + defaults: DefaultsHandle; + runtime: ActivityHandle; }>) { function useSetupCatalog() { const state = useSyncExternalStore( diff --git a/src/bundled/codex/index.test.ts b/src/bundled/codex/index.test.ts index 35f4c7366..be52d61e7 100644 --- a/src/bundled/codex/index.test.ts +++ b/src/bundled/codex/index.test.ts @@ -1,11 +1,11 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { AgentType } from "../../features/agents/service"; import { CodexLogoIcon } from "../../shared/design-system/icons"; import { CodexRuntime } from "./runtime"; import { CodexSetup } from "./setup"; import { CodexProvider } from "./tabs"; -import { apply } from "./index"; +import * as codex from "./index"; +import { appliedType } from "../../features/agents/plugin-testing"; const desktop = vi.hoisted(() => ({ tauri: true })); vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => desktop.tauri })); @@ -21,23 +21,18 @@ afterEach(() => { /** Applies the plugin to a stand-in context and returns the registered type. */ function applied() { - let type: AgentType | undefined; const subscribe = () => () => undefined; const ctx = { pluginOwner: { id: "buzz.codex", revision: "r1" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { - register: (registered: AgentType) => { - type = registered; - }, snapshot: () => ({ status: "ready", agents: [], running: [] }), relay: () => undefined, subscribe, }, effect: vi.fn(), }; - apply(ctx as never); - return type; + return appliedType(codex, ctx); } it("registers only where the desktop app can run codex and base64", () => { @@ -123,7 +118,7 @@ it("stops agents that stop running even when the agent list could not be read", const agent = { pubkey: "agent", type: "buzz.codex/codex" }; let state = { status: "ready", agents: [agent], running: [agent] }; let notify = () => undefined; - apply({ + codex.agent.apply({ pluginOwner: { id: "buzz.codex" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { @@ -187,7 +182,7 @@ it("starts the server once Codex is updated and checked, only while a Codex agen notify(); }; let cleanup: () => void = () => undefined; - apply({ + codex.agent.apply({ pluginOwner: { id: "buzz.codex" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 0d64dabb7..baca58ba4 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -9,15 +9,77 @@ import { type Config, config, defaults, validWorkspace } from "./config"; import { codexRun, TYPE_ID } from "./run"; import { CodexRuntime } from "./runtime"; import { CodexSetup, withScript } from "./setup"; -import { CodexProvider, createTabs } from "./tabs"; +import { CodexProvider, createTabs, transcripts } from "./tabs"; import { readUsage } from "./usage"; /** How often Buzz's copy of Codex looks for a new release. */ const UPDATE_MS = 6 * 60 * 60_000; -export const inject = ["agents", "host"]; +// Only the desktop app can start `codex`, and setup and file uploads run +// `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. +const supported = () => isTauri() && /Mac|Linux/i.test(navigator.platform); + +/** What the type's UI reaches of its runtime. */ +type CodexExposed = Readonly<{ + provider: Parameters[0]; + runtime: Parameters[1]; + transcript: Parameters[2]; + setup: Parameters[3]; +}>; + +export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { - // Only the desktop app can start `codex`, and setup and file uploads run - // `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. - if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; + if (!supported()) return; + ctx.agents.present( + TYPE_ID, + ({ provider, runtime, transcript, setup }) => { + const { + Create, + CreateModel, + DefaultControl, + ProviderDialogBody, + SettingsTab, + ActivityTab, + } = createTabs(provider, runtime, transcript, setup); + return { + icon: CodexLogoIcon, + Create, + CreateModel, + importShared: (source) => + source.runtime === "codex" && + (!source.provider || source.provider === "openai") + ? { ...defaults, model: source.model } + : undefined, + exportShared: (saved) => ({ + runtime: "codex", + model: config(saved).model, + }), + provider: { DialogBody: ProviderDialogBody, DefaultControl }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + splitSettings: true, + component: SettingsTab, + }, + { + id: "activity", + title: "Activity", + role: "activity", + component: ActivityTab, + }, + ], + }; + }, + ); +}; + +// The runtime, which runs where the agents do. +export const agent: NonNullable = { + inject: ["agents", "host"], + apply: (ctx) => applyAgent(ctx), +}; +function applyAgent(ctx: Parameters[0]) { + if (!supported()) return; const spawn = ctx.host.spawn && withScript(ctx.host.spawn.bind(ctx.host)); const readFile = ctx.host.readFile?.bind(ctx.host); const setup = new CodexSetup(spawn, (input) => ctx.host.request(input)); @@ -110,30 +172,19 @@ export const apply: PluginModule["apply"] = (ctx) => { runtime.dispose(); setup.dispose(); }); - const { - Create, - CreateModel, - DefaultControl, - ProviderDialogBody, - SettingsTab, - ActivityTab, - } = createTabs(providerController, runtime, start, setup); ctx.agents.register({ id: TYPE_ID, hooks: { events: CODEX_HOOK_EVENTS }, title: "Codex", - icon: CodexLogoIcon, description: "Codex on this computer, using its native coding tools and your account.", defaults: () => ({ config: { ...defaults } }), - Create, - CreateModel, - importShared: (source) => - source.runtime === "codex" && - (!source.provider || source.provider === "openai") - ? { ...defaults, model: source.model } - : undefined, - exportShared: (saved) => ({ runtime: "codex", model: config(saved).model }), + expose: { + provider: providerController, + runtime, + transcript: transcripts(start, runtime), + setup, + } satisfies CodexExposed, adopt: (carry, saved) => ({ ...saved, instructions: carry.instructions ?? saved.instructions, @@ -178,8 +229,6 @@ export const apply: PluginModule["apply"] = (ctx) => { snapshot: providerController.snapshot, subscribe: providerController.subscribe, check: () => providerController.check(), - DialogBody: ProviderDialogBody, - DefaultControl, }, sessions: { list: (key) => { @@ -200,21 +249,6 @@ export const apply: PluginModule["apply"] = (ctx) => { }, subscribe: runtime.subscribe, }, - tabs: [ - { - id: "settings", - title: "Settings", - role: "settings", - splitSettings: true, - component: SettingsTab, - }, - { - id: "activity", - title: "Activity", - role: "activity", - component: ActivityTab, - }, - ], // A mention during the first download waits for it, within its deadline. ...codexRun(runtime, async (signal) => { await setup.installed(signal); @@ -222,4 +256,4 @@ export const apply: PluginModule["apply"] = (ctx) => { await providerController.check(); }), }); -}; +} diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index dd77547db..bce2a1a65 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { schnorr } from "@noble/curves/secp256k1.js"; import { bytesToHex } from "nostr-tools/utils"; -import { apply } from "./index"; +import { agent as codexAgent } from "./index"; import { describe, afterEach, expect, it, vi } from "vitest"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; @@ -456,7 +456,7 @@ function fixture( if (plugin) { vi.stubGlobal("localStorage", local); vi.stubGlobal("navigator", { platform: "MacIntel" }); - apply({ + codexAgent.apply({ host: { spawn }, agents: { snapshot: () => ({ status: "ready", agents: [], running }), @@ -474,7 +474,7 @@ function fixture( effect: (effect: () => () => void) => { pluginDispose = effect(); }, - } as unknown as Parameters[0]); + } as unknown as Parameters[0]); } const starts = () => sent.filter((wire) => wire.method === "turn/start"); const interrupt = () => { diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 381594e5b..d0c4ee250 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -31,7 +31,7 @@ import type { RelayData } from "../../features/relay/service"; import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import { defaults, type Config } from "./config"; import { sessionTag } from "./prompt"; -import { CodexProvider, createTabs } from "./tabs"; +import { CodexProvider, createTabs, transcripts } from "./tabs"; import { CodexRuntime } from "./runtime"; import type { Wire } from "./rpc"; import { CodexSetup } from "./setup"; @@ -204,7 +204,12 @@ function fixture({ sessions, (raw) => provider.resolve(raw), ); - const tabs = createTabs(provider, runtime, spawn, setup); + const tabs = createTabs( + provider, + runtime, + transcripts(spawn, runtime), + setup, + ); const props = (settings = defaults, save = vi.fn(async () => {})) => ({ agent: { @@ -1474,7 +1479,7 @@ it("installs and signs in Buzz's copy of Codex from its provider settings", asyn const { ProviderDialogBody } = createTabs( provider, f.runtime, - f.spawn, + transcripts(f.spawn, f.runtime), f.setup, ); render(); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index a0092908e..d3dd10f2c 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -53,7 +53,12 @@ import { import { AppServer, listModels, needsSignIn, type Spawn } from "./rpc"; import type { CodexRuntime, SavedConversation } from "./runtime"; import type { CodexSetup } from "./setup"; -import { readTranscript, TURN_LIMIT, type CodexTranscript } from "./transcript"; +import { + readTranscript, + TURN_LIMIT, + type CodexTranscript, + type Lookup, +} from "./transcript"; const label = ({ channelId, name, root }: SavedConversation) => `${name || channelId.slice(0, 8)} · ${root ? `thread ${root.slice(0, 8)}` : "channel"}`; @@ -311,7 +316,7 @@ function CodexSignIn({ check, disabled, }: { - login: CodexSetup; + login: SetupHandle; step: "install" | "login"; checking: boolean; check(): void; @@ -413,11 +418,39 @@ function CodexSignIn({ ); } +// What the tabs reach of the runtime: stores and calls returning promises, +// as an agent type's `expose` allows. +type ProviderHandle = Pick< + CodexProvider, + "snapshot" | "subscribe" | "check" | "resolve" | "saveDefaults" +>; +type ActivityHandle = Pick< + CodexRuntime, + "subscribe" | "sessions" | "conversations" | "find" | "channelLabel" +>; +type SetupHandle = Pick< + CodexSetup, + "snapshot" | "subscribe" | "run" | "cancel" +>; +/** Reads one conversation's Codex thread. */ +export type TranscriptRead = ( + agent: Lookup["agent"], + conversation: SavedConversation, + signal: AbortSignal, +) => Promise; +/** Transcripts read with `spawn`, named by the runtime. */ +export const transcripts = + (spawn: Spawn, runtime: Pick): TranscriptRead => + (agent, conversation, signal) => + readTranscript(spawn, { agent, ...conversation }, signal, (pubkey) => + runtime.name(agent.key, pubkey), + ); + export function createTabs( - provider: CodexProvider, - runtime: CodexRuntime, - spawn: Spawn, - setup: CodexSetup, + provider: ProviderHandle, + runtime: ActivityHandle, + transcript: TranscriptRead, + setup: SetupHandle, ) { const login = setup; @@ -739,11 +772,10 @@ export function createTabs( useEffect(() => { const abort = new AbortController(); setLoading(true); - void readTranscript( - spawn, - { agent: { key: agent.key, name: agent.name }, ...conversation }, + void transcript( + { key: agent.key, name: agent.name }, + conversation, abort.signal, - (pubkey) => runtime.name(agent.key, pubkey), ) .then( (transcript): Read => diff --git a/src/bundled/github/SoloPull.test.tsx b/src/bundled/github/SoloPull.test.tsx index 2009b326e..ddbe3f227 100644 --- a/src/bundled/github/SoloPull.test.tsx +++ b/src/bundled/github/SoloPull.test.tsx @@ -8,7 +8,7 @@ import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; import type { AddedTool } from "../../buzz-mcp/session-tools"; import type { BuzzClient } from "../../buzz-mcp/client"; -import { apply } from "./index"; +import { agent, apply } from "./index"; import type { SoloPull, SoloPulls } from "./solo-pull"; import { SoloPullIcon, SoloPullLink, SoloPullMenu } from "./SoloPull"; @@ -161,7 +161,14 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () { id: team, channelType: "stream" }, ]); let tool: AddedTool | undefined; - const inject = vi.fn(); + const agents = { + addTool: (added: AddedTool) => { + if (added.name === "github_link_pr") tool = added; + }, + relay: () => relay, + // The agent side also lets agents follow pull requests. + addAttentionSource: () => {}, + }; apply({ host: {}, panels: { register: () => {} }, @@ -171,22 +178,15 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () registerLink: () => {}, }, relay, - agents: { - addTool: (added: AddedTool) => { - if (added.name === "github_link_pr") tool = added; - }, - relay: () => relay, - }, - inject, + agents, + inject: () => {}, } as never); + void agent.apply({ host: {}, agents, effect: () => {} } as never); const client = { key: "a".repeat(64), pubkey: "a".repeat(64), } as BuzzClient; - expect(inject).toHaveBeenCalledWith( - ["agents", "host", "navigation"], - expect.any(Function), - ); + expect(agent.inject).toEqual(["agents", "host"]); expect(tool?.name).toBe("github_link_pr"); await expect( tool?.call(client, { channel: chat }, { url: "https://github.com/block" }), diff --git a/src/bundled/github/attention.tsx b/src/bundled/github/attention.tsx index 860113aa2..efd139c0c 100644 --- a/src/bundled/github/attention.tsx +++ b/src/bundled/github/attention.tsx @@ -1,7 +1,8 @@ // How a pull request watch looks in Agent Attention. import { GitPullRequestIcon } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; -import type { AttentionSource } from "../../features/agents/attention-sources"; +import type { SourcePresentation } from "../../features/agents/attention-sources"; +import type { SourceWatch } from "../../features/agents/attention"; import type { Navigation } from "../../features/navigation/controller"; import { pullOf } from "./watch"; @@ -9,7 +10,7 @@ import { pullOf } from "./watch"; * feedback wakes agents. */ export function presentPull( navigation: Pick, -): NonNullable { +): (watch: SourceWatch, title: string) => SourcePresentation { const Authors = () => (