diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 9cd2e56be6..15e8349b74 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -299,12 +299,24 @@ 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. +from `apply`. The `agent` entry registers what runs: `defaults`, `run`, +`models`, `usage`, session state and actions, and the provider's state and +checks, setup included. `present(typeId, (runtime) => ({ ... }))` gives what +the screens show and how agents are managed: `icon`, `Peek`, `Create`, +`CreateModel`, `tabs`, `provider: { DialogBody, DefaultControl }`, `adopt`, +`importShared`, `carry`, `describeMigration`, `migrationWarning`, `summary` and +`sessionScope`. A type that runs here and registers one of those fails. + +Only JSON passes between the two entries. The type's `expose` is +`{ topics, commands }`, served to its UI: a topic is a value the UI watches, +and a command is a call that answers when done, after the values it changed. +`runtime` is the UI's link to them, with `read(topic, args)`, +`watch(topic, args, listener)` and `call(command, args)`. The screens read the +type itself, its models, usage, session state and provider state, over the +same kind of link. What arrives is a copy: a field that is `undefined` is +missing, and a call that stops waiting does not stop the runtime's work. +Hosted types, such as Builderlab's, register a whole type from the UI entry +and are unchanged. A plugin, bundled or installed, can add a tab to the settings of every agent running on this device, whatever its type, with @@ -322,6 +334,15 @@ agent, as the rest of a trusted plugin's code acts as its owner. Names are lowercase and cannot be a Buzz tool's. The tool is removed when its plugin's scope ends, so safe mode removes installed plugins' tools. +Tools belong in the `agent` entry. A tool that needs something only the +screens have, such as a sign-in or opening a panel, gets it from what the UI +entry shares: `ctx.agents.share({ topics, commands })` from `apply`, once per +plugin, and `ctx.agents.shared()` from the `agent` entry, a link with the same +`read`, `watch` and `call` as a type's `runtime`. Only JSON crosses. A topic +reads as `null` while the UI shares nothing, and a call fails. Builderlab +shares its sign-in with its Apps tools this way, and GitHub's `link_pr` tells +the sidebar of a link it wrote. + `ctx.agents.wake(pubkey, { channel, root?, text })` wakes an agent this copy of the app runs in one of its conversations, as a mention there would, with no message. `root` is the thread's root event id (64 hex); leave it out for a Solo diff --git a/src/app/navigation.test.tsx b/src/app/navigation.test.tsx index e0e5c908bb..91327fd477 100644 --- a/src/app/navigation.test.tsx +++ b/src/app/navigation.test.tsx @@ -48,6 +48,9 @@ vi.mock("../bundled", async () => ({ component: () =>

Gated card body

, }); const state = { readiness: "ready" as const }; + ctx.agents.present("provider", () => ({ + provider: { DialogBody: () =>

Gated provider controls

}, + })); ctx.agents.register({ id: "provider", title: "Gated provider", @@ -56,7 +59,6 @@ vi.mock("../bundled", async () => ({ snapshot: () => state, subscribe: () => () => {}, check() {}, - DialogBody: () =>

Gated provider controls

, }, }); }, diff --git a/src/app/navigation.ts b/src/app/navigation.ts index f587fc8d63..dd6d08333d 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -59,10 +59,13 @@ export function useAppNavigation( ? services.agents.subscribe(listener) : () => {}, () => - !!provider && - !!services.agents - ?.types() - .some((type) => type.key === provider && type.provider), + !provider || !services.agents + ? false + : services.agents.typePending(provider) + ? "pending" + : services.agents + .types() + .some((type) => type.key === provider && !!type.provider), ); const visibility = useSyncExternalStore( services.settingsCards.subscribe, @@ -176,7 +179,8 @@ export function useAppNavigation( const owner = provider.split("/")[0] ?? ""; if ( startup === "loading" || - plugins.activation[owner]?.status === "starting" + plugins.activation[owner]?.status === "starting" || + providerAvailable === "pending" ) waiting = true; else if (!providerAvailable) failure = "unavailable"; diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index fcda9c39c8..9725f802d6 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -292,6 +292,8 @@ function setup( register: vi.fn(), addTool: vi.fn(), present: vi.fn(), + share: vi.fn(), + shared: vi.fn(), tools: () => [], create: vi.fn(async () => { // `shown: false`: it was made for a community no longer selected. diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index 10718e751b..10a0e1d62b 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -8,7 +8,7 @@ import type { ConversationExtensions } from "../../features/conversation/contrac import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; import type { Navigation } from "../../features/navigation/controller"; import { BestiePage } from "./index"; -import { bestieFixture, cleanups, HOME } from "./testing"; +import { bestieFixture, cleanups, stubHome } from "./testing"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); vi.mock("@tauri-apps/api/core", () => native); @@ -28,11 +28,7 @@ beforeEach(() => { configurable: true, value: "MacIntel", }); - let next = 0; - const randomUUID = crypto.randomUUID.bind(crypto); - vi.spyOn(crypto, "randomUUID").mockImplementation(() => - next++ === 0 ? HOME : randomUUID(), - ); + stubHome(); vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(async () => { diff --git a/src/bundled/bestie/setup.test.ts b/src/bundled/bestie/setup.test.ts index d0e9552cb8..586e988c34 100644 --- a/src/bundled/bestie/setup.test.ts +++ b/src/bundled/bestie/setup.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { MAX_INSTRUCTIONS_LENGTH } from "../builderlab/agents/client"; import { bestieInstructions, promptVersion } from "./prompt"; import { BUILDERLAB_TYPE } from "./setup"; -import { bestieFixture, cleanups, HOME, MODEL } from "./testing"; +import { bestieFixture, cleanups, HOME, MODEL, stubHome } from "./testing"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); vi.mock("@tauri-apps/api/core", () => native); @@ -28,12 +28,7 @@ beforeEach(() => { }, }, }); - let next = 0; - const randomUUID = crypto.randomUUID.bind(crypto); - // The first UUID is Bestie's home; the rest stay random. - vi.spyOn(crypto, "randomUUID").mockImplementation(() => - next++ === 0 ? HOME : randomUUID(), - ); + stubHome(); // Builderlab doesn't support profile() yet; silence its warning. vi.spyOn(console, "warn").mockImplementation(() => {}); }); diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts index bb313f5261..e460790954 100644 --- a/src/bundled/bestie/testing.ts +++ b/src/bundled/bestie/testing.ts @@ -44,6 +44,18 @@ import { export const COMMUNITY = "https://community.example"; export const HOME = "11111111-1111-4111-8111-111111111111"; +let homeNext = false; +/** Makes the first UUID asked for once a fixture is ready Bestie's home; the + * rest, and those the app asks for while it starts, stay random. */ +export function stubHome() { + homeNext = false; + const randomUUID = crypto.randomUUID.bind(crypto); + vi.spyOn(crypto, "randomUUID").mockImplementation(() => { + if (!homeNext) return randomUUID(); + homeNext = false; + return HOME; + }); +} export const cleanups: (() => Promise | void)[] = []; /** The fake catalog's model and an effort it supports. */ @@ -394,6 +406,7 @@ export async function bestieFixture(invoke: Mock) { }); const provided = root.get("builderlab"); if (!provided) throw new Error("Builderlab not provided"); + homeNext = true; const raw = createRemoteBestie(provided, agents, relay); // The owner picks this model whenever setup asks; `raw` stops at the choice. const bestie: RemoteBestie = { diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 1ad082e3a0..1b1dcee154 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -23,7 +23,7 @@ export type AppsClient = ReturnType; export function createAppsClient( host: Host, - session: OAuthSession, + session: Pick, base: string, ) { // One client belongs to one tool operation, including all its HTTP steps. diff --git a/src/bundled/builderlab/apps/index.test.ts b/src/bundled/builderlab/apps/index.test.ts index 44a7cf53c3..29c6f2976b 100644 --- a/src/bundled/builderlab/apps/index.test.ts +++ b/src/bundled/builderlab/apps/index.test.ts @@ -3,7 +3,11 @@ import type { AddedTool } from "../../../buzz-mcp/session-tools"; import type { BuzzClient } from "../../../buzz-mcp/client"; import type { HostResponse, HostRequest } from "../../../features/host/service"; import { createOAuthSession } from "../oauth/session"; -import { applyApps } from "./index"; +import { + delivered, + runtimeLink, +} from "../../../features/agents/plugin-testing"; +import { applyApps, applyAppTools } from "./index"; afterEach(() => { vi.useRealTimers(); @@ -55,7 +59,9 @@ async function setup( host: { request: respond, upload: respond }, agents: { addTool: (tool: AddedTool) => tools.set(tool.name, tool) }, }; - applyApps(ctx as never, session); + // The tools reach the UI's sign-in over a link, as from another page. + applyAppTools(ctx as never, runtimeLink(applyApps(ctx as never, session))); + await delivered(); const call = (name: string, args: Record, context = {}) => { const tool = tools.get(name); if (!tool) throw new Error(`No tool ${name}`); @@ -85,6 +91,7 @@ it("preserves an accepted upload after sign-in changes and starts later tools wi await uploading; session.signOut(); await session.signIn(); + await delivered(); accept(json({ version_id: "v1", deployment_id: "d1", external_url: APP })); const result = JSON.parse(String(await pending)); expect(result).toMatchObject({ @@ -120,8 +127,8 @@ it("adds the app tools only for a tenant with an Apps control plane", async () = }); it("returns once the deployed version serves", async () => { - vi.useFakeTimers(); const { call, responses, respond } = await setup(); + vi.useFakeTimers(); responses.push( json({ ok: true, version_id: "ver-1", external_url: APP }), json({ ready: false }), @@ -201,8 +208,8 @@ it("stops polling on a terminal readiness status", async () => { }); it("uses the tenant readiness deadline instead of giving up after 90 seconds", async () => { - vi.useFakeTimers(); const { call, responses, respond } = await setup(); + vi.useFakeTimers(); responses.push( json({ version_id: "v1" }), ...Array.from({ length: 31 }, () => json({ ready: false })), diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index db7f8dfd38..86383f8827 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -5,6 +5,12 @@ import { createElement } from "react"; import type { PluginModule } from "../../../plugins/api"; import type { PanelProps } from "../../../features/panels/service"; import type { OAuthSession } from "../oauth/session"; +import type { Credential } from "../oauth/browser"; +import { + linkedStore, + type RuntimeLink, +} from "../../../features/agents/exposed"; +import type { Json, Served } from "../../../features/agents/link"; import { createAppsClient, type RuntimeProfile } from "./client"; import { appName, controlPlane, isAppUrl } from "./environment"; @@ -34,7 +40,10 @@ const DEPLOY_RECIPE = `Deploy a Builderlab app. Read apps_contract before choosi 4. Call this with the archive's absolute path; it must be in your owner's home directory. It returns the version/deployment IDs once accepted, even if readiness checking fails. Use apps_status for that exact version; do not redeploy to retry readiness. Redeploy the same app_id after source changes; a validation error names what to fix in the archive.`; -export function applyApps(ctx: Ctx, session: OAuthSession) { +/** The panel that shows a deployed app, and what the app tools need of the + * UI: the sign-in, and opening an app beside a conversation. The plugin + * shares what this returns with its agent entry. */ +export function applyApps(ctx: Ctx, session: OAuthSession): Served { // Cordis wraps service functions on access; keep React's component identity. const View = ctx.browser.View; ctx.panels.register({ @@ -52,8 +61,55 @@ export function applyApps(ctx: Ctx, session: OAuthSession) { label: appName(target), }), }); + // Each new credential counts, so the tools see a sign-in change even when + // its value is the same. + let held: Credential | undefined; + let generation = 0; + const signIn = () => { + const now = + session.snapshot().status === "signed-in" + ? session.credential() + : undefined; + if (now !== held) { + held = now; + generation += 1; + } + return { credential: now ?? null, generation }; + }; + return { + topics: { + signIn: () => ({ + get: () => signIn() as unknown as Json, + subscribe: (listener) => session.subscribe(listener), + }), + }, + commands: { + open: (args) => + ctx.panels.requestOpen( + args as unknown as Parameters[0], + ), + }, + }; +} + +/** The tools that create, deploy, share and open apps, for the agent entry, + * with what the UI shares (`applyApps`). */ +export function applyAppTools(ctx: Ctx, shared: RuntimeLink) { const base = controlPlane(); if (!base) return; + const signIn = linkedStore | null>(shared, "signIn", null); + const session: Pick = { + snapshot: () => ({ + status: signIn.snapshot()?.credential ? "signed-in" : "signed-out", + }), + credential() { + const credential = signIn.snapshot()?.credential; + if (!credential) throw new Error("Sign in to Builderlab first."); + return credential; + }, + }; const apps = () => createAppsClient(ctx.host, session, base); const reply = (value: unknown) => JSON.stringify(value, null, 2); ctx.agents.addTool({ @@ -288,7 +344,7 @@ export function applyApps(ctx: Ctx, session: OAuthSession) { if (!isAppUrl(url)) throw new Error("That is not a Builderlab app URL"); if (!context.channel) throw new Error("There is no conversation to open it beside"); - const opened = ctx.panels.requestOpen({ + const opened = await shared.call("open", { target: url, channelId: context.channel, ...(context.root ? { rootId: context.root } : {}), diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index 1115d7bb0f..f9582b204d 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -17,7 +17,7 @@ import { startKnownCommunitiesSync } from "./known-communities/sync"; import type { AgentProviderSnapshot } from "../../features/agents/service"; import { Header, InlineHeader } from "../../shared/design-system/ui/Header"; import { hostedAgentSettings } from "./service"; -import { applyApps } from "./apps"; +import { applyApps, applyAppTools } from "./apps"; export const inject = [ "host", @@ -28,6 +28,11 @@ export const inject = [ "browser", "knownCommunities", ]; +// The app tools run where agents do, with the sign-in the UI shares. +export const agent: NonNullable = { + inject: ["agents", "host"], + apply: (ctx) => applyAppTools(ctx, ctx.agents.shared()), +}; export const apply: PluginModule["apply"] = (ctx) => { let unavailable = ""; try { @@ -44,7 +49,7 @@ export const apply: PluginModule["apply"] = (ctx) => { return browserCredential(ctx.host, signal); }); ctx.effect(() => () => session.dispose()); - applyApps(ctx, session); + ctx.agents.share(applyApps(ctx, session)); const backupSession = () => { if (!nativeIdentityEnabled()) return; handoffBackupSession( diff --git a/src/bundled/claude-code/defaults.ts b/src/bundled/claude-code/defaults.ts index b0bea2945a..812861142a 100644 --- a/src/bundled/claude-code/defaults.ts +++ b/src/bundled/claude-code/defaults.ts @@ -5,7 +5,9 @@ export type ClaudeProviderDefaults = Readonly<{ model: string; effort?: string; }>; -const NATIVE_DEFAULTS: ClaudeProviderDefaults = Object.freeze({ model: "" }); +export const NATIVE_DEFAULTS: ClaudeProviderDefaults = Object.freeze({ + model: "", +}); const STORAGE_KEY = "buzz.claude-code.provider-defaults.v1"; /** Device-wide defaults are separate from agent records; a blank agent value diff --git a/src/bundled/claude-code/exposed.ts b/src/bundled/claude-code/exposed.ts new file mode 100644 index 0000000000..cd6382e346 --- /dev/null +++ b/src/bundled/claude-code/exposed.ts @@ -0,0 +1,186 @@ +// What Claude's UI reaches of its runtime: the runtime serves it, and the UI +// reaches it over the link to the runtime, with the same shape as the +// runtime's own handles. +import { + abortable, + linkedStore, + linkedView, + storeTopic, + viewTopic, + type RuntimeLink, +} from "../../features/agents/exposed"; +import type { Json, Served } from "../../features/agents/link"; +import type { AgentKey } from "../../features/agents/membership"; +import { + NATIVE_DEFAULTS, + type ClaudeDefaults, + type ClaudeProviderDefaults, +} from "./defaults"; +import type { ClaudeRuntime, SavedConversation } from "./runtime"; +import { SETUP_START, type ClaudeSetup, type SetupState } from "./setup"; +import type { createTabs } from "./tabs"; +import type { Conversation } from "./transcript"; + +/** What the type's UI reaches of its runtime. */ +export type ClaudeExposed = Parameters[0]; +type Place = Readonly<{ channelId: string; root?: string }>; +type Transcript = Awaited>; +/** A channel's label, as one agent reads it. */ +type LabelKey = Readonly<{ agent: AgentKey; channelId: string }>; +const NONE: readonly never[] = Object.freeze([]); +const done = () => null; +// The UI stops waiting by itself; reading changes nothing, so it finishes. +const unstopped = () => new AbortController().signal; + +/** The runtime's side. */ +export function serveClaude( + setup: ClaudeSetup, + defaults: ClaudeDefaults, + runtime: ClaudeRuntime, +): Served { + const changed = (listener: () => void) => runtime.subscribe(listener); + return { + topics: { + setup: storeTopic(setup), + defaults: storeTopic(defaults), + sessions: viewTopic(changed, (key: AgentKey) => runtime.sessions(key)), + conversations: viewTopic(changed, (key: AgentKey) => + runtime.conversations(key), + ), + // Told again when its channel list or the runtime changes. + label: (args) => { + const { agent, channelId } = args as LabelKey; + return { + get: () => + (runtime.channelLabel(agent, channelId) ?? null) as unknown as Json, + subscribe(listener) { + const stops = [ + changed(listener), + runtime.subscribeChannels(agent, listener), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, + }; + }, + }, + commands: { + check: (refresh) => setup.check(refresh === true).then(done), + loadModels: (retry) => setup.loadModels(retry === true).then(done), + run: (args) => { + const { step, asked } = args as Readonly<{ + step: "install" | "login"; + asked: boolean; + }>; + return setup.run(step, asked).then(done); + }, + answer: async (text) => { + await setup.answer(text as string); + return null; + }, + cancel: async () => { + await setup.cancel(); + return null; + }, + saveDefaults: (next) => + defaults.save(next as ClaudeProviderDefaults).then(done), + find: async (args) => { + const { key, place, workspace } = args as Readonly<{ + key: AgentKey; + place: Place; + workspace: string; + }>; + const found = await runtime.find(key, place, workspace, unstopped()); + return (found ?? null) as Json; + }, + transcript: async (args) => { + const { conversation, workspace, working } = args as Readonly<{ + conversation: Conversation; + workspace: string; + working: boolean; + }>; + const read = await runtime.transcript( + conversation, + workspace, + working, + unstopped(), + ); + return (read ?? null) as unknown as Json; + }, + }, + }; +} + +/** The UI's side: the runtime's handles, reached over `link`. */ +export function linkClaude(link: RuntimeLink): ClaudeExposed { + const setup = linkedStore(link, "setup", SETUP_START); + const defaults = linkedStore( + link, + "defaults", + NATIVE_DEFAULTS, + ); + const sessions = linkedView>( + link, + "sessions", + [], + ); + const conversations = linkedView( + link, + "conversations", + NONE, + ); + const labels = linkedView< + LabelKey, + ReturnType + >(link, "label", undefined); + const call = async (command: string, args: unknown = null) => { + await link.call(command, args as Json); + }; + return { + setup: { + snapshot: setup.snapshot, + subscribe: setup.subscribe, + check: (refresh = false) => call("check", refresh), + loadModels: (retry = false) => call("loadModels", retry), + run: (step, asked = true) => call("run", { step, asked }), + answer: (text) => call("answer", text), + cancel: () => call("cancel"), + }, + defaults: { + snapshot: defaults.snapshot, + subscribe: defaults.subscribe, + save: (next) => call("saveDefaults", next), + }, + runtime: { + subscribe(listener) { + const stops = [ + sessions.subscribe(listener), + conversations.subscribe(listener), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, + sessions: (key) => sessions.read(key), + conversations: (key) => conversations.read(key), + loaded: (key) => conversations.loaded(key), + channelLabel: (agent, channelId) => labels.read({ agent, channelId }), + subscribeChannels: (_agent, listener) => labels.subscribe(listener), + find: async (key, place, workspace, signal) => + ((await abortable( + link.call("find", { key, place, workspace } as unknown as Json), + signal, + )) ?? undefined) as SavedConversation | undefined, + transcript: async (conversation, workspace, working, signal) => + ((await abortable( + link.call("transcript", { + conversation, + workspace, + working, + } as unknown as Json), + signal, + )) ?? undefined) as Transcript, + }, + }; +} diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 49f4135780..d82be29cb8 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -12,6 +12,7 @@ import { claudeRun, TYPE_ID } from "./run"; import { ClaudeDefaults } from "./defaults"; import { ClaudeRuntime, type Config, config, DEFAULT_CONFIG } from "./runtime"; import { ClaudeSetup, withScript } from "./setup"; +import { linkClaude, serveClaude } from "./exposed"; import { createTabs } from "./tabs"; import { readUsage } from "./usage"; @@ -23,13 +24,10 @@ const UPDATE_MS = 6 * 60 * 60_000; // `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) => { if (!supported()) return; - ctx.agents.present(TYPE_ID, (exposed) => { + ctx.agents.present(TYPE_ID, (runtime) => { const { Create, CreateModel, @@ -37,7 +35,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ProviderDialogBody, SettingsTab, ActivityTab, - } = createTabs(exposed); + } = createTabs(linkClaude(runtime)); return { icon: ClaudeLogoIcon, Create, @@ -67,6 +65,33 @@ export const apply: PluginModule["apply"] = (ctx) => { component: ActivityTab, }, ], + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + describeMigration: (saved) => { + const { model, effort, scope } = config(saved); + return [ + { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, + { id: "thinking", label: "Thinking", isOverride: Boolean(effort) }, + { + id: "sessions", + label: "Sessions", + isOverride: scope !== DEFAULT_CONFIG.scope, + unchangedKey: scope, + }, + ]; + }, + summary: (agent) => config(agent.config).model || "Claude", + sessionScope: (agent) => config(agent.config).scope, }; }); }; @@ -149,37 +174,7 @@ function applyAgent(ctx: Parameters[0]) { description: "Claude on this computer. Each thread it is mentioned in is one continuing session.", defaults: () => ({ config: DEFAULT_CONFIG }), - expose: { - setup, - defaults: providerDefaults, - runtime, - } satisfies ClaudeExposed, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - workspace: carry.workspace ?? saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), - }; - }, - describeMigration: (saved) => { - const { model, effort, scope } = config(saved); - return [ - { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, - { id: "thinking", label: "Thinking", isOverride: Boolean(effort) }, - { - id: "sessions", - label: "Sessions", - isOverride: scope !== DEFAULT_CONFIG.scope, - unchangedKey: scope, - }, - ]; - }, - summary: (agent) => config(agent.config).model || "Claude", + expose: serveClaude(setup, providerDefaults, runtime), ...(spawn && readFile && { usage: (agent, { since, signal }) => diff --git a/src/bundled/claude-code/run.ts b/src/bundled/claude-code/run.ts index 1c4154e79e..0c54ec303a 100644 --- a/src/bundled/claude-code/run.ts +++ b/src/bundled/claude-code/run.ts @@ -2,7 +2,7 @@ // desktop app registers it with its tabs, and a standalone host runs it alone. import { syncedSettingsSchema, withSyncedSettings } from "./settings"; import type { AgentType } from "../../features/agents/service"; -import { type ClaudeRuntime, type Config, config } from "./runtime"; +import type { ClaudeRuntime, Config } from "./runtime"; export const TYPE_ID = "claude-code"; /** A turn's run returns once Claude has it, so this bounds only the hand-over. */ @@ -25,7 +25,6 @@ export const claudeRun = ( > => ({ models: (agent) => runtime.models(agent.key), sessionControls: { - scope: (agent) => config(agent.config).scope, state: (agent, place) => runtime.sessionState(agent, place), run: (agent, place, action) => runtime.session(agent, place, action), subscribe: (listener) => runtime.subscribe(listener), diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index db78013f5d..010866078c 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -367,6 +367,11 @@ export class ClaudeRuntime { channelLabel(agent: AgentKey, channelId: string) { return this.reads(agent)?.channelLabel(channelId); } + /** Tells `listener` when the channel list `channelLabel` reads now + * changes. */ + subscribeChannels(agent: AgentKey, listener: () => void) { + return this.reads(agent)?.subscribeChannels(listener) ?? (() => {}); + } subscribe(listener: () => void) { this.listeners.add(listener); return () => { diff --git a/src/bundled/claude-code/setup.ts b/src/bundled/claude-code/setup.ts index 1370c92857..89cf84f536 100644 --- a/src/bundled/claude-code/setup.ts +++ b/src/bundled/claude-code/setup.ts @@ -215,18 +215,21 @@ export type SetupState = Readonly<{ }>; const OUTPUT_LIMIT = 20_000; +/** What setup knows before its first check. */ +export const SETUP_START: SetupState = Object.freeze({ + status: undefined, + models: [], + modelsChecking: false, + modelsError: "", + checking: false, + running: undefined, + output: "", + notice: undefined, +}); + /** The plugin's one view of Claude on this computer. */ export class ClaudeSetup { - private state: SetupState = { - status: undefined, - models: [], - modelsChecking: false, - modelsError: "", - checking: false, - running: undefined, - output: "", - notice: undefined, - }; + private state: SetupState = SETUP_START; private readonly listeners = new Set<() => void>(); private health: AgentProviderSnapshot = { readiness: "checking" }; private process: HostProcess | undefined; diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 3f981a9032..4f9f16b360 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -146,6 +146,7 @@ function applied({ /** Whether Claude lists its models when asked. */ catalog?: { listed: boolean; ready?: Promise }; } = {}) { + const relisted = new Set<() => void>(); const home = mkdtempSync(join(tmpdir(), "claude-tabs-")); homes.push(home); for (const [path, content] of Object.entries(files)) { @@ -262,7 +263,15 @@ function applied({ ({ snapshot: () => ({ status: "ready", - session: { channels: { list: () => ({ channels }) } }, + session: { + channels: { + list: () => ({ channels }), + subscribeList: (listener: () => void) => { + relisted.add(listener); + return () => relisted.delete(listener); + }, + }, + }, }), } as unknown as RelayData), subscribe: () => () => undefined, @@ -270,12 +279,16 @@ function applied({ effect: vi.fn(), }; const type = appliedType(claude, ctx); - return { type, spawn, home }; + /** The community lists its channels anew, as `channels` has them now. */ + const relist = () => { + for (const listener of [...relisted]) listener(); + }; + return { type, spawn, home, relist }; } function installed(options?: Parameters[0]) { - const { type, spawn, home } = applied(options); + const { type, spawn, home, relist } = applied(options); if (!type) throw new Error("no type registered"); - return { type, spawn, home }; + return { type, spawn, home, relist }; } function tab(type: AgentType, index: number) { const component = type.tabs?.[index]?.component; @@ -293,7 +306,7 @@ async function choose(label: string, option: string) { await user.click(await screen.findByRole("option", { name: option })); } -it("shows each saved conversation's channel with the sidebar's icon", () => { +it("shows each saved conversation's channel with the sidebar's icon", async () => { localStorage.setItem( "buzz.claude-code.sessions.v1", JSON.stringify({ @@ -322,14 +335,17 @@ it("shows each saved conversation's channel with the sidebar's icon", () => { const icon = name.querySelector("svg")?.getAttribute("class"); return `${icon?.match(/tabler-icon-[\w-]+/)?.[0]} ${name.textContent}`; }; - expect( - screen.getAllByRole("listitem").map((row) => shown(row as HTMLElement)), - ).toEqual([ - "tabler-icon-hash general", - "tabler-icon-lock plans", - "tabler-icon-message-circle Pinky", - "tabler-icon-message-circle Notes", - ]); + // The conversations and their channels come from the runtime, over the link. + await waitFor(() => + expect( + screen.getAllByRole("listitem").map((row) => shown(row as HTMLElement)), + ).toEqual([ + "tabler-icon-hash general", + "tabler-icon-lock plans", + "tabler-icon-message-circle Pinky", + "tabler-icon-message-circle Notes", + ]), + ); expect( screen.getByRole("button", { name: "View transcript: plans · channel" }), ).toBeVisible(); @@ -370,7 +386,8 @@ it("lists saved conversations and shows one's transcript, or that it is gone", a const save = vi.fn(); render(); const user = userEvent.setup(); - const rows = screen.getAllByRole("listitem"); + // The saved conversations come from the runtime, over the link. + const rows = await screen.findAllByRole("listitem"); expect( within(rows[0] as HTMLElement).getByRole("button", { name: "View transcript: channel · thread eeeeeeee", @@ -484,9 +501,11 @@ it("shows the transcript and that sessions may be missing when some history cann render(); const user = userEvent.setup(); await user.click( - screen.getAllByRole("button", { - name: /^View transcript/, - })[0] as HTMLElement, + ( + await screen.findAllByRole("button", { + name: /^View transcript/, + }) + )[0] as HTMLElement, ); expect(await screen.findByText("what changed?")).toBeVisible(); expect( @@ -495,7 +514,7 @@ it("shows the transcript and that sessions may be missing when some history cann expect(screen.queryByRole("alert")).toBeNull(); }); -it("sets the conversation the profile was opened from apart from recent conversations", () => { +it("sets the conversation the profile was opened from apart from recent conversations", async () => { const root = "e".repeat(64); localStorage.setItem( "buzz.claude-code.sessions.v1", @@ -514,9 +533,9 @@ it("sets the conversation the profile was opened from apart from recent conversa conversation={{ channelId: "channel", rootId: root }} />, ); - const opened = screen.getByRole("region", { name: "This thread" }); + const opened = await screen.findByRole("region", { name: "This thread" }); expect( - within(opened).getByRole("button", { name: /^View transcript/ }), + await within(opened).findByRole("button", { name: /^View transcript/ }), ).toBeVisible(); expect(screen.getAllByRole("listitem")).toHaveLength(1); expect( @@ -581,7 +600,7 @@ it("shows the channel's current kind for a session another build started", async const channels: ChannelSummary[] = [ { id: "channel", name: "dev", channelType: "stream" }, ]; - const { type, spawn } = installed({ + const { type, spawn, relist } = installed({ files: { "./-Users-me--buzz/elsewhere.jsonl": started }, channels, }); @@ -595,10 +614,13 @@ it("shows the channel's current kind for a session another build started", async /> ); const view = render(shown()); - const here = screen.getByRole("region", { name: "This thread" }); + const here = await screen.findByRole("region", { name: "This thread" }); await within(here).findByRole("button", { name: /^View transcript/ }); - expect(within(here).getByTitle("dev").querySelector("svg")).toHaveClass( - "tabler-icon-hash", + // The channel's label comes from the runtime too. + await waitFor(() => + expect(within(here).getByTitle("dev").querySelector("svg")).toHaveClass( + "tabler-icon-hash", + ), ); const searched = spawn.mock.calls.filter(([id]) => id === "starts").length; @@ -608,9 +630,12 @@ it("shows the channel's current kind for a session another build started", async channelType: "stream", private: true, }; + relist(); view.rerender(shown()); - expect(within(here).getByTitle("plans").querySelector("svg")).toHaveClass( - "tabler-icon-lock", + await waitFor(() => + expect(within(here).getByTitle("plans").querySelector("svg")).toHaveClass( + "tabler-icon-lock", + ), ); expect(spawn.mock.calls.filter(([id]) => id === "starts").length).toBe( searched, @@ -834,8 +859,10 @@ it("replaces generic installation copy with the latest installer state", async ( const installedType = installed({ available: false }).type; const Setup = providerSetup(installedType); render(); - expect(await screen.findByRole("status")).toHaveTextContent( - "Claude is not installed.", + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Claude is not installed.", + ), ); await userEvent .setup() @@ -1152,15 +1179,16 @@ it("does not open a competing Claude sign-in or let it cancel the owner", async if (!buttons[0] || !buttons[1]) throw new Error("Missing sign-in"); fireEvent.click(buttons[0]); fireEvent.click(buttons[1]); - expect( + const logins = () => spawn.mock.calls.filter( ([, options]) => options?.args?.slice(-2).join(" ") === "auth login", - ), - ).toHaveLength(1); + ); + await waitFor(() => expect(logins()).toHaveLength(1)); + await waitFor(() => expect(buttons[1]).toBeDisabled()); + expect(logins()).toHaveLength(1); expect( screen.getAllByRole("dialog", { name: "Sign in to Claude" }), ).toHaveLength(1); - expect(buttons[1]).toBeDisabled(); }); it("offers an update before discovering models on a pre-safe-mode Claude copy", async () => { @@ -1368,7 +1396,9 @@ it("saves model-specific default effort from the nested menu and inherits it", a fireEvent.click( await screen.findByRole("menuitem", { name: "Use Claude default" }), ); - expect(new ClaudeDefaults(localStore()).snapshot().model).toBe(""); + await waitFor(() => + expect(new ClaudeDefaults(localStore()).snapshot().model).toBe(""), + ); }); it("retries failed model discovery from the defaults row", async () => { @@ -1401,10 +1431,12 @@ it("keeps the model inherited when changing the default model's effort", async ( await screen.findByRole("menuitemradio", { name: "Opus 5.5" }), ); fireEvent.click(await screen.findByRole("menuitemradio", { name: "High" })); - expect(new ClaudeDefaults(localStore()).snapshot()).toMatchObject({ - model: "", - effort: "high", - }); + await waitFor(() => + expect(new ClaudeDefaults(localStore()).snapshot()).toMatchObject({ + model: "", + effort: "high", + }), + ); expect(control).toHaveTextContent("· Default"); }); @@ -1458,7 +1490,7 @@ it("keeps keyboard focus through Claude defaults discovery retries", async () => ).toHaveFocus(); }); -it("keeps same-channel recent threads visibly distinguishable", () => { +it("keeps same-channel recent threads visibly distinguishable", async () => { localStorage.setItem( "buzz.claude-code.sessions.v1", JSON.stringify({ @@ -1472,7 +1504,7 @@ it("keeps same-channel recent threads visibly distinguishable", () => { ); const Activity = tab(installed().type, 1); render(); - const rows = screen.getAllByRole("listitem"); + const rows = await screen.findAllByRole("listitem"); expect(rows).toHaveLength(2); expect(rows.map((row) => row.textContent)).toEqual( expect.arrayContaining([ diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index a574096b69..e9d2bc1d94 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -11,6 +11,7 @@ import { ProviderDialogActions } from "../../features/agents/ProviderDialogActio import { useEffect, useRef, + useReducer, useId, useState, useSyncExternalStore, @@ -68,11 +69,18 @@ type ActivityHandle = Pick< ClaudeRuntime, | "subscribe" | "sessions" - | "conversations" | "find" | "transcript" | "channelLabel" ->; + | "subscribeChannels" +> & + Readonly<{ + /** The same list until `subscribe` tells of a change. */ + conversations(agent: string): readonly SavedConversation[]; + /** Whether the runtime's list for the agent has reached this page yet; + * always, without it. */ + loaded?(agent: string): boolean; + }>; import { type ClaudeTranscript, TURN_LIMIT } from "./transcript"; const message = (error: unknown) => @@ -740,17 +748,30 @@ export function createTabs({ (listener) => runtime.subscribe(listener), () => runtime.sessions(agent.key), ); - const conversations = runtime.conversations(agent.key); + const conversations = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.conversations(agent.key), + ); + // Until its saved conversations arrive, it neither searches other builds' + // history nor offers the transcript asked for, which is one of them. + const loaded = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.loaded?.(agent.key) ?? true, + ); const busy = new Map( sessions.map((session) => [session.key, session.busy]), ); + // A session found in another build's history shows its channel's name + // and kind as the community has them now. + const [, relabel] = useReducer((count: number) => count + 1, 0); + useEffect(() => runtime.subscribeChannels(agent.key, relabel), [agent.key]); const workspace = readConfig(agent.config).workspace; const { session: here, searching, error: lookupError, } = useOpenedSession( - conversations, + loaded ? conversations : undefined, conversation, (place, signal) => runtime.find(agent.key, place, workspace, signal), (channelId) => runtime.channelLabel(agent.key, channelId), @@ -765,7 +786,9 @@ export function createTabs({ const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript && { initial: here, ...initialTranscript }, + loaded && initialTranscript + ? { initial: here, ...initialTranscript } + : undefined, close, ); return ( diff --git a/src/bundled/codex/exposed.ts b/src/bundled/codex/exposed.ts new file mode 100644 index 0000000000..161d7162ab --- /dev/null +++ b/src/bundled/codex/exposed.ts @@ -0,0 +1,184 @@ +// What Codex's UI reaches of its runtime: the runtime serves it, and the UI +// reaches it over the link to the runtime, with the same shape as the +// runtime's own handles. +import { + abortable, + linkedStore, + linkedView, + storeTopic, + viewTopic, + type RuntimeLink, +} from "../../features/agents/exposed"; +import type { Json, Served } from "../../features/agents/link"; +import type { AgentKey } from "../../features/agents/membership"; +import type { CodexRuntime, SavedConversation } from "./runtime"; +import { type CodexSetup, SETUP_START, type SetupState } from "./setup"; +import { + type CodexProvider, + type createTabs, + type ProviderDefaults, + type ProviderState, + resolveConfig, + type TranscriptRead, +} from "./tabs"; + +type Tabs = Parameters; +/** What the type's UI reaches of its runtime. */ +export type CodexExposed = Readonly<{ + provider: Tabs[0]; + runtime: Tabs[1]; + transcript: Tabs[2]; + setup: Tabs[3]; +}>; +type Agent = Parameters[0]; +type Place = Readonly<{ channelId: string; root?: string }>; +/** A channel's label, as one agent reads it. */ +type LabelKey = Readonly<{ agent: AgentKey; channelId: string }>; +const NONE: readonly never[] = Object.freeze([]); +// Checking from the start, so the UI waits for the runtime's own check. +const CHECKING: ProviderState = Object.freeze({ + readiness: "checking", + checking: true, + data: undefined, + error: "", + defaults: Object.freeze({ model: "", effort: "" }), +}); +const done = () => null; +// The UI stops waiting by itself; reading changes nothing, so it finishes. +const unstopped = () => new AbortController().signal; + +/** The runtime's side. */ +export function serveCodex( + provider: CodexProvider, + setup: CodexSetup, + runtime: CodexRuntime, + transcript: TranscriptRead, +): Served { + return { + topics: { + provider: storeTopic(provider), + setup: storeTopic(setup), + sessions: viewTopic(runtime.subscribe, (key: AgentKey) => + runtime.sessions(key), + ), + conversations: viewTopic(runtime.subscribe, (key: AgentKey) => + runtime.conversations(key), + ), + // Told again when its channel list or the runtime changes. + label: (args) => { + const { agent, channelId } = args as LabelKey; + return { + get: () => + (runtime.channelLabel(agent, channelId) ?? null) as unknown as Json, + subscribe(listener) { + const stops = [ + runtime.subscribe(listener), + runtime.subscribeChannels(agent, listener), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, + }; + }, + }, + commands: { + check: () => provider.check().then(done), + saveDefaults: (next) => + provider.saveDefaults(next as ProviderDefaults).then(done), + run: (args) => { + const { step, asked } = args as Readonly<{ + step: "install" | "login"; + asked: boolean; + }>; + return setup.run(step, asked).then(done); + }, + cancel: async () => { + await setup.cancel(); + return null; + }, + find: async (args) => { + const { agent, place } = args as Readonly<{ + agent: Agent; + place: Place; + }>; + const found = await runtime.find(agent, place, unstopped()); + return (found ?? null) as Json; + }, + transcript: async (args) => { + const { agent, conversation } = args as Readonly<{ + agent: Agent; + conversation: SavedConversation; + }>; + const read = await transcript(agent, conversation, unstopped()); + return (read ?? null) as unknown as Json; + }, + }, + }; +} + +/** The UI's side: the runtime's handles, reached over `link`. */ +export function linkCodex(link: RuntimeLink): CodexExposed { + const provider = linkedStore(link, "provider", CHECKING); + const setup = linkedStore(link, "setup", SETUP_START); + const sessions = linkedView>( + link, + "sessions", + NONE, + ); + const conversations = linkedView( + link, + "conversations", + NONE, + ); + const labels = linkedView>( + link, + "label", + undefined, + ); + const call = async (command: string, args: unknown = null) => { + await link.call(command, args as Json); + }; + return { + provider: { + snapshot: provider.snapshot, + subscribe: provider.subscribe, + check: () => call("check"), + saveDefaults: (next) => call("saveDefaults", next), + // From the provider's state the UI has, as the runtime resolves it. + resolve: (raw) => resolveConfig(provider.snapshot(), raw), + }, + runtime: { + subscribe(listener) { + const stops = [ + sessions.subscribe(listener), + conversations.subscribe(listener), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, + sessions: (key) => sessions.read(key), + conversations: (key) => conversations.read(key), + loaded: (key) => conversations.loaded(key), + channelLabel: (agent, channelId) => labels.read({ agent, channelId }), + subscribeChannels: (_agent, listener) => labels.subscribe(listener), + find: async (agent, place, signal) => + ((await abortable( + link.call("find", { agent, place } as unknown as Json), + signal, + )) ?? undefined) as SavedConversation | undefined, + }, + transcript: async (agent, conversation, signal) => + ((await abortable( + link.call("transcript", { agent, conversation } as unknown as Json), + signal, + )) ?? undefined) as Awaited>, + setup: { + snapshot: setup.snapshot, + subscribe: setup.subscribe, + run: (step, asked = true) => call("run", { step, asked }), + cancel: () => call("cancel"), + }, + }; +} diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index baca58ba4c..2b979e7104 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -9,6 +9,7 @@ import { type Config, config, defaults, validWorkspace } from "./config"; import { codexRun, TYPE_ID } from "./run"; import { CodexRuntime } from "./runtime"; import { CodexSetup, withScript } from "./setup"; +import { linkCodex, serveCodex } from "./exposed"; import { CodexProvider, createTabs, transcripts } from "./tabs"; import { readUsage } from "./usage"; /** How often Buzz's copy of Codex looks for a new release. */ @@ -17,60 +18,86 @@ const UPDATE_MS = 6 * 60 * 60_000; // `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) => { 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: [ + ctx.agents.present(TYPE_ID, (link) => { + const { provider, runtime, transcript, setup } = linkCodex(link); + const { + Create, + CreateModel, + DefaultControl, + ProviderDialogBody, + SettingsTab, + ActivityTab, + } = createTabs(provider, runtime, transcript, setup); + return { + icon: CodexLogoIcon, + Create, + CreateModel, + provider: { DialogBody: ProviderDialogBody, DefaultControl }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + splitSettings: true, + component: SettingsTab, + }, + { + id: "activity", + title: "Activity", + role: "activity", + component: ActivityTab, + }, + ], + importShared: (source) => + source.runtime === "codex" && + (!source.provider || source.provider === "openai") + ? { ...defaults, model: source.model } + : undefined, + exportShared: (saved) => ({ + runtime: "codex", + model: config(saved).model, + }), + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + // Codex runs only in an absolute or home workspace. + workspace: + carry.workspace && validWorkspace(carry.workspace) + ? carry.workspace + : saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== defaults.workspace && { workspace }), + }; + }, + describeMigration: (saved) => { + const { model, effort, scope } = config(saved); + return [ + { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, { - id: "settings", - title: "Settings", - role: "settings", - splitSettings: true, - component: SettingsTab, + id: "thinking", + label: "Thinking", + isOverride: Boolean(effort), }, { - id: "activity", - title: "Activity", - role: "activity", - component: ActivityTab, + id: "sessions", + label: "Sessions", + isOverride: scope !== defaults.scope, + unchangedKey: scope, }, - ], - }; - }, - ); + ]; + }, + migrationWarning: "Any commands Codex left running will stop.", + summary: (agent) => config(agent.config).model || "Codex", + sessionScope: (agent) => config(agent.config).scope, + }; + }); }; // The runtime, which runs where the agents do. @@ -179,47 +206,12 @@ function applyAgent(ctx: Parameters[0]) { description: "Codex on this computer, using its native coding tools and your account.", defaults: () => ({ config: { ...defaults } }), - expose: { - provider: providerController, - runtime, - transcript: transcripts(start, runtime), + expose: serveCodex( + providerController, setup, - } satisfies CodexExposed, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - // Codex runs only in an absolute or home workspace. - workspace: - carry.workspace && validWorkspace(carry.workspace) - ? carry.workspace - : saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== defaults.workspace && { workspace }), - }; - }, - describeMigration: (saved) => { - const { model, effort, scope } = config(saved); - return [ - { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, - { - id: "thinking", - label: "Thinking", - isOverride: Boolean(effort), - }, - { - id: "sessions", - label: "Sessions", - isOverride: scope !== defaults.scope, - unchangedKey: scope, - }, - ]; - }, - migrationWarning: "Any commands Codex left running will stop.", - summary: (agent) => config(agent.config).model || "Codex", + runtime, + transcripts(start, runtime), + ), ...(spawn && readFile && { usage: (agent, { since, signal }) => diff --git a/src/bundled/codex/run.ts b/src/bundled/codex/run.ts index 85cfb70515..33c16def5a 100644 --- a/src/bundled/codex/run.ts +++ b/src/bundled/codex/run.ts @@ -2,7 +2,7 @@ // desktop app registers it with its tabs, and a standalone host runs it alone. import { syncedSettingsSchema, withSyncedSettings } from "./settings"; import type { AgentType } from "../../features/agents/service"; -import { type Config, config } from "./config"; +import type { Config } from "./config"; import type { CodexRuntime } from "./runtime"; export const TYPE_ID = "codex"; @@ -27,7 +27,6 @@ export const codexRun = ( > => ({ models: (agent) => runtime.preview(agent), sessionControls: { - scope: (agent) => config(agent.config).scope, state: (agent, place) => runtime.sessionState(agent, place), run: (agent, place, action) => runtime.session(agent, place, action), subscribe: (listener) => runtime.subscribe(listener), diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index ad12fe5b5f..a406905c29 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -648,6 +648,11 @@ export class CodexRuntime { channelLabel(agent: AgentKey, channelId: string) { return this.reads(agent)?.channelLabel(channelId); } + /** Tells `listener` when the channel list `channelLabel` reads now + * changes. */ + subscribeChannels(agent: AgentKey, listener: () => void) { + return this.reads(agent)?.subscribeChannels(listener) ?? (() => {}); + } private storageKey(scope: string, agent: AgentKey) { // Resume restores the persisted tool schema, so a binding from before a // tool change starts fresh. v5 added channel and member management; v6 diff --git a/src/bundled/codex/setup.ts b/src/bundled/codex/setup.ts index adbb4aa721..83eb7749ba 100644 --- a/src/bundled/codex/setup.ts +++ b/src/bundled/codex/setup.ts @@ -164,15 +164,18 @@ export type SetupState = Readonly<{ }>; const OUTPUT_LIMIT = 20_000; +/** Setup before anything is known. */ +export const SETUP_START: SetupState = Object.freeze({ + status: undefined, + checking: false, + running: undefined, + output: "", + notice: undefined, +}); + /** The plugin's one view of Codex on this computer. */ export class CodexSetup { - private state: SetupState = { - status: undefined, - checking: false, - running: undefined, - output: "", - notice: undefined, - }; + private state: SetupState = SETUP_START; private readonly listeners = new Set<() => void>(); private process: HostProcess | undefined; /** Cancel was asked for before the running step's process was returned. */ diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 7a83cdbc54..8be3142df3 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -35,6 +35,8 @@ import { CodexProvider, createTabs, transcripts } from "./tabs"; import { CodexRuntime } from "./runtime"; import type { Wire } from "./rpc"; import { CodexSetup } from "./setup"; +import { linkCodex, serveCodex } from "./exposed"; +import { delivered, runtimeLink } from "../../features/agents/plugin-testing"; function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { @@ -58,6 +60,26 @@ afterEach(() => { vi.unstubAllGlobals(); localStorage.clear(); }); +/** The tabs, reaching the runtime over a link as from another page. */ +function linkedTabs( + provider: CodexProvider, + runtime: CodexRuntime, + spawn: Parameters[0], + setup: CodexSetup, +) { + const { + provider: shown, + runtime: activity, + transcript, + setup: login, + } = linkCodex( + runtimeLink( + serveCodex(provider, setup, runtime, transcripts(spawn, runtime)), + ), + ); + return createTabs(shown, activity, transcript, login); +} + function fixture({ signedIn = true, requiresOpenaiAuth = true, @@ -183,12 +205,17 @@ function fixture({ }); const setup = new CodexSetup(spawn); const provider = new CodexProvider(spawn, storage, setup); + const relisted = new Set<() => void>(); const relay = { snapshot: () => ({ status: "ready", session: { channels: { list: () => ({ channels }), + subscribeList: (listener: () => void) => { + relisted.add(listener); + return () => relisted.delete(listener); + }, }, }, }), @@ -204,12 +231,7 @@ function fixture({ sessions, (raw) => provider.resolve(raw), ); - const tabs = createTabs( - provider, - runtime, - transcripts(spawn, runtime), - setup, - ); + const tabs = linkedTabs(provider, runtime, spawn, setup); const props = (settings = defaults, save = vi.fn(async () => {})) => ({ agent: { @@ -230,6 +252,10 @@ function fixture({ props, disk, logins, + /** The community lists its channels anew, as `channels` has them now. */ + relist: () => { + for (const listener of [...relisted]) listener(); + }, }; } it("lists saved conversations and shows one's Codex thread, or that Codex no longer has it", async () => { @@ -279,6 +305,8 @@ it("lists saved conversations and shows one's Codex thread, or that Codex no lon }); const save = vi.fn(); render(); + // What the runtime sends arrives over the link. + await act(delivered); const viewButton = (index: number) => screen.getAllByRole("button", { name: /^View transcript/ })[ index @@ -303,7 +331,7 @@ it("lists saved conversations and shows one's Codex thread, or that Codex no lon expect(save).not.toHaveBeenCalled(); }); -it("sets the conversation the profile was opened from apart from recent conversations", () => { +it("sets the conversation the profile was opened from apart from recent conversations", async () => { const root = "e".repeat(64); const f = fixture({ saved: { @@ -325,6 +353,8 @@ it("sets the conversation the profile was opened from apart from recent conversa conversation={{ channelId: "channel", rootId: root }} />, ); + // What the runtime sends arrives over the link. + await act(delivered); const opened = screen.getByRole("region", { name: "This thread" }); expect( within(opened).getByRole("button", { name: /^View transcript/ }), @@ -368,6 +398,8 @@ it("brings an open transcript's tab back, and closes it when the profile moves", const view = render( , ); + // What the runtime sends arrives over the link. + await act(delivered); const tab = (name: string) => screen.getByRole("tab", { name }); const viewHere = () => user.click( @@ -539,6 +571,8 @@ it("finds the thread's Codex session another build started", async () => { conversation={{ channelId: "channel", rootId: root }} />, ); + // What the runtime sends arrives over the link. + await act(delivered); const opened = screen.getByRole("region", { name: "This thread" }); fireEvent.click( await within(opened).findByRole("button", { name: /^View transcript/ }), @@ -570,17 +604,23 @@ it("shows the channel's current kind for a session another build started", async /> ); const view = render(shown()); - const here = screen.getByRole("region", { name: "This thread" }); + const here = await screen.findByRole("region", { name: "This thread" }); await within(here).findByRole("button", { name: /^View transcript/ }); - expect(within(here).getByTitle("general").querySelector("svg")).toHaveClass( - "tabler-icon-lock", + // The channel's label comes from the runtime too. + await waitFor(() => + expect(within(here).getByTitle("general").querySelector("svg")).toHaveClass( + "tabler-icon-lock", + ), ); // Made public, the lock found with the session does not stay. channels[0] = { id: "channel", name: "general", channelType: "stream" }; + f.relist(); view.rerender(shown()); - expect(within(here).getByTitle("general").querySelector("svg")).toHaveClass( - "tabler-icon-hash", + await waitFor(() => + expect(within(here).getByTitle("general").querySelector("svg")).toHaveClass( + "tabler-icon-hash", + ), ); }); @@ -737,6 +777,8 @@ it("selects a model in the Settings dropdown without changing local Codex prefer const f = fixture({ configured }); await f.provider.check(); render(); + // What the runtime sends arrives over the link. + await act(delivered); const user = userEvent.setup(); const control = screen.getByRole("button", { name: /^Codex default model:/ }); expect(control).toHaveTextContent("Model A"); @@ -747,9 +789,11 @@ it("selects a model in the Settings dropdown without changing local Codex prefer await screen.findByRole("menuitemradio", { name: "Model B" }), ); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Medium" })); - expect( - new CodexProvider(f.spawn, localStore()).resolve(defaults), - ).toMatchObject({ model: "model-b", effort: "medium" }); + await waitFor(() => + expect( + new CodexProvider(f.spawn, localStore()).resolve(defaults), + ).toMatchObject({ model: "model-b", effort: "medium" }), + ); await waitFor(() => expect(control).toHaveTextContent("Medium")); fireEvent.click(control); fireEvent.click( @@ -816,6 +860,8 @@ it("shows a provider model recheck inside a focusable inactive picker", async () const f = fixture(); await f.provider.check(); render(); + // What the runtime sends arrives over the link. + await act(delivered); const picker = screen.getByRole("button", { name: /^Codex default model:/ }); expect(picker).toHaveTextContent("Model A"); const spawn = f.spawn.getMockImplementation(); @@ -857,6 +903,8 @@ it("keeps model-default provider thinking dynamic across save and reopen", async await f.provider.saveDefaults({ model: "", effort: "high" }); const onClose = vi.fn(); const view = render(); + // What the runtime sends arrives over the link. + await act(delivered); const user = userEvent.setup(); expect(screen.getByLabelText("Default thinking")).toHaveTextContent(/^High$/); @@ -876,6 +924,8 @@ it("keeps model-default provider thinking dynamic across save and reopen", async view.unmount(); const reopened = fixture(); render(); + // What the runtime sends arrives over the link. + await act(delivered); await waitFor(() => expect(screen.getByLabelText("Default thinking")).toBeEnabled(), ); @@ -1247,6 +1297,8 @@ it("replaces sign-in with available models after Codex login succeeds", async () /> , ); + // What the runtime sends arrives over the link. + await act(delivered); await waitFor(() => expect(screen.getAllByRole("button", { name: "Sign in" })).toHaveLength(2), ); @@ -1255,11 +1307,14 @@ it("replaces sign-in with available models after Codex login succeeds", async () fireEvent.click(buttons[0]); if (!buttons[1]) throw new Error("Missing second sign-in"); fireEvent.click(buttons[1]); - expect( + const logins = () => f.spawn.mock.calls.filter( ([id, options]) => id === "codex" && options?.args?.join(" ") === "login", - ), - ).toHaveLength(1); + ); + await waitFor(() => + expect(buttons[1]).toHaveAttribute("aria-disabled", "true"), + ); + expect(logins()).toHaveLength(1); expect( screen.queryByRole("combobox", { name: "Model" }), ).not.toBeInTheDocument(); @@ -1437,10 +1492,10 @@ it("moves the card from Install Codex to Sign in once the install succeeds", asy it("installs and signs in Buzz's copy of Codex from its provider settings", async () => { const f = fixture({ installed: false, signedIn: false }); const provider = new CodexProvider(f.spawn, localStore(), f.setup); - const { ProviderDialogBody } = createTabs( + const { ProviderDialogBody } = linkedTabs( provider, f.runtime, - transcripts(f.spawn, f.runtime), + f.spawn, f.setup, ); render(); @@ -1620,6 +1675,8 @@ it("keeps the recommended model inherited when only effort changes", async () => const f = fixture(); await f.provider.check(); render(); + // What the runtime sends arrives over the link. + await act(delivered); const control = screen.getByRole("button", { name: /^Codex default model:/ }); expect(control).toHaveTextContent("Model A · Default"); fireEvent.click(control); @@ -1636,7 +1693,7 @@ it("keeps the recommended model inherited when only effort changes", async () => ); }); -it("keeps same-channel recent threads visibly distinguishable", () => { +it("keeps same-channel recent threads visibly distinguishable", async () => { const saved = Object.fromEntries( ["e", "f"].map((char) => [ JSON.stringify(["channel", char.repeat(64)]), @@ -1645,6 +1702,8 @@ it("keeps same-channel recent threads visibly distinguishable", () => { ); const f = fixture({ saved }); render(); + // What the runtime sends arrives over the link. + await act(delivered); const rows = screen.getAllByRole("listitem"); expect(rows).toHaveLength(2); expect(rows.map((row) => row.textContent)).toEqual( @@ -1657,12 +1716,14 @@ it("keeps same-channel recent threads visibly distinguishable", () => { it.each(["Needs attention", "Session could not be saved"])( "keeps %s diagnostics visible and multiline", - (status) => { + async (status) => { const f = fixture(); vi.spyOn(f.runtime, "sessions").mockReturnValue([ { key: "unbound", status, detail: "First line\nSecond line" }, ]); render(); + // What the runtime sends arrives over the link. + await act(delivered); const diagnostic = screen.getByText("First line Second line"); expect(diagnostic.closest("details")).toBeNull(); expect(diagnostic.textContent).toBe("First line\nSecond line"); @@ -1670,12 +1731,14 @@ it.each(["Needs attention", "Session could not be saved"])( }, ); -it("collapses Working command details", () => { +it("collapses Working command details", async () => { const f = fixture(); vi.spyOn(f.runtime, "sessions").mockReturnValue([ { key: "unbound", status: "Working", detail: "git status" }, ]); render(); + // What the runtime sends arrives over the link. + await act(delivered); expect(screen.getByText("git status").closest("details")).not.toHaveAttribute( "open", ); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index cd29a781cb..d7bc79302d 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -14,6 +14,7 @@ import { AgentSaveButton } from "../../features/agents/AgentSaveButton"; import { useEffect, useRef, + useReducer, useId, useState, useSyncExternalStore, @@ -70,8 +71,8 @@ type Read = type Catalog = { models: Model[]; }; -type ProviderDefaults = Readonly<{ model: string; effort: string }>; -type ProviderState = AgentProviderSnapshot & +export type ProviderDefaults = Readonly<{ model: string; effort: string }>; +export type ProviderState = AgentProviderSnapshot & Readonly<{ checking: boolean; data: Catalog | undefined; @@ -101,6 +102,32 @@ async function catalog(spawn: Spawn, signal: AbortSignal) { } } +/** `raw` with the provider's defaults for what it leaves unset. */ +export function resolveConfig( + state: Pick, + raw: unknown, +): Config { + const explicit = readConfig(raw); + const inheritedModel = state.defaults.model; + const unavailable = Boolean( + inheritedModel && state.data && !catalogModel(inheritedModel, state.data), + ); + const model = explicit.model || (unavailable ? "" : inheritedModel); + if (explicit.effort) return { ...explicit, model }; + const selected = catalogModel(model, state.data); + const preferred = unavailable && !explicit.model ? "" : state.defaults.effort; + const effort = !preferred + ? "" + : !state.data + ? preferred + : selected?.supportedReasoningEfforts.some( + (choice) => choice.reasoningEffort === preferred, + ) + ? preferred + : (selected?.defaultReasoningEffort ?? ""); + return { ...explicit, model, effort }; +} + /** Codex's one device-wide health/catalog view and versioned default store. */ export class CodexProvider { private state: ProviderState; @@ -215,26 +242,7 @@ export class CodexProvider { this.set({ defaults }); } resolve(raw: unknown): Config { - const explicit = readConfig(raw); - const inheritedModel = this.state.defaults.model; - const unavailable = Boolean( - inheritedModel && this.state.data && !this.model(inheritedModel), - ); - const model = explicit.model || (unavailable ? "" : inheritedModel); - if (explicit.effort) return { ...explicit, model }; - const selected = this.model(model); - const preferred = - unavailable && !explicit.model ? "" : this.state.defaults.effort; - const effort = !preferred - ? "" - : !this.state.data - ? preferred - : selected?.supportedReasoningEfforts.some( - (choice) => choice.reasoningEffort === preferred, - ) - ? preferred - : (selected?.defaultReasoningEffort ?? ""); - return { ...explicit, model, effort }; + return resolveConfig(this.state, raw); } dispose() { this.offSetup?.(); @@ -426,8 +434,15 @@ type ProviderHandle = Pick< >; type ActivityHandle = Pick< CodexRuntime, - "subscribe" | "sessions" | "conversations" | "find" | "channelLabel" ->; + "subscribe" | "sessions" | "find" | "channelLabel" | "subscribeChannels" +> & + Readonly<{ + /** The same list until `subscribe` tells of a change. */ + conversations(agent: string): readonly SavedConversation[]; + /** Whether the runtime's list for the agent has reached this page yet; + * always, without it. */ + loaded?(agent: string): boolean; + }>; type SetupHandle = Pick< CodexSetup, "snapshot" | "subscribe" | "run" | "cancel" @@ -855,16 +870,30 @@ export function createTabs( initialTranscript?: AgentViewProps["initialTranscript"]; close?: AgentViewProps["close"]; }) { - const sessions = useSyncExternalStore(runtime.subscribe, () => - runtime.sessions(agent.key), + const sessions = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.sessions(agent.key), + ); + const conversations = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.conversations(agent.key), + ); + // Until its saved conversations arrive, it neither searches other builds' + // history nor offers the transcript asked for, which is one of them. + const loaded = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.loaded?.(agent.key) ?? true, ); - const conversations = runtime.conversations(agent.key); + // A session found in another build's history shows its channel's name + // and kind as the community has them now. + const [, relabel] = useReducer((count: number) => count + 1, 0); + useEffect(() => runtime.subscribeChannels(agent.key, relabel), [agent.key]); const { session: here, searching, error: lookupError, } = useOpenedSession( - conversations, + loaded ? conversations : undefined, conversation, (place, signal) => runtime.find({ key: agent.key, name: agent.name }, place, signal), @@ -876,7 +905,9 @@ export function createTabs( const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript && { initial: here, ...initialTranscript }, + loaded && initialTranscript + ? { initial: here, ...initialTranscript } + : undefined, close, ); const saved = new Set(conversations.map((row) => row.key)); diff --git a/src/bundled/github/SoloPull.test.tsx b/src/bundled/github/SoloPull.test.tsx index 860cdbf998..74db07a26a 100644 --- a/src/bundled/github/SoloPull.test.tsx +++ b/src/bundled/github/SoloPull.test.tsx @@ -8,6 +8,8 @@ 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 type { Served } from "../../features/agents/link"; +import { runtimeLink } from "../../features/agents/plugin-testing"; import { agent, apply } from "./index"; import type { SoloPull, SoloPulls } from "./solo-pull"; import { SoloPullIcon, SoloPullLink, SoloPullMenu } from "./SoloPull"; @@ -161,6 +163,7 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () { id: team, channelType: "stream" }, ]); let tool: AddedTool | undefined; + let served: Served | undefined; const agents = { addTool: (added: AddedTool) => { if (added.name === "github_link_pr") tool = added; @@ -168,6 +171,10 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () relay: () => relay, // The agent side also lets agents follow pull requests. addAttentionSource: () => {}, + share: (shared: Served) => { + served = shared; + }, + shared: () => runtimeLink(served), }; apply({ host: {}, diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index 1e3867e592..3f60e084c5 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -105,6 +105,36 @@ export const apply: PluginModule["apply"] = (ctx) => { fallback={fallback} /> )); + // What the github_link_pr tool needs of the screens: a link an agent wrote shows + // in the sidebar without reading it back. + ctx.agents.share({ + commands: { + linked: (args) => { + const { scope, channel, url } = args as Readonly<{ + scope: string | null; + channel: string; + url: string | null; + }>; + // The sidebar reads the selected community through its own session. + const shown = ctx.relay.snapshot(); + if (shown.status === "ready" && shown.scope === (scope ?? undefined)) + pullsFor(shown.session).linked(channel, url); + return null; + }, + }, + }); +}; +// Agents can watch pull requests where the app runs them. +export const agent: NonNullable = { + inject: ["agents", "host"], + apply(ctx) { + watchPulls(ctx); + addLinkTool(ctx); + }, +}; + +function addLinkTool(ctx: Parameters[0]) { + const shared = ctx.agents.shared(); ctx.agents.addTool({ name: "github_link_pr", description: @@ -146,22 +176,18 @@ export const apply: PluginModule["apply"] = (ctx) => { throw new Error("Only a Solo chat can be linked to a pull request"); const link = url === null ? null : url.url; await writeLink(session, channel, link); - pullsFor(session).linked(channel, link); - // The sidebar reads the selected community through its own session. - const shown = ctx.relay.snapshot(); - if (shown.status === "ready" && shown.scope === relay?.scope) - pullsFor(shown.session).linked(channel, link); + // Best effort: without it, the sidebar shows the link once it reads + // the chat's link again. + await shared + .call("linked", { scope: relay?.scope ?? null, channel, url: link }) + .catch(() => {}); return link ? `Linked ${link}. The sidebar shows its state for this chat.` : "Unlinked this chat's pull request."; }, }); -}; -// Agents can watch pull requests where the app runs them. -export const agent: NonNullable = { - inject: ["agents", "host"], - apply: watchPulls, -}; +} + const labels = { repository: "Repository", pull: "Pull request", diff --git a/src/bundled/goose/exposed.test.ts b/src/bundled/goose/exposed.test.ts new file mode 100644 index 0000000000..ef4afda834 --- /dev/null +++ b/src/bundled/goose/exposed.test.ts @@ -0,0 +1,72 @@ +import { expect, it, vi } from "vitest"; +import { delivered, runtimeLink } from "../../features/agents/plugin-testing"; +import { linkGoose, serveGoose } from "./exposed"; +import type { GooseRuntime } from "./runtime"; +import type { GooseSetup } from "./setup"; +import type { GooseHistory } from "./transcript"; + +function linked() { + const setup = { + snapshot: () => ({ readiness: "ready" as const }), + subscribe: () => () => true, + check: vi.fn(async () => {}), + providers: vi.fn(async () => ({ providers: [], defaults: {} })), + saved: vi.fn(async () => new Set(["API_KEY"])), + save: vi.fn(async () => {}), + saveDefaults: vi.fn(async () => {}), + }; + const history = { + find: vi.fn(async () => undefined), + recent: vi.fn(async () => [{ sessionId: "s" }]), + transcript: vi.fn(async () => undefined), + }; + const runtime = { + subscribe: () => () => {}, + sessions: () => [{ key: "channel", busy: true }], + }; + const ui = linkGoose( + runtimeLink( + serveGoose( + setup as unknown as GooseSetup, + runtime as unknown as GooseRuntime, + history as unknown as GooseHistory, + ), + ), + ); + return { ui, setup, history }; +} + +it("gives goose's UI its setup over the link, sets as sets and saves with their fields", async () => { + const { ui, setup } = linked(); + expect(ui.setup.snapshot()).toEqual({ readiness: "checking" }); + await delivered(); + expect(ui.setup.snapshot()).toEqual({ readiness: "ready" }); + await expect(ui.setup.saved("openai")).resolves.toEqual(new Set(["API_KEY"])); + await ui.setup.save("openai", { API_KEY: "k" }); + expect(setup.save).toHaveBeenCalledWith("openai", { API_KEY: "k" }); + await ui.setup.saveDefaults({ provider: "openai", model: "m" }); + expect(setup.saveDefaults).toHaveBeenCalledWith( + { provider: "openai", model: "m" }, + {}, + ); +}); + +it("reads goose's sessions and history over the link, with nothing found as undefined", async () => { + const { ui, history } = linked(); + expect(ui.runtime.sessions("a")).toEqual([]); + await delivered(); + expect(ui.runtime.sessions("a")).toEqual([{ key: "channel", busy: true }]); + const signal = new AbortController().signal; + await expect( + ui.history.find("a", "~", { channelId: "c" }, signal), + ).resolves.toBeUndefined(); + expect(history.find).toHaveBeenCalledWith( + "a", + "~", + { channelId: "c" }, + expect.any(AbortSignal), + ); + await expect(ui.history.recent("a", "~", signal)).resolves.toEqual([ + { sessionId: "s" }, + ]); +}); diff --git a/src/bundled/goose/exposed.ts b/src/bundled/goose/exposed.ts new file mode 100644 index 0000000000..4e1e1cbe5e --- /dev/null +++ b/src/bundled/goose/exposed.ts @@ -0,0 +1,160 @@ +// What goose's UI reaches of its runtime: the runtime serves it, and the UI +// reaches it over the link to the runtime, with the same shape as the +// runtime's own handles. +import { + abortable, + linkedStore, + linkedView, + storeTopic, + viewTopic, + type RuntimeLink, +} from "../../features/agents/exposed"; +import type { Json, Served } from "../../features/agents/link"; +import type { AgentKey } from "../../features/agents/membership"; +import type { AgentProviderSnapshot } from "../../features/agents/service"; +import type { GooseRuntime } from "./runtime"; +import type { GooseSetup } from "./setup"; +import type { createTabs } from "./tabs"; +import type { GooseHistory } from "./transcript"; + +/** What the type's UI reaches of its runtime. */ +export type GooseExposed = Parameters[0]; +type Setup = GooseSetup; +type History = GooseHistory; +type Arg< + F extends (...args: never[]) => unknown, + N extends number, +> = Parameters[N]; +type Answer unknown> = Awaited>; +const CHECKING: AgentProviderSnapshot = Object.freeze({ + readiness: "checking", +}); +const done = () => null; +const json = (value: unknown) => (value ?? null) as Json; +// The UI stops waiting by itself; reading changes nothing, so it finishes. +const unstopped = () => new AbortController().signal; + +/** The runtime's side. */ +export function serveGoose( + setup: Setup, + runtime: GooseRuntime, + history: History, +): Served { + type Args = Readonly>; + const changed = (listener: () => void) => runtime.subscribe(listener); + return { + topics: { + setup: storeTopic(setup), + sessions: viewTopic(changed, (key: AgentKey) => runtime.sessions(key)), + }, + commands: { + check: () => setup.check().then(done), + providers: () => setup.providers().then(json), + models: (id) => setup.models(id as string).then(json), + efforts: (choice) => + setup.efforts(choice as Arg).then(json), + // A set does not cross; its names do. + saved: async (id) => [...(await setup.saved(id as string))], + save: (args) => { + const { id, fields } = args as Args; + return setup + .save(id as string, fields as Arg) + .then(done); + }, + saveDefaults: (args) => { + const { choice, fields } = args as Args; + return setup + .saveDefaults( + choice as Arg, + fields as Arg, + ) + .then(done); + }, + test: (choice) => setup.test(choice as Arg).then(json), + find: (args) => { + const { agent, workspace, place } = args as Args; + return history + .find( + agent as string, + workspace as string, + place as Arg, + unstopped(), + ) + .then(json); + }, + recent: (args) => { + const { agent, workspace } = args as Args; + return history + .recent(agent as string, workspace as string, unstopped()) + .then(json); + }, + transcript: (lookup) => + history + .transcript(lookup as Arg, unstopped()) + .then(json), + }, + }; +} + +/** The UI's side: the runtime's handles, reached over `link`. */ +export function linkGoose(link: RuntimeLink): GooseExposed { + const setup = linkedStore(link, "setup", CHECKING); + const sessions = linkedView>( + link, + "sessions", + [], + ); + const ask = (command: string, args: unknown = null) => + link.call(command, args as Json) as Promise; + const undefinedOf = (value: T | null) => value ?? undefined; + return { + setup: { + snapshot: setup.snapshot, + subscribe: setup.subscribe, + check: async () => { + await ask("check"); + }, + providers: () => ask>("providers"), + models: (id) => ask>("models", id), + efforts: (choice) => ask>("efforts", choice), + saved: async (id) => new Set(await ask("saved", id)), + save: async (id, fields) => { + await ask("save", { id, fields }); + }, + saveDefaults: async (choice, fields = {}) => { + await ask("saveDefaults", { choice, fields }); + }, + test: (choice) => ask>("test", choice), + }, + runtime: { + subscribe: sessions.subscribe, + sessions: (key) => sessions.read(key), + loaded: (key) => sessions.loaded(key), + }, + history: { + find: async (agent, workspace, place, signal) => + undefinedOf( + await abortable( + ask | null>("find", { + agent, + workspace, + place, + }), + signal, + ), + ), + recent: (agent, workspace, signal) => + abortable( + ask>("recent", { agent, workspace }), + signal, + ), + transcript: async (lookup, signal) => + undefinedOf( + await abortable( + ask | null>("transcript", lookup), + signal, + ), + ), + }, + }; +} diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index ea7d8a6cd3..9c8a893b78 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -9,20 +9,18 @@ import { GooseLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, DEFAULT_CONFIG, GooseRuntime } from "./runtime"; import { gooseRun } from "./run"; import { GooseSetup } from "./setup"; +import { linkGoose, serveGoose } from "./exposed"; import { createTabs } from "./tabs"; import { GooseHistory } from "./transcript"; import { readUsage } from "./usage"; export const TYPE_ID = "goose"; -/** What the type's UI reaches of its runtime. */ -type GooseExposed = Parameters[0]; - export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `goose-acp`; no unusable type elsewhere. if (!isTauri()) return; - ctx.agents.present(TYPE_ID, (exposed) => { + ctx.agents.present(TYPE_ID, (runtime) => { const { Create, CreateModel, @@ -30,7 +28,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ActivityTab, DefaultControl, ProviderDialogBody, - } = createTabs(exposed); + } = createTabs(linkGoose(runtime)); return { icon: GooseLogoIcon, Create, @@ -63,6 +61,23 @@ export const apply: PluginModule["apply"] = (ctx) => { component: ActivityTab, }, ], + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + summary: (agent) => { + const { model, provider } = config(agent.config); + return [provider, model].filter(Boolean).join(" · ") || "Goose"; + }, + sessionScope: (agent) => config(agent.config).scope, }; }); }; @@ -113,28 +128,12 @@ function applyAgent(ctx: Parameters[0]) { description: "goose on this computer, with any provider goose supports. Each thread it is mentioned in is one continuing session.", defaults: () => ({ config: DEFAULT_CONFIG }), - expose: { setup, runtime, history } satisfies GooseExposed, + expose: serveGoose(setup, runtime, history), provider: { snapshot: setup.snapshot, subscribe: setup.subscribe, check: setup.check, }, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - workspace: carry.workspace ?? saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), - }; - }, - summary: (agent) => { - const { model, provider } = config(agent.config); - return [provider, model].filter(Boolean).join(" · ") || "Goose"; - }, sessions: { list: (key) => { const live = runtime.sessions(key); diff --git a/src/bundled/goose/run.ts b/src/bundled/goose/run.ts index 394eb181b1..7234f5980d 100644 --- a/src/bundled/goose/run.ts +++ b/src/bundled/goose/run.ts @@ -1,6 +1,6 @@ // The part of the goose agent type that runs agents, without its views. import type { AgentType } from "../../features/agents/service"; -import { type Config, config, type GooseRuntime } from "./runtime"; +import type { Config, GooseRuntime } from "./runtime"; import { syncedSettingsSchema, withSyncedSettings } from "./settings"; /** A turn's run returns once goose has it, so this bounds only the hand-over. */ @@ -19,7 +19,6 @@ export const gooseRun = ( | "resumes" > => ({ sessionControls: { - scope: (agent) => config(agent.config).scope, state: (agent, place) => runtime.sessionState(agent, place), run: (agent, place, action) => runtime.session(agent, place, action), subscribe: (listener) => runtime.subscribe(listener), diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index eba6e2f0ec..ba52463e1f 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -157,7 +157,12 @@ export function createTabs({ history, }: Readonly<{ setup: SetupHandle; - runtime: Pick; + runtime: Pick & + Readonly<{ + /** Whether the runtime's sessions for the agent have reached this page + * yet; always, without it. */ + loaded?(agent: string): boolean; + }>; history: Pick; }>) { function DefaultControl({ onConfigure }: { onConfigure(): void }) { @@ -840,6 +845,12 @@ export function createTabs({ (listener) => runtime.subscribe(listener), () => runtime.sessions(agent.key), ); + // Until its sessions arrive, it neither searches the history nor offers + // the transcript asked for, which may be one of them. + const loaded = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.loaded?.(agent.key) ?? true, + ); const running = live.map( (session): Row => ({ key: session.key, @@ -853,7 +864,7 @@ export function createTabs({ searching, error: lookupError, } = useOpenedSession( - running.filter((row) => row.sessionId), + loaded ? running.filter((row) => row.sessionId) : undefined, conversation, async (place, signal) => { const found = await history.find(agent.key, workspace, place, signal); @@ -885,7 +896,9 @@ export function createTabs({ const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript && { initial: here, ...initialTranscript }, + loaded && initialTranscript + ? { initial: here, ...initialTranscript } + : undefined, close, ); const rows = (recent.value ?? []) diff --git a/src/bundled/pi/exposed.test.ts b/src/bundled/pi/exposed.test.ts new file mode 100644 index 0000000000..ae0cc10a33 --- /dev/null +++ b/src/bundled/pi/exposed.test.ts @@ -0,0 +1,38 @@ +import { expect, it, vi } from "vitest"; +import { delivered, runtimeLink } from "../../features/agents/plugin-testing"; +import { linkPi, servePi } from "./exposed"; +import type { PiRuntime } from "./runtime"; +import type { PiSetup } from "./setup"; + +it("tells Pi's UI of each runtime change, so it reads its places again", async () => { + const listeners = new Set<() => void>(); + let places = [{ key: "channel", conversation: 1, seen: 0 }]; + const runtime = { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + working: () => [], + places: vi.fn(async () => places), + transcript: vi.fn(async () => ({ entries: [], more: false })), + }; + const setup = { login: vi.fn(async () => {}) }; + const ui = linkPi( + runtimeLink( + servePi(setup as unknown as PiSetup, runtime as unknown as PiRuntime), + ), + ); + const changed = vi.fn(); + ui.runtime.subscribe(changed); + await delivered(); + await expect(ui.runtime.places("a")).resolves.toEqual(places); + places = [...places, { key: "channel/root", conversation: 2, seen: 0 }]; + for (const listener of [...listeners]) listener(); + await delivered(); + expect(changed).toHaveBeenCalled(); + await expect(ui.runtime.places("a")).resolves.toHaveLength(2); + await ui.setup.login("openai", "key"); + expect(setup.login).toHaveBeenCalledWith("openai", "key"); +}); diff --git a/src/bundled/pi/exposed.ts b/src/bundled/pi/exposed.ts new file mode 100644 index 0000000000..74e4ee67d5 --- /dev/null +++ b/src/bundled/pi/exposed.ts @@ -0,0 +1,139 @@ +// What Pi's UI reaches of its runtime: the runtime serves it, and the UI +// reaches it over the link to the runtime, with the same shape as the +// runtime's own handles. +import { + abortable, + linkedStore, + linkedView, + viewTopic, + type RuntimeLink, +} from "../../features/agents/exposed"; +import type { Json, Served } from "../../features/agents/link"; +import type { AgentKey } from "../../features/agents/membership"; +import type { PiRuntime, Place } from "./runtime"; +import type { PiSetup } from "./setup"; +import type { createTabs } from "./tabs"; + +/** What the type's UI reaches of its runtime. */ +export type PiExposed = Parameters[0]; +type Answer unknown> = Awaited>; +const NONE: readonly never[] = Object.freeze([]); +/** A channel's label, as one agent reads it. */ +type LabelKey = Readonly<{ agent: AgentKey; channelId: string }>; +const json = (value: unknown) => (value ?? null) as Json; +// The UI stops waiting by itself; reading changes nothing, so it finishes. +const unstopped = () => new AbortController().signal; + +/** The runtime's side. */ +export function servePi(setup: PiSetup, runtime: PiRuntime): Served { + type Args = Readonly>; + const changed = (listener: () => void) => runtime.subscribe(listener); + // Each change the runtime tells of, as a count, so the UI rereads what it + // asks for by command (its places) on the same changes. + let changes = 0; + const counted = new Set<() => void>(); + let stop: (() => void) | undefined; + return { + topics: { + changes: () => ({ + get: () => changes, + subscribe(listener) { + counted.add(listener); + stop ??= changed(() => { + changes += 1; + for (const each of [...counted]) each(); + }); + return () => { + counted.delete(listener); + if (counted.size) return; + stop?.(); + stop = undefined; + }; + }, + }), + working: viewTopic(changed, (key: AgentKey) => runtime.working(key)), + // Read when shown, and told again when it may answer differently. + label: (args) => { + const { agent, channelId } = args as LabelKey; + return { + get: () => json(runtime.channelLabel(agent, channelId)), + subscribe: (listener) => runtime.subscribeChannels(agent, listener), + }; + }, + }, + commands: { + providers: () => setup.providers().then(json), + models: (id) => setup.models(id as string).then(json), + login: async (args) => { + const { id, key } = args as Args; + await setup.login(id as string, key as string); + return null; + }, + test: (choice) => + setup.test(choice as Parameters[0]).then(json), + places: (agent) => runtime.places(agent as AgentKey).then(json), + transcript: (args) => { + const { agent, place, working } = args as Args; + return runtime + .transcript( + agent as AgentKey, + place as Place, + working as boolean, + unstopped(), + ) + .then(json); + }, + }, + }; +} + +/** The UI's side: the runtime's handles, reached over `link`. */ +export function linkPi(link: RuntimeLink): PiExposed { + const changes = linkedStore(link, "changes", 0); + const working = linkedView>( + link, + "working", + NONE, + ); + const labels = linkedView>( + link, + "label", + undefined, + ); + const ask = (command: string, args: unknown = null) => + link.call(command, args as Json) as Promise; + return { + setup: { + providers: () => ask>("providers"), + models: (id) => ask>("models", id), + login: async (id, key) => { + await ask("login", { id, key }); + }, + test: (choice) => ask>("test", choice), + }, + runtime: { + subscribe(listener) { + const stops = [ + changes.subscribe(listener), + working.subscribe(listener), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, + working: (key) => working.read(key), + channelLabel: (agent, channelId) => labels.read({ agent, channelId }), + subscribeChannels: (_agent, listener) => labels.subscribe(listener), + places: (key) => ask>("places", key), + transcript: (agent, place, working, signal) => + abortable( + ask>("transcript", { + agent, + place, + working, + }), + signal, + ), + }, + }; +} diff --git a/src/bundled/pi/index.ts b/src/bundled/pi/index.ts index eec5f67d4e..1480edd4b8 100644 --- a/src/bundled/pi/index.ts +++ b/src/bundled/pi/index.ts @@ -9,6 +9,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; import type { PluginModule } from "../../plugins/api"; import { PiLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, DEFAULT_CONFIG, PiRuntime } from "./runtime"; +import { linkPi, servePi } from "./exposed"; import { createTabs } from "./tabs"; export const TYPE_ID = "pi"; @@ -16,16 +17,14 @@ export const TYPE_ID = "pi"; * which includes the first install of Pi's packages. */ const HANDOVER_MS = 2 * 60_000; -/** What the type's UI reaches of its runtime. */ -type PiExposed = Parameters[0]; - export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start the Pi host; no unusable type elsewhere. if (!isTauri()) return; - ctx.agents.present(TYPE_ID, (exposed) => { - const { ActivityTab, Create, CreateModel, SettingsTab } = - createTabs(exposed); + ctx.agents.present(TYPE_ID, (runtime) => { + const { ActivityTab, Create, CreateModel, SettingsTab } = createTabs( + linkPi(runtime), + ); return { icon: PiLogoIcon, Create, @@ -57,6 +56,23 @@ export const apply: PluginModule["apply"] = (ctx) => { component: ActivityTab, }, ], + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + summary: (agent) => { + const { model, provider } = config(agent.config); + return [provider, model].filter(Boolean).join(" · ") || "Pi"; + }, + sessionScope: (agent) => config(agent.config).scope, }; }); }; @@ -111,23 +127,7 @@ function applyAgent(ctx: Parameters[0]) { description: "Pi on this computer, with any provider Pi supports. Each thread it is mentioned in is one continuing conversation.", defaults: () => ({ config: DEFAULT_CONFIG }), - expose: { setup: runtime.setup, runtime } satisfies PiExposed, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - workspace: carry.workspace ?? saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), - }; - }, - summary: (agent) => { - const { model, provider } = config(agent.config); - return [provider, model].filter(Boolean).join(" · ") || "Pi"; - }, + expose: servePi(runtime.setup, runtime), ...(host.spawn && { usage: (agent, { since, signal }) => runtime.usage(agent.key, since, signal), @@ -137,7 +137,6 @@ function applyAgent(ctx: Parameters[0]) { subscribe: (listener) => runtime.subscribe(listener), }, sessionControls: { - scope: (agent) => config(agent.config).scope, state: (agent, place) => runtime.sessionState(agent, place), run: (agent, place, action) => runtime.session(agent, place, action), subscribe: (listener) => runtime.subscribe(listener), diff --git a/src/features/agents/OpenedConversation.tsx b/src/features/agents/OpenedConversation.tsx index 75f6d82868..c32ab4ba4d 100644 --- a/src/features/agents/OpenedConversation.tsx +++ b/src/features/agents/OpenedConversation.tsx @@ -25,9 +25,9 @@ type Place = Readonly<{ channelId: string; root?: string }>; * session comes first; `find` looks in the type's own history for one another * build of the app started, since each build saves its own. That one has no * channel name or kind; `label` gives it the channel's current ones each - * render. */ + * render. Until `saved` is known it is searching, and looks nowhere else. */ export function useOpenedSession( - saved: readonly T[], + saved: readonly T[] | undefined, conversation: AgentConversation | undefined, find: (place: Place, signal: AbortSignal) => Promise, label?: (channelId: string) => ChannelLabel | undefined, @@ -41,7 +41,7 @@ export function useOpenedSession( ] : []; const bound = (place: Place) => - saved.find( + saved?.find( (item) => item.channelId === place.channelId && item.root === place.root, ); const exact = places[0] && bound(places[0]); @@ -55,9 +55,10 @@ export function useOpenedSession( }>(); const latest = useRef({ bound, find }); latest.current = { bound, find }; + const known = saved !== undefined; // biome-ignore lint/correctness/useExhaustiveDependencies: places follow key; bound and find are read when it runs. useEffect(() => { - if (!key || exact) return; + if (!key || exact || !known) return; const abort = new AbortController(); void (async () => { for (const place of places) { @@ -79,9 +80,10 @@ export function useOpenedSession( setFound({ key }); })(); return () => abort.abort(); - }, [key, !!exact]); + }, [key, !!exact, known]); if (!conversation) return {}; if (exact) return { session: exact }; + if (!known) return { searching: true }; if (found?.key !== key) return { searching: true }; // The place found stays; its row is the one saved now, since a type gives // each render its channel's current name and kind, else the one found, diff --git a/src/features/agents/agent-start.ts b/src/features/agents/agent-start.ts index d454b836db..fd94791c8e 100644 --- a/src/features/agents/agent-start.ts +++ b/src/features/agents/agent-start.ts @@ -77,49 +77,79 @@ export const isSynced = ( ): boolean => !!side && (side.mode === "synced" || store.hadVersion(side.subject)); +/** What a start takes from the synced store, apart from the local row: the + * confirmed copy of each synced side, and the revisions they are, or why the + * agent can't start. It is JSON, so it can cross to the Runner's page, which + * lays it over the row it keeps (`applySynced`). */ +export type Synced = + | Readonly<{ + ok: true; + /** The synced settings, which the type's `withSynced` lays over the + * row. */ + settings?: Readonly>; + instructions?: string; + teams?: readonly Team[]; + revisions: readonly string[]; + }> + | Readonly<{ ok: false; state: ArtifactState; reason: string }>; + +/** What starts with the agent's row, `local`. */ +export function resolveStart(input: StartInput): Start { + return applySynced( + input.local, + resolveSynced(input), + input.settings?.withSynced, + ); +} + /** Pure over storage: never reads the relay. Once this device has had any * synced revision of a type, its local field is never a launch source again. * A transport failure never changes a stored state, so a confirmed `present` * head keeps serving while the relay is unreachable (offline rule, §4.4); * `invalid`, `deleted` and lost access block. */ -export function resolveStart(input: StartInput): Start { +export function resolveSynced( + input: Omit, "local" | "settings"> & + Readonly<{ + settings?: Omit< + NonNullable["settings"]>, + "withSynced" + >; + }>, +): Synced { const { store } = input; const synced = ( - side: StartInput["instructions"], + side: StartInput["instructions"], ): side is Readonly<{ mode: Mode; subject: Subject }> => isSynced(store, side); const revisions: string[] = []; - let config = input.local; - let applied: Readonly> | undefined; + let settings: Readonly> | undefined; + let instructions: string | undefined; - const { settings } = input; - if (settings && synced(settings)) { - const entry = store.state(settings.subject); + if (input.settings && synced(input.settings)) { + const entry = store.state(input.settings.subject); if (entry.state !== "present" || !entry.head) return blocked(entry.state, entry.problem, "Settings"); const read = syncedSettings( entry.head.content, input.agentType, - settings.schema, + input.settings.schema, ); if ("problem" in read) return blocked("invalid", read.problem, "Settings"); - config = settings.withSynced(config, read.settings); - applied = read.settings; + settings = read.settings; revisions.push(entry.head.id); } - const { instructions } = input; - if (synced(instructions)) { - const entry = store.state(instructions.subject); - const text = store.instructions(instructions.subject); + if (synced(input.instructions)) { + const entry = store.state(input.instructions.subject); + const text = store.instructions(input.instructions.subject); if (!text.ok) return blocked(text.state, text.problem, "Instructions"); - config = { ...config, instructions: text.text }; + instructions = text.text; if (entry.head) revisions.push(entry.head.id); } const done = { ok: true as const, - config, revisions, - ...(applied && { synced: applied }), + ...(settings && { settings }), + ...(instructions !== undefined && { instructions }), }; if (!input.teams) return done; @@ -155,6 +185,35 @@ export function resolveStart(input: StartInput): Start { return { ...done, teams }; } +/** What the agent starts with: its row, `local`, with `synced` over it. + * Synced settings need the type's `withSynced`. */ +export function applySynced( + local: C, + synced: Synced, + withSynced?: (config: C, settings: Readonly>) => C, +): Start { + if (!synced.ok) return synced; + const { settings, instructions, teams, revisions } = synced; + let config = local; + if (settings) { + if (!withSynced) + return { + ok: false, + state: "invalid", + reason: "This agent type has no synced settings", + }; + config = withSynced(config, settings); + } + if (instructions !== undefined) config = { ...config, instructions }; + return { + ok: true, + config, + revisions, + ...(settings && { synced: settings }), + ...(teams && { teams }), + }; +} + function teamName(content: string) { try { const name = (JSON.parse(content) as { name?: unknown }).name; diff --git a/src/features/agents/agents-runner.test.ts b/src/features/agents/agents-runner.test.ts new file mode 100644 index 0000000000..c9904cf816 --- /dev/null +++ b/src/features/agents/agents-runner.test.ts @@ -0,0 +1,60 @@ +import { expect, it, vi } from "vitest"; +import { AgentsRunner, type RunnerManager } from "./agents-runner"; +import { createLocalAgentActivity } from "./local-activity"; +import type { Member } from "./records"; +import { memoryStorage } from "./test-fakes"; + +const owner = "b".repeat(64); +const ada: Member = { + key: "a".repeat(64), + pubkey: "a".repeat(64), + relay: "wss://relay.example", + owner, + type: "test/agent", + name: "Ada", + deleted: false, +}; +const gone = "c".repeat(64); + +function saved() { + const storage = memoryStorage(); + for (const pubkey of [ada.pubkey, gone]) + storage.setItem( + `buzz.agent.v1:${pubkey}`, + JSON.stringify({ pubkey, attention: {}, config: {} }), + ); + return storage; +} + +function runner(storage: ReturnType) { + const manager = { + ownershipChanged: () => {}, + changed: () => {}, + open: () => undefined, + types: () => [], + defaults: () => { + throw new Error("No types here"); + }, + } as unknown as RunnerManager; + return new AgentsRunner( + storage, + undefined, + createLocalAgentActivity(), + undefined, + manager, + ); +} + +it("forgets records no membership keeps only once the whole list is read", async () => { + const storage = saved(); + const agents = runner(storage); + expect(agents.records.keys().sort()).toEqual([ada.pubkey, gone]); + // Before native's list is read, an empty list names nobody. + agents.members([], owner, false); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(agents.records.keys().sort()).toEqual([ada.pubkey, gone]); + agents.members([ada], owner, true); + await vi.waitFor(() => expect(agents.records.keys()).toEqual([ada.pubkey])); + expect(storage.getItem(`buzz.agent.v1:${gone}`)).toBeNull(); + agents.clear(); +}); diff --git a/src/features/agents/agents-runner.ts b/src/features/agents/agents-runner.ts index 8b86e82703..24a37014cd 100644 --- a/src/features/agents/agents-runner.ts +++ b/src/features/agents/agents-runner.ts @@ -1,13 +1,12 @@ // The Runner: the part of agents that runs them. It decides which agents run, // and owns the agent runners and the agent storage they keep, which is -// records, catch-up progress, turns in progress and synced config copies. +// records, catch-up progress and turns in progress. // What it needs from the Manager, the part the screens use, it asks through // `RunnerManager`; those facts and requests are what will cross the agents // link when the Runner moves to its own page. import { bytesToHex } from "nostr-tools/utils"; -import { AgentConfigStore } from "./agent-config"; import { excerpt, mentionFacts, type ClassifierContext } from "./classifier"; -import type { Start } from "./agent-start"; +import { applySynced, type Synced } from "./agent-start"; import { readCheckpoints, splitLegacyCheckpoints, @@ -22,20 +21,23 @@ import { pubkeyOf, type AgentKey } from "./membership"; import { migrateWatchFilters } from "./migrate-filter"; import { migrateWatchKeywords } from "./migrate-keywords"; import { migrateAgentStorage } from "./migrate-storage"; -import type { AgentIdentity } from "./native"; import { ownerReads } from "./reads"; -import { AgentRecords, EMPTY, type Member } from "./records"; +import { AgentRecords, EMPTY, type Member, type RecordEdit } from "./records"; import { AgentRunners, type Connection, type RunnerHost } from "./runner"; +import type { AgentsNative } from "./native"; import type { Agent, AgentWake, HandoffSource, RegisteredAgentType, + SessionControlAction, + SessionPlace, } from "./service"; import { soloChat } from "./solo-chat"; import { nameSoloChat } from "./solo-chat-name"; import type { AgentStore } from "./state"; -import type { AgentRecord } from "./store"; +import type { AgentRecord, Team } from "./store"; +import { teamPrompt } from "./team-prompt"; import { relayPartition } from "../relay/partition"; import type { RelayEvent } from "../relay/events"; import type { RelayData } from "../relay/service"; @@ -48,11 +50,9 @@ import type { Navigation } from "../navigation/controller"; /** What the Runner asks of the Manager. */ export type RunnerManager = Pick< RunnerHost, - "team" | "createAgent" | "updateAgent" | "templates" | "classifier" + "createAgent" | "updateAgent" | "templates" > & Readonly<{ - /** Who runs which agent changed. */ - ownershipChanged(): void; /** A record or turn in progress changed what the screens show. */ changed(): void; /** The community's relay, while the viewer is in it. */ @@ -60,11 +60,11 @@ export type RunnerManager = Pick< types(): readonly RegisteredAgentType[]; /** The settings a new agent of this type starts with. */ defaults(pubkey: string, type: RegisteredAgentType): AgentRecord; - /** What the agent starts with: `local`, its home record's config, with - * each synced side's confirmed copy over it. */ - resolve(agent: Agent, local: unknown): Start; /** A run of the agent ended. */ finished(key: AgentKey): void; + /** Saves the agent's synced settings as `type`'s, before its type + * changes here to `type` with `config`. */ + publishType(key: AgentKey, type: string, config: unknown): Promise; /** Where an agent's closed tabs leave the viewer. */ navigation?: Navigation | undefined; }>; @@ -77,21 +77,41 @@ export class AgentsRunner { readonly runners: AgentRunners; /** The Runner's own live session of each community it runs agents in. */ readonly connections: RunnerConnections; - /** Confirmed synced config copies, the only synced launch source. */ - readonly configStore: AgentConfigStore; /** The agents running here, in every community, and those of them with * turns the app left working last time. */ private state: Readonly<{ running: readonly Agent[]; interrupted: readonly Agent[]; }> = { running: [], interrupted: [] }; - /** The memberships it last decided from. */ + /** Which memberships this copy runs, which another copy holds, and which + * are changing type. */ + private held: RunnerStatus = { + runs: EMPTY_KEYS, + yielded: EMPTY_KEYS, + transitioning: EMPTY_KEYS, + }; + /** The type each retyped agent has, until the memberships the Manager + * sends say so too. */ + private readonly retyped = new Map(); + /** The memberships it last decided from, and their viewer. */ private identities: readonly Member[] = []; + private viewer: string | undefined; + /** The last full list it forgot records by. */ + private list: string | undefined; private readonly listeners = new Set<() => void>(); + private readonly recordListeners = new Set<() => void>(); + private readonly typeListeners = new Set<() => void>(); + /** The owner's teams, as the Manager last sent them. */ + private teams: readonly Team[] = []; + /** What each membership starts with from the synced store, as the + * Manager last sent it. */ + private synced: Readonly> = {}; constructor( storage: AgentStore, - private readonly custody: RunnerHost["custody"], + private readonly custody: + | (NonNullable & Pick) + | undefined, activity: LocalAgentActivity, decisions: Decisions | undefined, private readonly manager: RunnerManager, @@ -110,19 +130,23 @@ export class AgentsRunner { console.warn(`Agent type ${type} has invalid defaults`, error); } }, - changed: () => manager.changed(), + changed: () => { + for (const listener of [...this.recordListeners]) listener(); + this.changed(); + }, // Its timers waited for this change, saved or failed. settled: (key) => this.runners.wake(key), }); void splitLegacyCheckpoints(storage); - this.configStore = new AgentConfigStore(storage); this.connections = new RunnerConnections({ open: (origin) => manager.open(origin), listen: (session, origin, connection) => this.listen(session, origin, connection), - // The Manager's own session may change first: what runs is shown and - // reconciled again once this one has. - changed: () => manager.changed(), + changed: (origin, renewed) => { + this.update(); + if (renewed) this.refreshCore(origin); + this.manager.changed(); + }, }); this.coreMemory = new CoreMemoryCache((key) => ownerReads(this.relay(key), pubkeyOf(key)), @@ -130,10 +154,7 @@ export class AgentsRunner { /** The catch-up progress this program last read or saved. */ let savedProgress: Checkpoints = {}; this.runners = new AgentRunners({ - ownershipChanged: () => { - this.notify(); - manager.ownershipChanged(); - }, + ownershipChanged: () => this.status(), ready: (key) => this.ready(key), agent: (key) => this.agent(key), member: (agent, event) => this.member(agent, event), @@ -165,7 +186,7 @@ export class AgentsRunner { console.warn("Agent turns in progress were not saved", error), ); }, - changed: () => manager.changed(), + changed: () => this.changed(), }, scope: (agent) => this.connections.binding(agent)?.scope, saved: (key) => this.records.saved(key), @@ -188,11 +209,34 @@ export class AgentsRunner { timers: (key) => this.records.timersOf(key), progress: (key, ran) => this.records.progress(key, ran), start: (agent) => - manager.resolve( - agent, - (this.records.saved(agent.pubkey) ?? agent).config, + applySynced( + (this.records.saved(agent.pubkey) ?? agent).config as object, + // Each membership the Manager sends comes with what it starts + // with from the synced store. + this.synced[agent.key] ?? { ok: true, revisions: [] }, + manager.types().find((type) => type.key === agent.type) + ?.syncedSettings?.withSynced as + | (( + config: object, + synced: Readonly>, + ) => object) + | undefined, ), - team: (key) => manager.team(key), + team: (key) => { + const member = this.liveMember(key); + const origin = member && originOf(member.relay); + // Only teammates in the same community, where it can reach them. + return member + ? teamPrompt( + this.teams, + this.identities.filter( + (identity) => + !identity.deleted && originOf(identity.relay) === origin, + ), + member.pubkey, + ) + : ""; + }, agentTypes: () => manager .types() @@ -207,7 +251,8 @@ export class AgentsRunner { const session = this.connections.binding(agent)?.session; return session && channelTabs(session, manager.navigation); }, - classifier: () => manager.classifier(), + classifier: () => + decisions?.status() === "ready" ? "available" : "unavailable", decisions: () => decisions, context: (agent, event) => this.context(agent, event), displayName: (agent, pubkey) => this.displayName(agent, pubkey), @@ -243,26 +288,202 @@ export class AgentsRunner { }); } - /** Hears each change to what runs here or who holds it. */ + /** Hears each change to what runs here, which of it was interrupted, who + * holds it, or which is changing type. */ subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } + /** Hears each change to a record. */ + subscribeRecords(listener: () => void) { + this.recordListeners.add(listener); + return () => { + this.recordListeners.delete(listener); + }; + } private notify() { for (const listener of [...this.listeners]) listener(); } - /** Connects each community where the viewer has an agent. */ - watch(identities: readonly AgentIdentity[], viewer: string | undefined) { + /** The memberships on this device, as the Manager last sent them, and + * whether they are all of them (`loaded`): connects each community where + * the viewer has an agent, decides again what runs, and forgets records + * no membership keeps. */ + members( + identities: readonly Member[], + viewer: string | undefined, + loaded: boolean, + ) { + // Only a new list can name a record no membership keeps. + const list = JSON.stringify(identities); + const listed = loaded && list !== this.list; + if (loaded) this.list = list; + for (const identity of identities) + if (this.retyped.get(identity.pubkey) === identity.type) + this.retyped.delete(identity.pubkey); + this.identities = this.overlaid(identities); + this.viewer = viewer; this.connections.watch(identities, viewer); + this.update(); + if (listed) this.forget(); + } + /** Removes the settings whose agent is gone, or that a community it left + * kept: they do nothing. Its home record keeps its config while it is + * anywhere. */ + private forget() { + const live = new Set( + this.identities + .filter((saved) => !saved.deleted) + .flatMap((saved) => [saved.key, saved.pubkey]), + ); + for (const key of this.records.keys()) + if (!live.has(key)) + void this.records + .edit(key, { kind: "remove" }) + .catch((error) => + console.warn(`Agent ${key} settings were not removed`, error), + ); + } + /** Decides again what runs once a record, a turn in progress or one of its + * sessions changed, and lets the Manager show it. */ + private changed() { + this.update(); + this.manager.changed(); + } + /** Agent types were loaded or unloaded. */ + typesChanged() { + this.update(); + for (const listener of [...this.typeListeners]) listener(); + } + /** The agent types registered here. */ + types() { + return this.manager.types(); + } + /** Hears each change to the agent types. */ + subscribeTypes(listener: () => void) { + this.typeListeners.add(listener); + return () => { + this.typeListeners.delete(listener); + }; + } + /** The viewer joined or left a community: connects or lets go of it. A + * new viewer waits for the memberships the Manager sends with it. */ + communitiesChanged(viewer: string | undefined) { + if (viewer !== this.viewer) return; + this.connections.watch(this.identities, this.viewer); + this.update(); + } + /** Gives the turns the app left `keys` working on last time again. */ + resumeInterrupted(keys: readonly AgentKey[]) { + this.runners.resumeInterrupted(keys); + this.update(); + } + /** The owner's teams changed. */ + setTeams(teams: readonly Team[]) { + this.teams = teams; + } + /** What each membership starts with from the synced store changed. */ + setSynced(synced: Readonly>) { + this.synced = synced; + } + /** Forgets the turns the app left `keys` working on last time. */ + dismissInterrupted(keys: readonly AgentKey[]) { + this.runners.dismissInterrupted(keys); + this.update(); + } + /** Applies the Manager's edit. Its config waits for no change of type: one + * sent once that began would write the old type's config over the new. */ + async edit(key: AgentKey, edit: RecordEdit) { + if ( + edit.kind === "settings" && + edit.change.config !== undefined && + this.runners.transitioning(key) + ) + throw new Error(`${edit.member.name}'s provider is already changing`); + await this.records.edit(key, edit); + } + /** Runs `action` on the agent's session in `place`, while this copy holds + * the agent with the type `type`, as the owner last saw it. */ + async session( + key: AgentKey, + type: string, + place: SessionPlace, + action: SessionControlAction, + ) { + const kind = this.manager.types().find((entry) => entry.key === type); + if (!kind?.sessionControls || this.agent(key)?.type !== type) + throw new Error( + "Use session controls in the Buzz app that runs this agent.", + ); + await this.runners.control(key, async () => { + const current = this.agent(key); + if (current?.type !== type || !this.manager.types().includes(kind)) + throw new Error("The agent's harness changed. Try again."); + await kind.sessionControls?.run(current, place, action); + }); + } + /** Gives the agent the type `type`, starting with `config`, while no turn + * of it works and this copy holds it. With `publish`, its synced settings + * are saved as the new type's first. It shows with the new type once that + * config is saved; when the save fails, it keeps its type. */ + async retype(key: AgentKey, type: string, config: unknown, publish: boolean) { + const member = this.liveMember(key); + const custody = this.custody; + if (!member || !custody) throw new Error("No such agent on this device"); + const { pubkey, name } = member; + const retyping = this.runners.retype(key, name, async () => { + // Its other devices take the type its synced settings name. + if (publish) await this.manager.publishType(key, type, config); + await custody.setType(pubkey, type); + try { + await this.records.change( + pubkey, + (saved) => ({ + ...(saved ?? this.records.settingsOf(member)), + config, + }), + () => { + this.retyped.set(pubkey, type); + this.identities = this.overlaid(this.identities); + }, + ); + } catch (error) { + try { + await custody.setType(pubkey, member.type); + } catch (rollback) { + throw new AggregateError( + [error, rollback], + `Could not save or restore ${name}'s provider`, + ); + } + throw error; + } + }); + this.status(); + try { + await retyping; + } finally { + this.status(); + } + } + /** The types of retyped agents the Manager does not show yet, by pubkey. */ + retypes(): Readonly> { + return Object.fromEntries(this.retyped); + } + private overlaid(identities: readonly Member[]) { + if (!this.retyped.size) return identities; + return identities.map((identity) => { + const type = this.retyped.get(identity.pubkey); + return type && type !== identity.type ? { ...identity, type } : identity; + }); } /** Decides which agents run: each of the viewer's agents whose community is * joined and not disconnected. A reconnect does not stop one. An unfinished * Delete or Remove stops it, and its types' plugins stop what they keep for * it. Then each runner goes on with what it can do now. */ - update(identities: readonly Member[], viewer: string | undefined) { - this.identities = identities; + private update() { + const { identities, viewer } = this; const live = identities.filter( (identity) => !identity.deleted && !identity.deleting, ); @@ -317,7 +538,12 @@ export class AgentsRunner { ? prior.interrupted : Object.freeze(interrupted), }; - if (this.state.running !== prior.running) this.notify(); + if ( + !this.status() && + (this.state.running !== prior.running || + this.state.interrupted !== prior.interrupted) + ) + this.notify(); this.runners.resume( new Set( running @@ -335,6 +561,32 @@ export class AgentsRunner { interrupted() { return this.state.interrupted; } + /** Which memberships this copy runs, which another copy holds, and which + * are changing type. */ + runnerStatus() { + return this.held; + } + /** Reads again who holds each membership and which is changing type, and + * says so when that changed. */ + private status() { + const keys = this.identities + .filter((identity) => !identity.deleted) + .map((identity) => identity.key); + const next: RunnerStatus = { + runs: keys.filter((key) => this.runners.runs(key)), + yielded: keys.filter((key) => this.runners.yielded(key)), + transitioning: keys.filter((key) => this.runners.transitioning(key)), + }; + const prior = this.held; + const changed = + !sameKeys(next.runs, prior.runs) || + !sameKeys(next.yielded, prior.yielded) || + !sameKeys(next.transitioning, prior.transitioning); + if (!changed) return false; + this.held = Object.freeze(next); + this.notify(); + return true; + } /** A running agent, in any community. */ agent(key: AgentKey) { return this.state.running.find((agent) => agent.key === key); @@ -382,7 +634,16 @@ export class AgentsRunner { clear() { this.connections.clear(); this.runners.clear(); + this.identities = []; + this.viewer = undefined; + this.list = undefined; + this.retyped.clear(); this.state = { running: [], interrupted: [] }; + this.held = { + runs: EMPTY_KEYS, + yielded: EMPTY_KEYS, + transitioning: EMPTY_KEYS, + }; } /** Delivers the session's live events, and retries what failed whenever its @@ -532,6 +793,17 @@ const PARENT_LOOKUP_MS = 5_000; const same = (next: readonly Agent[], prior: readonly Agent[]) => next.length === prior.length && next.every((agent, index) => agent === prior[index]); +/** Which memberships this copy runs, which another copy holds, and which are + * changing type. */ +export type RunnerStatus = Readonly<{ + runs: readonly AgentKey[]; + yielded: readonly AgentKey[]; + transitioning: readonly AgentKey[]; +}>; +const EMPTY_KEYS: readonly AgentKey[] = Object.freeze([]); +const sameKeys = (next: readonly AgentKey[], prior: readonly AgentKey[]) => + next.length === prior.length && + next.every((key, index) => key === prior[index]); /** A membership as it runs here. */ function view(identity: Member, record: AgentRecord): Agent { return Object.freeze({ diff --git a/src/features/agents/connections.test.ts b/src/features/agents/connections.test.ts index 33843f3d28..658a28a33b 100644 --- a/src/features/agents/connections.test.ts +++ b/src/features/agents/connections.test.ts @@ -42,7 +42,7 @@ function community() { return { relay, session, set }; } -it("hears a session while it is live, and says each time it starts or ends", () => { +it("hears a session while it is live, and says each time its connection changes", () => { const { relay, session, set } = community(); const stopLive = vi.fn(); const listen = vi.fn(() => stopLive); @@ -54,18 +54,61 @@ it("hears a session while it is live, and says each time it starts or ends", () }); connections.watch([identity], viewer); expect(connections.binding(identity)).toBeUndefined(); + expect(changed).toHaveBeenLastCalledWith("https://relay.example", false); + // A session that starts after it began watching may have missed changes. set(true); expect(listen).toHaveBeenCalledWith( session, "https://relay.example", expect.objectContaining({ epoch: 0 }), ); - expect(changed).toHaveBeenCalledTimes(1); - // The same session again changes nothing. + expect(changed).toHaveBeenLastCalledWith("https://relay.example", true); + // The same session again starts nothing. set(true); - expect(changed).toHaveBeenCalledTimes(1); + expect(listen).toHaveBeenCalledOnce(); + expect(changed).toHaveBeenLastCalledWith("https://relay.example", false); set(false); expect(stopLive).toHaveBeenCalled(); expect(connections.binding(identity)).toBeUndefined(); - expect(changed).toHaveBeenCalledTimes(2); + expect(changed).toHaveBeenCalledTimes(4); +}); + +it("does not call a session it found live when it began watching renewed", () => { + const { relay, set } = community(); + set(true); + const changed = vi.fn(); + const connections = new RunnerConnections({ + open: () => relay, + listen: () => () => {}, + changed, + }); + connections.watch([identity], viewer); + expect(connections.entries()).toEqual([ + ["https://relay.example", expect.objectContaining({ scope: "scope" })], + ]); + expect(changed).toHaveBeenCalledExactlyOnceWith( + "https://relay.example", + false, + ); +}); + +it("calls a community joined again renewed, since it may have missed changes", () => { + const { relay, set } = community(); + set(true); + let open: RelayData | undefined = relay; + const changed = vi.fn(); + const connections = new RunnerConnections({ + open: () => open, + listen: () => () => {}, + changed, + }); + connections.watch([identity], viewer); + open = undefined; + connections.watch([identity], viewer); + expect(connections.entries()).toEqual([]); + const again = community(); + again.set(true); + open = again.relay; + connections.watch([identity], viewer); + expect(changed).toHaveBeenLastCalledWith("https://relay.example", true); }); diff --git a/src/features/agents/connections.ts b/src/features/agents/connections.ts index 590ec501b0..89b4aa1bef 100644 --- a/src/features/agents/connections.ts +++ b/src/features/agents/connections.ts @@ -43,12 +43,16 @@ export type ConnectionsHost = Readonly<{ origin: string, connection: Connection, ): () => void; - /** The community's live session started, was replaced or ended. */ - changed(origin: string): void; + /** The community's connection changed. `renewed` for a new live session + * of a community it watched before, joined again or not, which may have + * missed changes made elsewhere since. */ + changed(origin: string, renewed: boolean): void; }>; export class RunnerConnections { private readonly bound = new Map(); + /** Each community it has watched, joined again or not. */ + private readonly watched = new Set(); constructor(private readonly host: ConnectionsHost) {} @@ -98,6 +102,7 @@ export class RunnerConnections { } clear() { for (const origin of [...this.bound.keys()]) this.unbind(origin); + this.watched.clear(); } private unbind(origin: string) { @@ -110,6 +115,8 @@ export class RunnerConnections { private bind(origin: string) { const entry = this.bound.get(origin); if (!entry) return; + const known = this.watched.has(origin); + this.watched.add(origin); const snapshot = entry.relay.snapshot(); const live = snapshot.status === "ready" && @@ -128,8 +135,11 @@ export class RunnerConnections { if ( entry.binding?.session === live?.session && entry.binding?.scope === live?.scope - ) + ) { + // Disconnecting stops its agents even when no binding changed. + this.host.changed(origin, false); return; + } entry.binding?.stopLive(); entry.binding?.controller.abort(); const connection: Connection = { @@ -141,6 +151,6 @@ export class RunnerConnections { connection, stopLive: this.host.listen(live.session, origin, connection), }; - this.host.changed(origin); + this.host.changed(origin, known && !!entry.binding); } } diff --git a/src/features/agents/exposed.test.ts b/src/features/agents/exposed.test.ts new file mode 100644 index 0000000000..1b6be0c289 --- /dev/null +++ b/src/features/agents/exposed.test.ts @@ -0,0 +1,84 @@ +import { expect, it, vi } from "vitest"; +import { + abortable, + linkedStore, + linkedView, + storeTopic, + viewTopic, +} from "./exposed"; +import { delivered, runtimeLink } from "./plugin-testing"; + +function store(initial: T) { + let value = initial; + const listeners = new Set<() => void>(); + return { + snapshot: () => value, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set(next: T) { + value = next; + for (const listener of [...listeners]) listener(); + }, + }; +} + +it("shows a runtime store to its UI: the initial value until the runtime's arrives, then each change", async () => { + const setup = store({ + status: "ready", + detail: undefined as string | undefined, + }); + const link = runtimeLink({ topics: { setup: storeTopic(setup) } }); + const initial = Object.freeze({ status: "checking" }); + const shown = linkedStore(link, "setup", initial); + const changed = vi.fn(); + shown.subscribe(changed); + expect(shown.snapshot()).toBe(initial); + await delivered(); + // What crosses is a copy, without its undefined fields. + expect(shown.snapshot()).toEqual({ status: "ready" }); + const first = shown.snapshot(); + expect(shown.snapshot()).toBe(first); + setup.set({ status: "signed-out", detail: "Sign in" }); + await delivered(); + expect(shown.snapshot()).toEqual({ status: "signed-out", detail: "Sign in" }); + expect(changed).toHaveBeenCalledTimes(2); +}); + +it("reads a runtime view by key, watching each key from its first read", async () => { + const runtime = store>({ ada: ["one"] }); + const read = vi.fn((key: string) => runtime.snapshot()[key]); + const link = runtimeLink({ + topics: { sessions: viewTopic(runtime.subscribe, read) }, + }); + const sessions = linkedView(link, "sessions", []); + await delivered(); + expect(read).not.toHaveBeenCalled(); + expect(sessions.read("ada")).toEqual([]); + await delivered(); + expect(sessions.read("ada")).toEqual(["one"]); + // A key with nothing reads as the initial value. + expect(sessions.read("bob")).toEqual([]); + runtime.set({ ada: ["one", "two"], bob: ["three"] }); + await delivered(); + expect(sessions.read("ada")).toEqual(["one", "two"]); + expect(sessions.read("bob")).toEqual(["three"]); +}); + +it("stops waiting for a call when its signal aborts, without an unhandled rejection", async () => { + let answer!: (value: string) => void; + const pending = new Promise((resolve) => (answer = resolve)); + const abort = new AbortController(); + const waiting = abortable(pending, abort.signal); + abort.abort(new Error("Closed")); + await expect(waiting).rejects.toThrow("Closed"); + answer("late"); + const settled = abortable(Promise.resolve("now"), undefined); + await expect(settled).resolves.toBe("now"); + const early = new AbortController(); + early.abort(new Error("Before")); + await expect( + abortable(Promise.reject(new Error("Ignored")), early.signal), + ).rejects.toThrow("Before"); +}); diff --git a/src/features/agents/exposed.ts b/src/features/agents/exposed.ts new file mode 100644 index 0000000000..3666430b37 --- /dev/null +++ b/src/features/agents/exposed.ts @@ -0,0 +1,115 @@ +// What an agent type's UI reaches of its runtime. The runtime runs where the +// agents do, and its UI may be in another page, so the runtime serves topics +// and commands (its `expose`) and the UI reaches them over a link of its own +// type. Only JSON crosses: a value without a field comes back without it, and +// `undefined` comes back as `null`. +import type { Json, Link, Topic } from "./link"; + +/** The UI's link to its type's runtime. */ +export type RuntimeLink = Pick; + +type Store = Readonly<{ + snapshot(): unknown; + subscribe(listener: () => void): () => void; +}>; + +/** A store's value, as a topic. */ +export const storeTopic = + (store: Store): Topic => + () => ({ + get: () => (store.snapshot() ?? null) as Json, + subscribe: (listener) => store.subscribe(listener), + }); + +/** A read by key (the topic's arguments), as a topic that changes whenever + * `subscribe` says something did. */ +export const viewTopic = + ( + subscribe: (listener: () => void) => () => void, + read: (key: Key) => unknown, + ): Topic => + (args) => ({ + get: () => (read(args as Key) ?? null) as Json, + subscribe, + }); + +/** The runtime's `topic`, as a store the UI reads: `initial` until its first + * value arrives. It is watched from now on, so it is current when read + * outside a render too. */ +export function linkedStore(link: RuntimeLink, topic: string, initial: T) { + const listeners = new Set<() => void>(); + link.watch(topic, null, () => { + for (const listener of [...listeners]) listener(); + }); + return { + snapshot: () => (link.read(topic) as T | null | undefined) ?? initial, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +/** The runtime's `topic` read by key: `initial` until the key's first value + * arrives. Each key is watched from its first read on. */ +export function linkedView( + link: RuntimeLink, + topic: string, + initial: T, +) { + const listeners = new Set<() => void>(); + const watched = new Set(); + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + const watch = (key: Key) => { + const id = JSON.stringify(key); + if (!watched.has(id)) { + watched.add(id); + link.watch(topic, key, notify); + } + }; + return { + read(key: Key): T { + watch(key); + return (link.read(topic, key) as T | null | undefined) ?? initial; + }, + /** Whether the runtime's value for `key` has arrived, so `read` is not + * just `initial` yet. */ + loaded(key: Key): boolean { + watch(key); + return link.read(topic, key) !== undefined; + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +/** A call that settles when `signal` aborts, rejecting with its reason. The + * runtime's work goes on; its answer is dropped. */ +export function abortable( + pending: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return pending; + if (signal.aborted) { + void pending.catch(() => {}); + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const stop = () => reject(signal.reason); + signal.addEventListener("abort", stop, { once: true }); + pending.then( + (value) => { + signal.removeEventListener("abort", stop); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", stop); + reject(error); + }, + ); + }); +} diff --git a/src/features/agents/launch-boundary.test.ts b/src/features/agents/launch-boundary.test.ts index c9e313b2c1..92bdf4f2c4 100644 --- a/src/features/agents/launch-boundary.test.ts +++ b/src/features/agents/launch-boundary.test.ts @@ -217,8 +217,7 @@ async function setup( subscribe: () => () => {}, }); const activity = createLocalAgentActivity(); - /** Each start's binding lookup: one per start attempt. */ - const attempts = vi.fn(() => binding); + const bindingFor = () => binding; const service = new AgentsService( ctx, communities, @@ -227,7 +226,7 @@ async function setup( undefined, activity, undefined, - attempts, + bindingFor, ); disposals.push(() => ctx.fiber.dispose()); const plugin = ctx.extend({ @@ -292,7 +291,6 @@ async function setup( transport: counted.state, deliveries, activity, - attempts, mention, }; } @@ -604,7 +602,6 @@ it("refuses to start from deleted synced settings once: no launch, the reason on [[settingsSubject, deleted], seeded[1] as [Subject, RelayEvent]], synced(), ); - f.attempts.mockClear(); const sent = f.mention(); const work = () => f.activity.reader.work().filter((item) => item.messageId === sent.id); @@ -615,10 +612,15 @@ it("refuses to start from deleted synced settings once: no launch, the reason on error: "Settings were deleted — restore from History", }), ]); - // Past the service's next tick, which runs whatever is still queued. + // Past the service's next tick, which runs whatever is still queued: the + // mention stays refused once, and nothing starts. await vi.advanceTimersByTimeAsync(6_000); - expect(f.attempts).toHaveBeenCalledTimes(1); - expect(work()).toHaveLength(1); + expect(work()).toEqual([ + expect.objectContaining({ + state: "error", + error: "Settings were deleted — restore from History", + }), + ]); expect(f.deliveries).toEqual([]); expect(goose.geese).toEqual([]); expectOnlyAllowed(f); diff --git a/src/features/agents/link.test.ts b/src/features/agents/link.test.ts index 97d3ef5c5c..6f84ba4c75 100644 --- a/src/features/agents/link.test.ts +++ b/src/features/agents/link.test.ts @@ -189,3 +189,23 @@ it("does not answer a call for a page that has since reloaded", async () => { release("slow"); await expect(answer).resolves.toBe("fast"); }); + +it("sends what a command changed before its result", async () => { + const count = store(1); + const { first, second } = pages(); + first({ + topics: { count: () => count }, + commands: { + bump: async () => { + await Promise.resolve(); + count.set(count.get() + 1); + return "done"; + }, + }, + }); + const manager = second(); + manager.watch("count", null, () => {}); + await vi.waitFor(() => expect(manager.read("count")).toBe(1)); + await expect(manager.call("bump")).resolves.toBe("done"); + expect(manager.read("count")).toBe(2); +}); diff --git a/src/features/agents/plugin-testing.ts b/src/features/agents/plugin-testing.ts index 6de02adcbc..68f587fe43 100644 --- a/src/features/agents/plugin-testing.ts +++ b/src/features/agents/plugin-testing.ts @@ -1,24 +1,103 @@ import type { PluginModule } from "../../plugins/api"; -import { presented, type AgentPresentation, type AgentType } from "./service"; +import type { RuntimeLink } from "./exposed"; +import { link, type LinkPort, type Served } from "./link"; +import type { AgentPresentation, AgentProvider, AgentType } from "./service"; +import { PRESENTED } from "./shown-types"; + +type Present = (runtime: RuntimeLink) => AgentPresentation; /** Starts a harness plugin's agent entry, then its UI entry, on `ctx`, and - * returns the type it registered as the service shows it, with what it - * presents. Undefined when it registered none. */ + * returns the type it registered with what it presents, its UI reaching the + * runtime over a link as from another page. Undefined when it registered + * none. */ export function appliedType( module: Pick, ctx: Readonly<{ agents: object }> & Record, ): AgentType | undefined { let type: AgentType | undefined; - let present: ((exposed: unknown) => AgentPresentation) | undefined; + let present: Present | undefined; Object.assign(ctx.agents, { register: (registered: AgentType) => { type = registered; }, - present: (_id: string, shown: typeof present) => { + present: (_id: string, shown: Present) => { present = shown; }, }); void module.agent?.apply(ctx as never); void module.apply(ctx as never); - return type && present ? presented(type, present(type.expose)) : type; + if (!type || !present) return type; + return merged(type, present(runtimeLink(type.expose))); +} + +/** What a type's UI reaches of `served`, over a link as from another page. + * Its messages pass as microtasks, so a macrotask later (`delivered`) each + * one sent before has arrived, with what it answered. */ +export function runtimeLink(served: Served | undefined): RuntimeLink { + const [runtime, ui] = ports(); + link(runtime, served); + return link(ui); +} + +/** Waits until what the links sent so far, and what that changed, has + * arrived. */ +export const delivered = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +function ports(): [LinkPort, LinkPort] { + const pair: LinkPort[] = []; + const port = (other: () => LinkPort | undefined): LinkPort => { + let closed = false; + return { + onmessage: null, + postMessage(message) { + // Only what a structured clone keeps crosses, as between pages. + const data = structuredClone(message); + queueMicrotask(() => { + const to = other(); + if (!closed) to?.onmessage?.({ data } as MessageEvent); + }); + }, + close() { + closed = true; + }, + }; + }; + pair.push( + port(() => pair[1]), + port(() => pair[0]), + ); + return pair as [LinkPort, LinkPort]; +} + +/** The type as the Manager shows it: what it registered, with what its UI + * entry presents. */ +function merged( + type: AgentType, + shown: AgentPresentation, +): AgentType { + const merged: AgentType = { ...type }; + for (const field of PRESENTED) + if (shown[field] !== undefined) + Object.assign(merged, { [field]: shown[field] }); + if (type.sessionControls && shown.sessionScope) + merged.sessionControls = { + ...type.sessionControls, + scope: shown.sessionScope, + }; + if (type.provider) { + // Keep getters, such as `attentionAction`, live. + const provider = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(type.provider), + ) as AgentProvider; + if (shown.provider?.DialogBody) + Object.assign(provider, { DialogBody: shown.provider.DialogBody }); + if (shown.provider?.DefaultControl) + Object.assign(provider, { + DefaultControl: shown.provider.DefaultControl, + }); + merged.provider = provider; + } + return merged; } diff --git a/src/features/agents/react.ts b/src/features/agents/react.ts index d51d2e78e7..5899060470 100644 --- a/src/features/agents/react.ts +++ b/src/features/agents/react.ts @@ -161,7 +161,7 @@ export function useMentionModels( const type = agent && types?.find((item) => item.key === agent.type); if (!agent || !type) return []; - const session = type.sessionControls?.scope(agent); + const session = type.sessionControls?.scope?.(agent); let found: Models = { current: "", models: [] }; try { if (type.models) found = await type.models(agent); @@ -201,7 +201,7 @@ export function useMentionModels( : key.split(":").flatMap((pubkey) => { const agent = snapshot?.agents.find((item) => item.pubkey === pubkey); const type = agent && types?.find((item) => item.key === agent.type); - const session = agent && type?.sessionControls?.scope(agent); + const session = agent && type?.sessionControls?.scope?.(agent); return agent && (type?.models || session) ? [ { diff --git a/src/features/agents/runner-link.test.ts b/src/features/agents/runner-link.test.ts index 79ef36e7d2..bc326d1ef6 100644 --- a/src/features/agents/runner-link.test.ts +++ b/src/features/agents/runner-link.test.ts @@ -1,7 +1,25 @@ import { afterEach, expect, it, vi } from "vitest"; -import { link, type Link } from "./link"; +import { link, type Link, type LinkPort, type Served } from "./link"; import { createLocalAgentActivity } from "./local-activity"; -import { linkedActivity, serveActivity } from "./runner-link"; +import { agentKey } from "./membership"; +import type { Synced } from "./agent-start"; +import type { RunnerStatus } from "./agents-runner"; +import { blank, type Member, type RecordEdit } from "./records"; +import { + linkedActivity, + managerSide, + routed, + runnerSide, + serveActivity, +} from "./runner-link"; +import type { + Agent, + RegisteredAgentType, + SessionControlAction, + SessionPlace, + SessionState, +} from "./service"; +import type { AgentRecord, Team } from "./store"; const open: Link[] = []; afterEach(() => { @@ -76,3 +94,689 @@ it("stops a turn and hides an error through the Runner", async () => { ), ); }); + +const owner = "b".repeat(64); +const member = (pubkey: string, name: string): Member => ({ + key: pubkey, + pubkey, + relay: "wss://relay.example", + owner, + type: "test/agent", + name, + deleted: false, +}); +const agentOf = (identity: Member) => + ({ + ...identity, + place: "here", + attention: {}, + skipped: {}, + timers: {}, + attentionEnabled: false, + config: { reply: "ok" }, + }) as unknown as Agent; + +/** A Runner that runs each membership it is sent, and marks those named in + * `left` interrupted. */ +function fakeRunner() { + const listeners = new Set<() => void>(); + let running: readonly Agent[] = []; + const left = new Set(); + const recordListeners = new Set<() => void>(); + let saved: Record = {}; + let status: RunnerStatus = { runs: [], yielded: [], transitioning: [] }; + let types: Record = {}; + let kinds: readonly RegisteredAgentType[] = []; + const typeListeners = new Set<() => void>(); + const runner = { + members: vi.fn( + (identities: readonly Member[], _viewer?: string, _loaded?: boolean) => { + const next = identities.map( + (identity) => + running.find( + (agent) => + agent.key === identity.key && agent.name === identity.name, + ) ?? agentOf(identity), + ); + const changed = + next.length !== running.length || + next.some((agent, index) => agent !== running[index]); + running = next; + if (changed) for (const listener of [...listeners]) listener(); + }, + ), + running: () => running, + interrupted: () => running.filter((agent) => left.has(agent.key)), + runnerStatus: () => status, + /** Another copy took an agent, or a change of type began or ended. */ + hold(next: RunnerStatus) { + status = next; + for (const listener of [...listeners]) listener(); + }, + edit: (key: string, edit: RecordEdit) => runner.records.edit(key, edit), + session: vi.fn( + async ( + _key: string, + _type: string, + _place: SessionPlace, + _action: SessionControlAction, + ) => {}, + ), + // Saves the config, and the type it is shown with, at once. + retype: vi.fn( + async (key: string, type: string, config: unknown, _publish: boolean) => { + await Promise.resolve(); + saved = { ...saved, [key]: { ...(saved[key] ?? blank(key)), config } }; + types = { ...types, [key]: type }; + for (const listener of [...recordListeners]) listener(); + }, + ), + retypes: () => types, + types: () => kinds, + setTeams: vi.fn((_teams: readonly Team[]) => {}), + setSynced: vi.fn((_synced: Readonly>) => {}), + resumeInterrupted: vi.fn((_keys: readonly string[]) => {}), + dismissInterrupted: vi.fn((_keys: readonly string[]) => {}), + /** The types that run here change, as when a plugin reloads. */ + setTypes(next: readonly RegisteredAgentType[]) { + kinds = next; + for (const listener of [...typeListeners]) listener(); + }, + subscribeTypes(listener: () => void) { + typeListeners.add(listener); + return () => { + typeListeners.delete(listener); + }; + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + records: { + all: () => Object.values(saved), + edit: vi.fn(async (key: string, edit: RecordEdit) => { + await Promise.resolve(); + if (edit.kind === "remove") { + const { [key]: _, ...rest } = saved; + saved = rest; + } else if (edit.kind === "create") + saved = { ...saved, [key]: edit.record }; + for (const listener of [...recordListeners]) listener(); + }), + }, + subscribeRecords(listener: () => void) { + recordListeners.add(listener); + return () => { + recordListeners.delete(listener); + }; + }, + }; + return { runner, left }; +} + +/** A new agent's settings, as its type gives them. */ +const defaults = vi.fn( + ({ pubkey }: Pick): AgentRecord => ({ + pubkey, + attention: {}, + config: { reply: "default" }, + }), +); + +function split(identities: Member[] = []) { + const { runner, left } = fakeRunner(); + const channel = new MessageChannel(); + let shown: Member[] = identities; + const changed = vi.fn(); + const retyped = vi.fn((_types: Readonly>) => {}); + const runnerLink = runnerSide(channel.port1, runner); + const shares = new Map(); + let teams: readonly Team[] = []; + let synced: Readonly> = {}; + const asks = { + createAgent: vi.fn(async () => ({ + requestId: "made", + saved: false as const, + })), + updateAgent: vi.fn(async () => ({ + requestId: "changed", + saved: false as const, + })), + templates: vi.fn(async () => [{ name: "Team", canvas: "", agents: [] }]), + publishType: vi.fn( + async (_key: string, _type: string, _config: unknown) => {}, + ), + finished: vi.fn((_key: string) => {}), + }; + const sharesChanged = new Set<() => void>(); + const manager = managerSide(channel.port2, { + members: () => ({ + loaded: true, + identities: shown, + viewer: owner, + teams, + synced, + }), + defaults, + retyped: (types) => retyped(types), + changed, + createAgent: asks.createAgent, + updateAgent: asks.updateAgent, + templates: asks.templates, + publishType: asks.publishType, + finished: asks.finished, + shared: (plugin) => shares.get(plugin), + subscribeShared(listener) { + sharesChanged.add(listener); + return () => sharesChanged.delete(listener); + }, + }); + open.push(runnerLink); + closing.push(manager); + return { + runner, + left, + manager, + runnerLink, + changed, + retyped, + asks, + tell( + next: Readonly<{ + teams?: readonly Team[]; + synced?: Readonly>; + }>, + ) { + teams = next.teams ?? teams; + synced = next.synced ?? synced; + manager.changed(); + }, + share(plugin: string, served: Served | undefined) { + if (served) shares.set(plugin, served); + else shares.delete(plugin); + for (const listener of [...sharesChanged]) listener(); + }, + show(next: Member[]) { + shown = next; + manager.changed(); + }, + }; +} +const closing: { close(): void }[] = []; +afterEach(() => { + for (const side of closing.splice(0)) side.close(); +}); + +it("lets the Runner decide from the Manager's memberships, and waits for it", async () => { + const ada = member("a".repeat(64), "Ada"); + const { runner, manager, show } = split(); + show([ada]); + await manager.decided(); + expect(runner.members).toHaveBeenLastCalledWith([ada], owner, true); + expect(manager.running()).toEqual([agentOf(ada)]); + expect(manager.agent(ada.key)?.name).toBe("Ada"); + expect(Object.isFrozen(manager.running()[0])).toBe(true); + // An unchanged list is not sent again, so there is nothing to wait for. + const calls = runner.members.mock.calls.length; + show([{ ...ada }]); + await manager.decided(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(runner.members).toHaveBeenCalledTimes(calls); +}); + +it("settles a wait even when nothing that runs changed", async () => { + const ada = member("a".repeat(64), "Ada"); + const { runner, manager, show } = split([ada]); + show([ada]); + await manager.decided(); + const shown = manager.running(); + // Deleted elsewhere and back: what runs is the same, the list is not. + show([{ ...ada, deleting: true }]); + await manager.decided(); + expect(runner.members).toHaveBeenLastCalledWith( + [{ ...ada, deleting: true }], + owner, + true, + ); + expect(manager.running()).toBe(shown); +}); + +it("keeps each agent the Runner did not change, and shows which were interrupted", async () => { + const ada = member("a".repeat(64), "Ada"); + const bo = member("c".repeat(64), "Bo"); + const { manager, left, show } = split(); + left.add(bo.key); + show([ada, bo]); + await manager.decided(); + const [first, second] = manager.running(); + expect(manager.interrupted()).toEqual([second]); + show([ada, { ...bo, name: "Bea" }]); + await manager.decided(); + expect(manager.running()[0]).toBe(first); + expect(manager.running()[1]).not.toBe(second); + expect(manager.running()[1]?.name).toBe("Bea"); + expect(manager.interrupted()).toEqual([manager.running()[1]]); +}); + +it("settles a wait when its side closes, since nothing decides then", async () => { + const { manager, show } = split(); + show([member("a".repeat(64), "Ada")]); + const waiting = manager.decided(); + manager.close(); + await expect(waiting).resolves.toBeUndefined(); + // Nor does it wait on what changes later. + show([member("c".repeat(64), "Bo")]); + await expect(manager.decided()).resolves.toBeUndefined(); +}); + +it("waits for the Runner to decide when it could not, rather than forever", async () => { + const ada = member("a".repeat(64), "Ada"); + const { runner, manager, show } = split(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + runner.members.mockImplementationOnce(() => { + throw new Error("broken type"); + }); + show([ada]); + await manager.decided(); + expect(error).toHaveBeenCalledWith( + "The Runner could not decide what runs", + expect.any(Error), + ); + error.mockRestore(); +}); + +it("does not take what the Runner decided for a page that reloaded as an answer", async () => { + const { runner } = fakeRunner(); + // The Runner's port, which reaches whichever Manager page is loaded. + let to: MessagePort | undefined; + const port: LinkPort = { + onmessage: null, + postMessage: (message) => to?.postMessage(message), + close() {}, + }; + const load = (identities: () => Member[]) => { + const channel = new MessageChannel(); + to = channel.port1; + channel.port1.onmessage = (event) => port.onmessage?.(event); + const page = managerSide(channel.port2, { + members: () => ({ + loaded: true, + identities: identities(), + viewer: owner, + teams: [], + synced: {}, + }), + defaults: () => undefined, + retyped: () => {}, + changed: () => {}, + createAgent: async () => ({ requestId: "", saved: false }), + updateAgent: async () => ({ requestId: "", saved: false }), + templates: async () => [], + publishType: async () => {}, + finished: () => {}, + shared: () => undefined, + subscribeShared: () => () => {}, + }); + closing.push(page); + return page; + }; + const ada = member("a".repeat(64), "Ada"); + const bo = member("c".repeat(64), "Bo"); + // Its first load sends two revisions. + let shown = [ada]; + const first = load(() => shown); + open.push(runnerSide(port, runner)); + first.changed(); + shown = [ada, bo]; + first.changed(); + await first.decided(); + expect(runner.members).toHaveBeenLastCalledWith([ada, bo], owner, true); + // The reloaded page's first revision is not the Runner's last one. + const second = load(() => [bo]); + second.changed(); + await second.decided(); + expect(runner.members).toHaveBeenLastCalledWith([bo], owner, true); + expect(second.running().map((agent) => agent.name)).toEqual(["Bo"]); +}); + +it("shows the Runner's records, and settles an edit once it is shown", async () => { + const ada = member("a".repeat(64), "Ada"); + const { runner, manager, show, changed } = split(); + show([ada]); + await manager.decided(); + // Until the Runner keeps one, its type's defaults, the same each time. + const first = manager.records.settingsOf(ada); + expect(first.config).toEqual({ reply: "default" }); + expect(manager.records.settingsOf(ada)).toBe(first); + expect(manager.records.keys()).toEqual([]); + const record = { pubkey: ada.key, attention: {}, config: { reply: "mine" } }; + changed.mockClear(); + await manager.records.edit(ada.key, { kind: "create", record }); + expect(runner.records.edit).toHaveBeenCalledWith(ada.key, { + kind: "create", + record, + }); + expect(changed).toHaveBeenCalled(); + expect(manager.records.keys()).toEqual([ada.key]); + const kept = manager.records.recordOf(ada); + expect(kept).toEqual(record); + // An unchanged record keeps its identity when another changes. + const bo = member("c".repeat(64), "Bo"); + await manager.records.edit(bo.key, { + kind: "create", + record: { ...record, pubkey: bo.key }, + }); + expect(manager.records.recordOf(ada)).toBe(kept); + await manager.records.edit(ada.key, { kind: "remove" }); + expect(manager.records.keys()).toEqual([bo.key]); +}); + +it("gives a membership outside its home its own attention and the agent's config", async () => { + const ada = member("a".repeat(64), "Ada"); + const away: Member = { + ...ada, + key: agentKey(ada.pubkey, "https://other.example", "https://relay.example"), + relay: "wss://other.example", + home: "wss://relay.example", + }; + const { manager, show } = split(); + show([ada, away]); + await manager.decided(); + await manager.records.edit(ada.key, { + kind: "create", + record: { pubkey: ada.key, attention: {}, config: { reply: "home" } }, + }); + expect(manager.records.recordOf(away)).toEqual({ + pubkey: away.key, + attention: {}, + config: { reply: "home" }, + }); +}); + +it("shows who holds each agent, keeping it while unchanged", async () => { + const ada = member("a".repeat(64), "Ada"); + const bo = member("c".repeat(64), "Bo"); + const { runner, manager, show } = split(); + show([ada]); + await manager.decided(); + expect(manager.runs(ada.key)).toBe(false); + runner.hold({ runs: [ada.key], yielded: [], transitioning: [] }); + await vi.waitFor(() => expect(manager.runs(ada.key)).toBe(true)); + const held = manager.status(); + expect(manager.yielded(ada.key)).toBe(false); + expect(manager.transitioning(ada.key)).toBe(false); + // What it decided changed, but not who holds what. + show([ada, bo]); + await manager.decided(); + expect(manager.status()).toBe(held); + runner.hold({ runs: [ada.key], yielded: [bo.key], transitioning: [ada.key] }); + await vi.waitFor(() => expect(manager.transitioning(ada.key)).toBe(true)); + expect(manager.yielded(bo.key)).toBe(true); +}); + +it("shows a retyped agent's new type only with its new config", async () => { + const ada = member("a".repeat(64), "Ada"); + const { runner, manager, show, retyped } = split(); + show([ada]); + await manager.decided(); + const seen: unknown[] = []; + retyped.mockImplementation((types) => + seen.push([types, manager.records.settingsOf(ada).config]), + ); + await manager.retype(ada.key, "example/other", { reply: "other" }, true); + expect(runner.retype).toHaveBeenCalledWith( + ada.key, + "example/other", + { reply: "other" }, + true, + ); + // Settled only once the type and its config were both shown. + expect(seen).toEqual([[{ [ada.key]: "example/other" }, { reply: "other" }]]); +}); + +it("asks the Runner to run a session control", async () => { + const ada = member("a".repeat(64), "Ada"); + const { runner, manager, show } = split(); + show([ada]); + await manager.decided(); + const place = { channelId: "c".repeat(64), root: "d".repeat(64) }; + await manager.session(ada.key, "example/echo", place, "compact"); + expect(runner.session).toHaveBeenCalledWith( + ada.key, + "example/echo", + place, + "compact", + ); +}); + +/** An agent type that runs here, as its plugin's agent entry registers it. */ +function echoType(revision = "one") { + let count = 1; + let state: SessionState = "idle"; + let readiness: "checking" | "ready" = "checking"; + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + const attend = vi.fn(); + const type: RegisteredAgentType = { + key: "example/echo", + id: "echo", + pluginId: "example", + revision, + title: "Echo", + defaults: () => ({ config: { reply: "ok", unset: undefined } }), + expose: { + topics: { count: () => ({ get: () => count, subscribe }) }, + commands: { + add: (more) => { + count += more as number; + notify(); + return count; + }, + }, + }, + models: async (agent) => ({ current: agent.name, models: [] }), + sessionControls: { + state: () => state, + run: async () => {}, + subscribe, + }, + provider: { + snapshot: () => ({ readiness }), + subscribe, + check: () => { + readiness = "ready"; + notify(); + }, + get attentionAction() { + return readiness === "ready" ? attend : undefined; + }, + }, + }; + return { + type, + attend, + compact() { + state = "compacting"; + notify(); + }, + }; +} +const hosted: RegisteredAgentType = { + key: "hosted/remote", + id: "remote", + pluginId: "hosted", + revision: "one", + title: "Hosted", + defaults: () => ({ config: {} }), + remote: {} as NonNullable, +}; + +it("tells the Manager only the types that run here, as JSON, and serves what each UI reaches", async () => { + const { runner, manager } = split(); + const echo = echoType(); + runner.setTypes([echo.type, hosted]); + await vi.waitFor(() => expect(manager.types()).toHaveLength(1)); + const [info] = manager.types(); + if (!info) throw new Error("no type"); + expect(info).toMatchObject({ + key: "example/echo", + revision: "one", + title: "Echo", + // A field the type left undefined does not cross. + defaults: { config: { reply: "ok" } }, + has: { models: true, usage: false, sessionControls: true, provider: true }, + }); + expect(info.defaults).toEqual({ config: { reply: "ok" } }); + const runtime = manager.runtime(info); + runtime.watch("count", null, () => {}); + await vi.waitFor(() => expect(runtime.read("count")).toBe(1)); + // A command answers after the values it changed. + await expect(runtime.call("add", 2)).resolves.toBe(3); + expect(runtime.read("count")).toBe(3); + await expect(runtime.call("missing")).rejects.toThrow("no command missing"); + await expect( + manager.models(info, agentOf(member("a".repeat(64), "Ada"))), + ).resolves.toEqual({ current: "Ada", models: [] }); + // A type that went away, or is now another revision, answers nothing. + runner.setTypes([echoType("two").type]); + await vi.waitFor(() => expect(manager.types()[0]?.revision).toBe("two")); + await expect(runtime.call("add", 1)).rejects.toThrow( + "That agent type is not available", + ); + runtime.close(); +}); + +it("shows a type's session state and provider from the Runner, and asks it to act", async () => { + const { runner, manager } = split(); + const echo = echoType(); + runner.setTypes([echo.type]); + await vi.waitFor(() => expect(manager.types()).toHaveLength(1)); + const [info] = manager.types(); + if (!info) throw new Error("no type"); + const ada = agentOf(member("a".repeat(64), "Ada")); + const place = { channelId: "channel" }; + const sessions = vi.fn(); + manager.watchSession(info, ada, place, sessions); + await vi.waitFor(() => + expect(manager.sessionState(info, ada, place)).toBe("idle"), + ); + echo.compact(); + await vi.waitFor(() => + expect(manager.sessionState(info, ada, place)).toBe("compacting"), + ); + expect(sessions).toHaveBeenCalled(); + manager.watchProvider(info, () => {}); + await vi.waitFor(() => + expect(manager.provider(info)).toEqual({ + snapshot: { readiness: "checking" }, + attend: false, + }), + ); + await manager.check(info); + expect(manager.provider(info)).toEqual({ + snapshot: { readiness: "ready" }, + attend: true, + }); + await manager.attend(info); + expect(echo.attend).toHaveBeenCalledOnce(); +}); + +it("gives a plugin's agent entry what its UI shares, from when it shares, and follows a new share", async () => { + const { share, runnerLink } = split(); + const reached = routed(runnerLink, "plugin", { plugin: "github" }); + const other = routed(runnerLink, "plugin", { plugin: "other" }); + closing.push(reached, other); + const changed = vi.fn(); + reached.watch("signIn", null, changed); + other.watch("signIn", null, () => {}); + // Nothing is shared yet: the topic reads as null, and calls fail. + await vi.waitFor(() => expect(reached.read("signIn")).toBeNull()); + await expect(reached.call("open", { target: "x" })).rejects.toThrow( + "The plugin's UI has no command open", + ); + let value = { credential: "one" }; + const listeners = new Set<() => void>(); + const opened = vi.fn((args: unknown) => args === null); + share("github", { + topics: { + signIn: () => ({ + get: () => value, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }), + }, + commands: { open: opened }, + }); + await vi.waitFor(() => + expect(reached.read("signIn")).toEqual({ credential: "one" }), + ); + expect(other.read("signIn")).toBeNull(); + await expect(reached.call("open")).resolves.toBe(true); + value = { credential: "two" }; + for (const listener of [...listeners]) listener(); + await vi.waitFor(() => + expect(reached.read("signIn")).toEqual({ credential: "two" }), + ); + // A plugin that starts again shares anew; the old share is not watched. + share("github", { + topics: { + signIn: () => ({ get: () => "again", subscribe: () => () => {} }), + }, + }); + await vi.waitFor(() => expect(reached.read("signIn")).toBe("again")); + expect(listeners.size).toBe(0); + share("github", undefined); + await vi.waitFor(() => expect(reached.read("signIn")).toBeNull()); + expect(changed).toHaveBeenCalled(); +}); + +it("tells the Runner the teams and synced settings, and takes what it asks of the Manager", async () => { + const { runner, runnerLink, manager, asks, tell } = split(); + const team: Team = { id: "t", name: "Crew", instructions: "", members: [] }; + const overlay: Synced = { + ok: true, + settings: { model: "big" }, + revisions: ["r1"], + }; + tell({ teams: [team], synced: { a: overlay } }); + await vi.waitFor(() => + expect(runner.setTeams).toHaveBeenLastCalledWith([team]), + ); + expect(runner.setSynced).toHaveBeenLastCalledWith({ a: overlay }); + const ada = agentOf(member("a".repeat(64), "Ada")); + await expect( + runnerLink.createAgent(ada, { type: "echo", name: "Bo" }), + ).resolves.toEqual({ requestId: "made", saved: false }); + expect(asks.createAgent).toHaveBeenCalledWith(ada, { + type: "echo", + name: "Bo", + }); + await expect( + runnerLink.updateAgent(ada, { agent: "Bo", name: "Cy" }), + ).resolves.toEqual({ requestId: "changed", saved: false }); + await expect(runnerLink.templates(ada)).resolves.toEqual([ + { name: "Team", canvas: "", agents: [] }, + ]); + await runnerLink.publishType(ada.key, "echo", { reply: "hi" }); + expect(asks.publishType).toHaveBeenCalledWith(ada.key, "echo", { + reply: "hi", + }); + runnerLink.finished(ada.key); + await vi.waitFor(() => expect(asks.finished).toHaveBeenCalledWith(ada.key)); + await manager.resume([ada.key]); + expect(runner.resumeInterrupted).toHaveBeenCalledWith([ada.key]); + await manager.dismiss([ada.key]); + expect(runner.dismissInterrupted).toHaveBeenCalledWith([ada.key]); +}); diff --git a/src/features/agents/runner-link.ts b/src/features/agents/runner-link.ts index a1c2fef57b..adf7dfc87c 100644 --- a/src/features/agents/runner-link.ts +++ b/src/features/agents/runner-link.ts @@ -1,8 +1,33 @@ // What the Runner serves the Manager over the agents link. Each name here is // part of the contract between the two owners, so add to it with care: only // JSON crosses, and either side may be a page that reloads. -import type { Json, Link, Served } from "./link"; +import type { AgentsRunner, RunnerStatus } from "./agents-runner"; +import { link, type Json, type Link, type LinkPort, type Served } from "./link"; import type { LocalAgentSource, LocalWork } from "./local-activity"; +import type { AgentKey } from "./membership"; +import { blank, type Member, type RecordEdit } from "./records"; +import type { AgentRecord } from "./store"; +import type { + Agent, + AgentProviderSnapshot, + RegisteredAgentType, + SessionControlAction, + SessionPlace, + SessionState, +} from "./service"; +import type { RuntimeLink } from "./exposed"; +import type { Synced } from "./agent-start"; +import type { SettingsSchema } from "./agent-config"; +import type { RunnerManager } from "./agents-runner"; +import type { Team } from "./store"; + +/** What the Runner asks of the Manager for an agent: requests the owner + * approves, the owner's channel templates, its synced settings saved as a new + * type's, and that a run of it ended. */ +type ManagerAsks = Pick< + RunnerManager, + "createAgent" | "updateAgent" | "templates" | "publishType" | "finished" +>; type WorkKey = Pick; @@ -63,3 +88,792 @@ export function linkedActivity(runner: Link) { }; return { source, stop }; } + +/** What the Runner decides from: the viewer's memberships, and the viewer, + * as of revision `revision` of the Manager page `from`. A page that reloads + * counts from 0 again, so a revision means nothing without its page. */ +type Members = Readonly<{ + from: string; + revision: number; + /** The list was read, so it names every membership on this device. */ + loaded: boolean; + identities: readonly Member[]; + viewer: string | null; + /** The owner's teams, which the Runner tells each member of. */ + teams: readonly Team[]; + /** What each membership starts with from the synced store, by key. */ + synced: Readonly>; +}>; +/** What the Runner decided from the memberships it was last sent, which + * were `revision` of the Manager page `from`: the agents running here, the + * keys of those with turns the app left working last time, and who holds + * each membership. */ +type RunnerState = Readonly<{ + from: string | null; + revision: number; + running: readonly Agent[]; + interrupted: readonly AgentKey[]; + status: RunnerStatus; +}>; +/** The records the Runner keeps, and the type of each agent it retyped that + * the memberships it was sent do not say yet, by pubkey. One value, so a new + * config never shows with the type it replaced. */ +type RunnerRecords = Readonly<{ + records: readonly AgentRecord[]; + types: Readonly>; +}>; + +/** What the Manager knows of an agent type that runs here, to show it with + * what its plugin's UI entry presents. `revision` names the registration, so + * what the Manager reaches of an earlier one is not taken for this one's. */ +export type TypeInfo = Readonly<{ + key: string; + id: string; + pluginId: string; + revision: string; + title: string; + description?: string; + /** What `defaults()` gave; absent when it threw. */ + defaults?: Json; + timeoutMs?: number; + resumes?: boolean; + /** The settings it syncs, if it syncs any. */ + syncedSettings?: SettingsSchema; + /** Which of these the type has. */ + has: Readonly<{ + run: boolean; + runOnce: boolean; + models: boolean; + usage: boolean; + sessionControls: boolean; + provider: boolean; + }>; +}>; +/** A type's provider status: its snapshot, and whether it handles its + * attention state itself. */ +export type ProviderStatus = Readonly<{ + snapshot: AgentProviderSnapshot; + attend: boolean; +}>; +type TypeArgs = Readonly<{ type: string; revision: string }>; + +function infoOf(type: RegisteredAgentType): TypeInfo { + let defaults: Json | undefined; + try { + defaults = JSON.parse(JSON.stringify(type.defaults())) as Json; + } catch (error) { + console.warn(`Agent type ${type.key} has invalid defaults`, error); + } + return { + key: type.key, + id: type.id, + pluginId: type.pluginId, + revision: type.revision, + title: type.title, + ...(type.description !== undefined && { description: type.description }), + ...(defaults !== undefined && { defaults }), + ...(type.timeoutMs !== undefined && { timeoutMs: type.timeoutMs }), + ...(type.resumes !== undefined && { resumes: type.resumes }), + ...(type.syncedSettings && { + syncedSettings: type.syncedSettings.schema, + }), + has: { + run: !!type.run, + runOnce: !!type.runOnce, + models: !!type.models, + usage: !!type.usage, + sessionControls: !!type.sessionControls, + provider: !!type.provider, + }, + }; +} +const nothing = { get: () => null, subscribe: () => () => {} }; + +/** What one side reaches of what the other serves under `route`, for `of` + * (a type, or a plugin), until `close`. */ +export function routed( + side: Link, + route: "type" | "plugin", + of: Readonly>, +): RuntimeLink & Readonly<{ close(): void }> { + const stops = new Set<() => void>(); + return { + read: (topic, args = null) => + side.read(`${route}.topic`, { ...of, topic, args }), + watch(topic, args, listener) { + const stop = side.watch( + `${route}.topic`, + { ...of, topic, args }, + listener, + ); + stops.add(stop); + return () => { + stops.delete(stop); + stop(); + }; + }, + call: (command, args = null) => + side.call(`${route}.call`, { ...of, command, args }), + close() { + for (const stop of stops) stop(); + stops.clear(); + }, + }; +} + +type Shared = Readonly<{ + /** What the plugin's UI entry shares with its agent entry, if it is + * active. */ + shared(plugin: string): Served | undefined; + /** Something any plugin shares changed. */ + subscribeShared(listener: () => void): () => void; +}>; + +/** A plugin's shared topic, followed from one share to the next: a plugin + * may share after its agent entry first watches, or start again. */ +function sharedTopic(host: Shared, args: Json) { + const { + plugin, + topic, + args: inner, + } = args as Readonly<{ + plugin: string; + topic: string; + args: Json; + }>; + let from: Served | undefined; + let made: ReturnType[string]> | undefined; + const current = () => { + const now = host.shared(plugin); + if (now !== from) { + from = now; + made = now?.topics?.[topic]?.(inner); + } + return made; + }; + return { + get: () => current()?.get() ?? null, + subscribe(listener: () => void) { + let seen = current(); + let stop = seen?.subscribe(listener); + const stopShares = host.subscribeShared(() => { + const now = current(); + if (now === seen) return; + stop?.(); + seen = now; + stop = now?.subscribe(listener); + listener(); + }); + return () => { + stopShares(); + stop?.(); + }; + }, + }; +} + +/** The Runner's side: it decides what runs from the memberships the Manager + * sends, and sends back what it decided. It also serves the agent types that + * run here: what they are, and what their UIs reach of them. */ +export function runnerSide( + port: LinkPort, + runner: Pick< + AgentsRunner, + | "members" + | "running" + | "interrupted" + | "runnerStatus" + | "subscribe" + | "subscribeRecords" + | "edit" + | "session" + | "retype" + | "retypes" + | "types" + | "subscribeTypes" + | "setTeams" + | "setSynced" + | "resumeInterrupted" + | "dismissInterrupted" + > & + Readonly<{ records: Pick }>, +) { + let from: string | null = null; + let revision = 0; + const decided = new Set<() => void>(); + // Only types that run here: a hosted one runs through its provider. + const local = () => runner.types().filter((type) => !type.remote); + const typeOf = (args: Json) => { + const { type, revision } = args as TypeArgs; + return local().find( + (entry) => entry.key === type && entry.revision === revision, + ); + }; + const kindOf = (args: Json) => { + const kind = typeOf(args); + if (!kind) throw new Error("That agent type is not available"); + return kind; + }; + const side = link(port, { + topics: { + "runner.state": () => ({ + get: () => + ({ + from, + revision, + running: runner.running(), + interrupted: runner.interrupted().map((agent) => agent.key), + status: runner.runnerStatus(), + }) satisfies RunnerState as unknown as Json, + subscribe: (listener) => { + const stop = runner.subscribe(listener); + decided.add(listener); + return () => { + stop(); + decided.delete(listener); + }; + }, + }), + "runner.types": () => ({ + get: () => local().map(infoOf) as unknown as Json, + subscribe: (listener) => runner.subscribeTypes(listener), + }), + "type.topic": (args) => { + const { topic, args: inner } = args as TypeArgs & + Readonly<{ topic: string; args: Json }>; + return typeOf(args)?.expose?.topics?.[topic]?.(inner) ?? nothing; + }, + "type.sessionState": (args) => { + const { agent, place } = args as unknown as Readonly<{ + agent: Agent; + place: SessionPlace; + }>; + const controls = typeOf(args)?.sessionControls; + if (!controls) return nothing; + return { + get: () => controls.state(agent, place) satisfies SessionState, + subscribe: (listener) => controls.subscribe(listener), + }; + }, + "type.provider": (args) => { + const provider = typeOf(args)?.provider; + if (!provider) return nothing; + return { + get: () => + ({ + snapshot: provider.snapshot(), + attend: !!provider.attentionAction, + }) satisfies ProviderStatus as unknown as Json, + subscribe: (listener) => provider.subscribe(listener), + }; + }, + "runner.records": () => ({ + get: () => + ({ + records: runner.records.all(), + types: runner.retypes(), + }) satisfies RunnerRecords as unknown as Json, + subscribe: (listener) => runner.subscribeRecords(listener), + }), + }, + commands: { + "records.edit": async (args) => { + const { key, edit } = args as unknown as Readonly<{ + key: AgentKey; + edit: RecordEdit; + }>; + await runner.edit(key, edit); + return null; + }, + "agents.session": async (args) => { + const { key, type, place, action } = args as unknown as Readonly<{ + key: AgentKey; + type: string; + place: SessionPlace; + action: SessionControlAction; + }>; + await runner.session(key, type, place, action); + return null; + }, + "type.call": async (args) => { + const { command, args: inner } = args as TypeArgs & + Readonly<{ command: string; args: Json }>; + const call = kindOf(args).expose?.commands?.[command]; + if (!call) throw new Error(`The agent type has no command ${command}`); + return (await call(inner)) ?? null; + }, + "type.models": async (args) => { + const { agent } = args as unknown as Readonly<{ agent: Agent }>; + const models = kindOf(args).models; + if (!models) throw new Error("This agent type lists no models"); + return (await models(agent)) as unknown as Json; + }, + "type.usage": async (args) => { + const { agent, since } = args as unknown as Readonly<{ + agent: Agent; + since: number; + }>; + const usage = kindOf(args).usage; + if (!usage) throw new Error("This agent type reports no usage"); + // The Manager stops waiting by itself; reading never changes a + // session, so it is left to finish. + const signal = new AbortController().signal; + return (await usage(agent, { since, signal })) as unknown as Json; + }, + "type.check": async (args) => { + await kindOf(args).provider?.check(); + return null; + }, + "type.attend": (args) => { + kindOf(args).provider?.attentionAction?.(); + return null; + }, + "agents.retype": async (args) => { + const { key, type, config, publish } = args as unknown as Readonly<{ + key: AgentKey; + type: string; + config: unknown; + publish: boolean; + }>; + await runner.retype(key, type, config, publish); + return null; + }, + "agents.resume": (keys) => { + runner.resumeInterrupted(keys as readonly AgentKey[]); + return null; + }, + "agents.dismiss": (keys) => { + runner.dismissInterrupted(keys as readonly AgentKey[]); + return null; + }, + }, + }); + const ask = async (command: string, args: unknown) => + (await side.call(command, args as Json)) as unknown; + side.watch("manager.members", null, () => { + const members = side.read("manager.members") as unknown as + | Members + | undefined; + if (!members) return; + runner.setTeams(members.teams); + runner.setSynced(members.synced); + try { + runner.members( + members.identities, + members.viewer ?? undefined, + members.loaded, + ); + } catch (error) { + console.error("The Runner could not decide what runs", error); + } + // Sent even when nothing that runs changed, or deciding failed, so the + // Manager does not wait on it. + from = members.from; + revision = members.revision; + for (const listener of [...decided]) listener(); + }); + const asks: ManagerAsks = { + createAgent: async (agent, input) => + (await ask("manager.createAgent", { agent, input })) as Awaited< + ReturnType + >, + updateAgent: async (agent, input) => + (await ask("manager.updateAgent", { agent, input })) as Awaited< + ReturnType + >, + templates: async (agent) => + (await ask("manager.templates", agent)) as Awaited< + ReturnType + >, + publishType: async (key, type, config) => { + await ask("manager.publishType", { key, type, config }); + }, + finished: (key) => { + ask("manager.finished", key).catch((error) => + console.warn("The Manager was not told a run ended", error), + ); + }, + }; + return Object.assign(side, asks); +} +/** The Manager's side: it sends the Runner the viewer's memberships, and + * shows what the Runner decided and the records it keeps. An agent or record + * keeps its identity until the Runner sends a change to it. */ +export function managerSide( + port: LinkPort, + host: Readonly<{ + members(): Omit; + /** The settings a new agent of this type starts with, if its type is + * here. */ + defaults( + identity: Pick, + ): AgentRecord | undefined; + /** The Runner gave these agents a type, by pubkey, that the + * memberships do not say yet. */ + retyped(types: Readonly>): void; + /** What the Runner decided, or a record, changed. */ + changed(): void; + }> & + ManagerAsks & + Shared, +) { + const { members, changed } = host; + const from = crypto.randomUUID(); + let revision = 0; + let closed = false; + let sent: string | undefined; + const listeners = new Set<() => void>(); + const none: RunnerStatus = Object.freeze({ + runs: [], + yielded: [], + transitioning: [], + }); + let state: Readonly<{ + revision: number; + running: readonly Agent[]; + interrupted: readonly Agent[]; + status: RunnerStatus; + }> = { + revision: 0, + running: Object.freeze([]), + interrupted: Object.freeze([]), + status: none, + }; + let statusJson = JSON.stringify(none); + const waits = new Set>(); + const shown = new Map(); + let records: Readonly> = {}; + const recordJson = new Map(); + // A new agent's defaults, until the Runner sends a record for it. + const filled = new Map(); + let types: readonly TypeInfo[] = Object.freeze([]); + const typeJson = new Map(); + const side = link(port, { + topics: { + "manager.members": () => ({ + get: () => + ({ + ...members(), + from, + revision, + }) satisfies Members as unknown as Json, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }), + "plugin.topic": (args) => sharedTopic(host, args), + }, + commands: { + "manager.createAgent": async (args) => { + const { agent, input } = args as unknown as Readonly<{ + agent: Agent; + input: Parameters[1]; + }>; + return (await host.createAgent(agent, input)) as unknown as Json; + }, + "manager.updateAgent": async (args) => { + const { agent, input } = args as unknown as Readonly<{ + agent: Agent; + input: Parameters[1]; + }>; + return (await host.updateAgent(agent, input)) as unknown as Json; + }, + "manager.templates": async (agent) => + (await host.templates(agent as unknown as Agent)) as unknown as Json, + "manager.publishType": async (args) => { + const { key, type, config } = args as unknown as Readonly<{ + key: AgentKey; + type: string; + config: unknown; + }>; + await host.publishType(key, type, config); + return null; + }, + "manager.finished": (key) => { + host.finished(key as AgentKey); + return null; + }, + "plugin.call": async (args) => { + const { + plugin, + command, + args: inner, + } = args as Readonly<{ + plugin: string; + command: string; + args: Json; + }>; + const call = host.shared(plugin)?.commands?.[command]; + if (!call) throw new Error(`The plugin's UI has no command ${command}`); + return (await call(inner)) ?? null; + }, + }, + }); + side.watch("runner.state", null, () => { + const next = side.read("runner.state") as unknown as + | RunnerState + | undefined; + if (!next) return; + const running = next.running.map((agent) => { + const json = JSON.stringify(agent); + const prior = state.running.find((item) => item.key === agent.key); + if (prior && shown.get(agent.key) === json) return prior; + shown.set(agent.key, json); + return Object.freeze(agent); + }); + for (const key of shown.keys()) + if (!running.some((agent) => agent.key === key)) shown.delete(key); + const left = new Set(next.interrupted); + const interrupted = running.filter((agent) => left.has(agent.key)); + const json = JSON.stringify(next.status); + const status = + json === statusJson ? state.status : Object.freeze(next.status); + statusJson = json; + state = { + // What it decided for an earlier load of this page is no answer. + revision: next.from === from ? next.revision : 0, + running: same(running, state.running) + ? state.running + : Object.freeze(running), + interrupted: same(interrupted, state.interrupted) + ? state.interrupted + : Object.freeze(interrupted), + status, + }; + for (const wait of [...waits]) + if (wait.revision <= state.revision) { + waits.delete(wait); + wait.resolve(); + } + changed(); + }); + side.watch("runner.types", null, () => { + const sent = side.read("runner.types") as unknown as + | readonly TypeInfo[] + | undefined; + if (!sent) return; + const next = sent.map((info) => { + const json = JSON.stringify(info); + const prior = types.find((entry) => entry.key === info.key); + if (prior && typeJson.get(info.key) === json) return prior; + typeJson.set(info.key, json); + return Object.freeze(info); + }); + for (const key of typeJson.keys()) + if (!next.some((info) => info.key === key)) typeJson.delete(key); + if ( + next.length === types.length && + next.every((info, index) => info === types[index]) + ) + return; + types = Object.freeze(next); + changed(); + }); + side.watch("runner.records", null, () => { + const sent = side.read("runner.records") as unknown as + | RunnerRecords + | undefined; + if (!sent) return; + const next: Record = {}; + for (const record of sent.records) { + const json = JSON.stringify(record); + const prior = records[record.pubkey]; + next[record.pubkey] = + prior && recordJson.get(record.pubkey) === json ? prior : record; + recordJson.set(record.pubkey, json); + filled.delete(record.pubkey); + } + for (const key of recordJson.keys()) if (!next[key]) recordJson.delete(key); + records = next; + if (Object.keys(sent.types).length) host.retyped(sent.types); + changed(); + }); + /** The agent's home record, which keeps its config: as the Runner keeps it + * or, without one, its type's defaults. */ + const settingsOf = (identity: Pick) => { + const { pubkey } = identity; + const kept = records[pubkey] ?? filled.get(pubkey); + if (kept) return kept; + const record = host.defaults(identity); + if (!record) return blank(pubkey); + filled.set(pubkey, record); + return record; + }; + return { + /** Gives each of `keys` again the turns it was working on. */ + async resume(keys: readonly AgentKey[]) { + await side.call("agents.resume", keys as unknown as Json); + }, + /** Forgets the turns the app left `keys` working on. */ + async dismiss(keys: readonly AgentKey[]) { + await side.call("agents.dismiss", keys as unknown as Json); + }, + /** The memberships may have changed: sends them if they did. */ + changed() { + const json = JSON.stringify(members()); + if (json === sent) return; + sent = json; + revision++; + for (const listener of [...listeners]) listener(); + }, + /** Settles once the Runner decided from the memberships sent so far. */ + decided() { + if (closed || state.revision >= revision) return Promise.resolve(); + return new Promise((resolve) => waits.add({ revision, resolve })); + }, + /** The agents running here, in every community. */ + running: () => state.running, + /** The running agents with turns the app left working last time. */ + interrupted: () => state.interrupted, + /** A running agent, in any community. */ + agent: (key: AgentKey) => state.running.find((agent) => agent.key === key), + /** Who holds each membership, kept while unchanged. */ + status: () => state.status, + /** Whether this copy runs the agent. */ + runs: (key: AgentKey) => state.status.runs.includes(key), + /** Whether another copy of the app runs an agent this one could. */ + yielded: (key: AgentKey) => state.status.yielded.includes(key), + /** Whether a change of type holds the agent's runner now. */ + transitioning: (key: AgentKey) => state.status.transitioning.includes(key), + /** Asks the Runner to run `action` on the session of the agent of type + * `type` in `place`. */ + async session( + key: AgentKey, + type: string, + place: SessionPlace, + action: SessionControlAction, + ) { + await side.call("agents.session", { + key, + type, + place, + action, + } as unknown as Json); + }, + /** The agent types that run here, each kept while unchanged. */ + types: () => types, + /** What the type's UI reaches of its runtime, until `close`. */ + runtime: (info: TypeInfo) => + routed(side, "type", { type: info.key, revision: info.revision }), + /** What the type's runtime calls on the Runner for an agent. */ + async models(info: TypeInfo, agent: Agent) { + return (await side.call("type.models", { + type: info.key, + revision: info.revision, + agent: agent as unknown as Json, + })) as unknown; + }, + async usage(info: TypeInfo, agent: Agent, since: number) { + return (await side.call("type.usage", { + type: info.key, + revision: info.revision, + agent: agent as unknown as Json, + since, + })) as unknown; + }, + /** Watches the session state of `agent` in `place` until the returned + * stop. */ + watchSession( + info: TypeInfo, + agent: Agent, + place: SessionPlace, + listener: () => void, + ) { + return side.watch( + "type.sessionState", + sessionArgs(info, agent, place), + listener, + ); + }, + sessionState: (info: TypeInfo, agent: Agent, place: SessionPlace) => + side.read("type.sessionState", sessionArgs(info, agent, place)) as + | SessionState + | null + | undefined, + /** Watches the type's provider status until the returned stop. */ + watchProvider: (info: TypeInfo, listener: () => void) => + side.watch( + "type.provider", + { type: info.key, revision: info.revision }, + listener, + ), + provider: (info: TypeInfo) => + side.read("type.provider", { + type: info.key, + revision: info.revision, + }) as ProviderStatus | null | undefined, + check: async (info: TypeInfo) => { + await side.call("type.check", { + type: info.key, + revision: info.revision, + }); + }, + attend: async (info: TypeInfo) => { + await side.call("type.attend", { + type: info.key, + revision: info.revision, + }); + }, + /** Asks the Runner to give the agent type `type`, starting with + * `config`, and with `publish` to save its synced settings as that + * type's first; settles once it is saved, and shown here. */ + async retype( + key: AgentKey, + type: string, + config: unknown, + publish: boolean, + ) { + await side.call("agents.retype", { + key, + type, + config, + publish, + } as unknown as Json); + }, + /** The records the Runner keeps, as it last sent them. */ + records: { + keys: () => Object.keys(records), + /** The record the Runner saved for `key`, if any. */ + saved: (key: AgentKey): AgentRecord | undefined => records[key], + settingsOf, + /** The membership's settings as its owner and agent see them: its own + * attention, starting with none, and the agent's config. */ + recordOf(member: Member): AgentRecord { + const settings = settingsOf(member); + if (member.key === member.pubkey) return settings; + const own = records[member.key] ?? blank(member.key); + return { ...own, config: settings.config }; + }, + /** Asks the Runner to apply `edit`; settles once it is saved, and + * shown here. */ + async edit(key: AgentKey, edit: RecordEdit) { + await side.call("records.edit", { + key, + edit, + } as unknown as Json); + }, + }, + close() { + closed = true; + side.close(); + // Nothing decides now. + for (const wait of waits) wait.resolve(); + waits.clear(); + }, + }; +} + +const sessionArgs = (info: TypeInfo, agent: Agent, place: SessionPlace) => + ({ + type: info.key, + revision: info.revision, + agent, + place, + }) as unknown as Json; + +const same = (next: readonly Agent[], prior: readonly Agent[]) => + next.length === prior.length && + next.every((agent, index) => agent === prior[index]); diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 3f44c4ee39..d4ff7c6656 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -30,6 +30,7 @@ import type { RelaySession } from "../relay/session"; import type { AgentIdentity } from "./native"; import { type Agent, + type Agents, AgentsService, type AgentType, type ConfigBinding, @@ -52,6 +53,7 @@ import { readInterrupted, writeInterrupted } from "./interrupted"; import { memoryStorage } from "./test-fakes"; import { SyncedTeams } from "./team-config"; import { STANDALONE_DEADLINE_MS } from "../agent-harness/standalone"; +import type { RuntimeLink } from "./exposed"; import { readTeams, writeTeams } from "./store"; import { bot, @@ -245,7 +247,6 @@ async function setup({ ...(sessionControls ? { sessionControls: { - scope: () => "thread" as const, subscribe: () => () => {}, ...sessionControls, }, @@ -254,11 +255,15 @@ async function setup({ ...(resumes ? { resumes } : {}), ...(syncedSettings ? { syncedSettings } : {}), }; - plugin.agents.register(type); + register(plugin.agents, type, sessionControls && (() => "thread" as const)); await vi.waitFor(() => expect(service.snapshot().status).toBe("ready"), { // Keep fake-clock advancement unchanged in the timer behavior cases. interval: vi.isFakeTimers() ? 50 : 1, }); + // An inactive plugin's type is shown once it is active. + if (pluginActive()) await typeShown(service, "example/echo"); + // And the Runner has the memberships it starts with. + await runnerHas(service); return { activity, service, @@ -290,20 +295,48 @@ async function setup({ }; } +/** Registers `type` as a harness plugin does: how it runs from its agent + * entry, and how it is managed (with `scope` for its session controls) from + * its UI entry. */ +function register( + agents: Pick, + type: AgentType, + scope?: (agent: Agent) => "thread" | "channel", +) { + const { adopt, carry, describeMigration, ...runs } = type; + agents.register(runs); + agents.present(type.id, () => ({ + ...(adopt && { adopt }), + ...(carry && { carry }), + ...(describeMigration && { describeMigration }), + ...(scope && { sessionScope: scope }), + })); +} + +/** Waits until the screens show the type `key`, which the Runner tells of + * over the link. */ +const typeShown = (service: AgentsService, key: string) => + vi.waitFor(() => { + const type = service.types().find((type) => type.key === key); + if (!type) throw new Error(`${key} is not shown yet`); + return type; + }); + /** Lets every pending promise chain finish: a macrotask runs only after the * microtask queue is empty, so this does not depend on how fast runners are. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); /** The service's synced config state, which tests set as the relay would. */ -const internals = (service: AgentsService) => { - const parts = service as unknown as { - runner: { configStore: AgentConfigStore }; +const internals = (service: AgentsService) => + service as unknown as { + configStore: AgentConfigStore; communityConfigs: Map; }; - return { - configStore: parts.runner.configStore, - communityConfigs: parts.communityConfigs, - }; -}; +/** Settles once the Runner has the synced state the Manager last sent it, + * which crosses the link a moment after the Manager has it. */ +const runnerHas = (service: AgentsService) => + ( + service as unknown as { decided: { decided(): Promise } } + ).decided.decided(); /** The events native published as an agent, of `kind`, in order. */ async function published(native: ReturnType, kind: number) { @@ -329,51 +362,98 @@ beforeEach(() => { }); afterEach(() => vi.useRealTimers()); -it("shows a type with what its plugin presents, from the handles the type exposes", async () => { +it("shows a type with what its plugin presents, its UI reaching what the type exposes over the link", async () => { const { service, plugin } = await setup(); const ready = { readiness: "ready" as const }; let attention: (() => void) | undefined; - const handles = { count: 1 }; - const shown = vi.fn((exposed: typeof handles) => ({ + let providerChanged = () => {}; + let count = 1; + let countChanged = () => {}; + const shown = vi.fn((runtime: RuntimeLink) => ({ icon: () => null, tabs: [{ id: "t", title: "T", component: () => null }], provider: { DialogBody: () => null }, // Only what a type shows is taken from `present`. run: () => {}, title: "Wrong", - exposed, + runtime, })); plugin.agents.register({ id: "shown", title: "Shown", defaults: () => ({ config: {} }), - expose: handles, + expose: { + topics: { + count: () => ({ + get: () => count, + subscribe: (listener) => { + countChanged = listener; + return () => {}; + }, + }), + }, + commands: { + add: (more) => { + count += more as number; + countChanged(); + return count; + }, + }, + }, provider: { snapshot: () => ready, - subscribe: () => () => {}, + subscribe: (listener) => { + providerChanged = listener; + return () => {}; + }, check: () => {}, get attentionAction() { return attention; }, }, }); - const runtimeOnly = service.types().find((t) => t.key === "example/shown"); - expect(runtimeOnly?.tabs).toBeUndefined(); + const runtimeOnly = await typeShown(service, "example/shown"); + expect(runtimeOnly.tabs).toBeUndefined(); plugin.agents.present("shown", shown as never); const type = service.types().find((t) => t.key === "example/shown"); - expect(shown).toHaveBeenCalledExactlyOnceWith(handles); + expect(shown).toHaveBeenCalledOnce(); expect(type).toMatchObject({ title: "Shown", tabs: [{ id: "t" }] }); expect(type?.run).toBeUndefined(); expect(type?.icon).toBeTypeOf("function"); expect(type?.provider?.DialogBody).toBeTypeOf("function"); - // The runtime's provider stays live, and readers see one stable type. + // The UI reads and changes the runtime over the link, never directly. + const runtime = shown.mock.calls[0]?.[0] as RuntimeLink; + runtime.watch("count", null, () => {}); + await vi.waitFor(() => expect(runtime.read("count")).toBe(1)); + await expect(runtime.call("add", 2)).resolves.toBe(3); + expect(runtime.read("count")).toBe(3); + // The provider's state, and its attention action, come from the Runner. + await vi.waitFor(() => expect(type?.provider?.snapshot()).toEqual(ready)); expect(type?.provider?.attentionAction).toBeUndefined(); - attention = () => {}; - expect(type?.provider?.attentionAction).toBe(attention); + attention = vi.fn(); + providerChanged(); + await vi.waitFor(() => + expect(type?.provider?.attentionAction).toBeTypeOf("function"), + ); + type?.provider?.attentionAction?.(); + await vi.waitFor(() => expect(attention).toHaveBeenCalledOnce()); + // Readers see one stable type. expect(service.types().find((t) => t.key === "example/shown")).toBe(type); expect(shown).toHaveBeenCalledOnce(); }); +it("refuses a type that runs here and gives what only its UI presents", async () => { + const { plugin } = await setup(); + expect(() => + plugin.agents.register({ + id: "mixed", + title: "Mixed", + defaults: () => ({ config: {} }), + summary: () => "Mixed", + }), + ).toThrow("gives summary from its plugin's present"); +}); + it("still runs a type whose present throws", async () => { const { service, plugin } = await setup(); const error = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -385,9 +465,9 @@ it("still runs a type whose present throws", async () => { plugin.agents.present("broken", () => { throw new Error("no UI"); }); - expect(service.types().find((t) => t.key === "example/broken")).toMatchObject( - { title: "Broken" }, - ); + expect(await typeShown(service, "example/broken")).toMatchObject({ + title: "Broken", + }); expect(error).toHaveBeenCalledOnce(); error.mockRestore(); }); @@ -1979,6 +2059,8 @@ describe("a type without synced settings", () => { const save = vi.fn(); internals(f.service).communityConfigs.set("https://relay.example.test", { save, + // Running agents are attached as the Runner reports them. + attach: async () => {}, } as never); await expect( f.service.save(bot, { config: { reply: "local", instructions: "x" } }), @@ -2024,6 +2106,8 @@ it("fails a Save its home's config channels didn't send, changing nothing here", const save = vi.fn(async () => ({})); internals(f.service).communityConfigs.set("https://relay.example.test", { save, + // Running agents are attached as the Runner reports them. + attach: async () => {}, } as never); await expect( f.service.save(bot, { config: { reply: "x", instructions: "mine" } }), @@ -2082,7 +2166,8 @@ it("gives running agents a remote instructions revision the way a local save doe config: { reply: "local", instructions: "from another device" }, configBase: { instructions: head.id }, }); - // The run then starts from it too. + // The run then starts from it too, once the Runner has it. + await runnerHas(f.service); f.emit({ events: [event("1", { tags: [["p", bot]] })] }); await vi.waitFor(() => expect(f.run).toHaveBeenCalledTimes(1)); expect(f.run.mock.calls[0]?.[0]).toMatchObject({ @@ -2134,6 +2219,7 @@ it("keeps a synced agent off its local row while no channel is bound to it", asy }), ); expect(f.service.find(bot)?.config).not.toHaveProperty("instructions"); + await runnerHas(f.service); f.emit({ events: [ event("1", { @@ -2177,6 +2263,7 @@ it("refuses an edit or a retype while the agent's config is moving, keeping the defaults: () => ({ config: {} }), run: vi.fn(), }); + await typeShown(f.service, "other/relay"); mode = "migrating"; const moving = "This agent's config is moving to the community; try again shortly"; @@ -2208,6 +2295,7 @@ it("keeps its type while its synced settings can't be saved as the new type's", defaults: () => ({ config: {} }), run: vi.fn(), }); + await typeShown(f.service, "other/relay"); // Its home's config isn't up, so its other devices couldn't follow. await expect(f.service.retype(bot, "other/relay")).rejects.toThrow( "Connect to the community to change its type", @@ -2292,6 +2380,7 @@ it("hands a run the synced settings it starts from, and refuses invalid ones", a ) as RelayEvent, }); revise({ effort: "max" }); + await runnerHas(f.service); f.emit({ events: [event("1", { tags: [["p", bot]] })] }); await vi.waitFor(() => expect(f.run).toHaveBeenCalledTimes(1)); expect(f.run.mock.calls[0]?.[0]).toMatchObject({ @@ -2300,6 +2389,7 @@ it("hands a run the synced settings it starts from, and refuses invalid ones", a }); // A value outside the type's allowlist never runs, nor falls back. revise({ effort: "xhigh" }); + await runnerHas(f.service); f.emit({ events: [ event("2", { @@ -2562,6 +2652,7 @@ it("hands out an agent's handle only to its new type's plugin once a change of t defaults: () => ({ config: {} }), run: vi.fn(), }); + await typeShown(service, "other/relay"); await vi.waitFor(() => expect(plugin.agents.handle(bot)).toBeDefined()); const kept = plugin.agents.handle(bot); let continueType = () => {}; @@ -2966,6 +3057,7 @@ describe("an agent in several communities", () => { defaults: () => ({ config: {} }), run: vi.fn(), }); + await typeShown(f.service, "other/relay"); await f.service.retype(bot, "other/relay"); expect(c.get(home)?.exclusive).toHaveBeenCalledWith( bot, @@ -2974,7 +3066,7 @@ describe("an agent in several communities", () => { expect(f.native.setType).toHaveBeenCalledWith(bot, "other/relay"); }); - const relayType = (f: Awaited>) => + const relayType = async (f: Awaited>) => { f.ctx .extend({ pluginOwner: Object.freeze({ id: "other", revision: "one" }), @@ -2985,12 +3077,14 @@ describe("an agent in several communities", () => { defaults: () => ({ config: {} }), run: vi.fn(), }); + await typeShown(f.service, "other/relay"); + }; it("attaches its config nowhere while its type changes, even where it comes up meanwhile", async () => { const c = channels("home synced"); const f = await joined(); await vi.waitFor(() => expect(c.get(home)?.attach).toHaveBeenCalled()); - relayType(f); + await relayType(f); // Home reconnects, and its coordinator isn't up yet. const make = coordinators.make; let up: (() => void) | undefined; @@ -3029,7 +3123,7 @@ describe("an agent in several communities", () => { const c = channels("home synced", "example/echo"); const f = await joined(); await vi.waitFor(() => expect(c.get(home)?.attach).toHaveBeenCalled()); - relayType(f); + await relayType(f); const store = internals(f.service).configStore; const opened = store.state(settings(home)).head?.id; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -3082,7 +3176,7 @@ describe("an agent in several communities", () => { const c = channels("home synced", "example/echo"); const f = await joined(); await vi.waitFor(() => expect(c.get(home)?.attach).toHaveBeenCalled()); - relayType(f); + await relayType(f); c.get(home)?.save.mockResolvedValueOnce({ settings: { status: "conflict" }, }); @@ -3095,7 +3189,7 @@ describe("an agent in several communities", () => { const c = channels("home synced", "example/echo"); const f = await joined(); await vi.waitFor(() => expect(c.get(home)?.attach).toHaveBeenCalled()); - relayType(f); + await relayType(f); const store = internals(f.service).configStore; const side = settings(home); store.applyRead(side, store.capture(side), { @@ -3115,7 +3209,7 @@ describe("an agent in several communities", () => { const c = channels("home synced", "example/echo"); const f = await joined(); await vi.waitFor(() => expect(c.get(home)?.attach).toHaveBeenCalled()); - relayType(f); + await relayType(f); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); f.native.setType.mockRejectedValueOnce(new Error("busy")); const store = internals(f.service).configStore; @@ -3430,12 +3524,13 @@ it("keeps an agent running while its community reconnects, and stops it on disco reconnecting(); expect(service.find(bot)).toBeUndefined(); expect(running()).toEqual([bot]); + // The screens see what runs once the Runner sends it over the link. connect(false); - expect(running()).toEqual([]); + await vi.waitFor(() => expect(running()).toEqual([])); connect(true); - expect(running()).toEqual([bot]); + await vi.waitFor(() => expect(running()).toEqual([bot])); leave("https://relay.example.test"); - expect(running()).toEqual([]); + await vi.waitFor(() => expect(running()).toEqual([])); expect(service.relay(bot)).toBeUndefined(); }); @@ -3632,9 +3727,10 @@ it("changes an agent's type between turns, carrying what the new type adopts", a const attention = service.find(bot)?.attention; await service.save(bot, { config: { reply: "Triage." } }); const other = vi.fn<(delivery: Delivery<{ prompt: string }>) => void>(); - ctx - .extend({ pluginOwner: Object.freeze({ id: "other", revision: "one" }) }) - .agents.register<{ prompt: string; model: string }>({ + register<{ prompt: string; model: string }>( + ctx.extend({ pluginOwner: Object.freeze({ id: "other", revision: "one" }) }) + .agents, + { id: "relay", title: "Relay", defaults: () => ({ config: { prompt: "", model: "small" } }), @@ -3660,7 +3756,8 @@ it("changes an agent's type between turns, carrying what the new type adopts", a { id: "destination", label: "Destination only", isOverride: false }, ], run: other, - }); + }, + ); // Not while a turn, or the work it handed off, is still going. let finish = () => {}; run.mockImplementationOnce(async ({ workingUntil }) => @@ -3685,6 +3782,7 @@ it("changes an agent's type between turns, carrying what the new type adopts", a { id: "sessions", label: "Sessions", outcome: "reset" }, ], }); + await typeShown(service, "other/relay"); expect(native.setType).not.toHaveBeenCalled(); await expect(service.retype(bot, "other/relay")).rejects.toThrow( "Echo is answering", @@ -4648,6 +4746,7 @@ it("keeps an agent created while identities are still loading", async () => { defaults: () => ({ config: { reply: "ok" } }), }); await vi.waitFor(() => expect(native.list).toHaveBeenCalled()); + await typeShown(service, "example/echo"); await service.create({ type: "example/echo", name: "Echo" }); release([]); await vi.waitFor(() => expect(service.snapshot().status).toBe("ready")); @@ -6318,7 +6417,9 @@ describe("a turn closing or reloading the app stopped", () => { expect(second.run).not.toHaveBeenCalled(); second.service.resumeInterrupted(); - expect(second.service.snapshot().interrupted).toEqual([]); + await vi.waitFor(() => + expect(second.service.snapshot().interrupted).toEqual([]), + ); await vi.waitFor(() => expect(second.run).toHaveBeenCalledOnce()); expect(resumedOf(second.run)).toEqual(["Echo, go on (resumed)"]); expect(second.run.mock.calls[0]?.[0]).toMatchObject({ channelId: channel }); @@ -6358,7 +6459,9 @@ describe("a turn closing or reloading the app stopped", () => { await settle(); expect(names(second.service.snapshot().interrupted)).toEqual(["Echo"]); second.service.dismissInterrupted(); - expect(second.service.snapshot().interrupted).toEqual([]); + await vi.waitFor(() => + expect(second.service.snapshot().interrupted).toEqual([]), + ); expect(readInterrupted(second.storage)).toEqual({}); second.service.resumeInterrupted(); await settle(); @@ -6428,10 +6531,12 @@ describe("a turn closing or reloading the app stopped", () => { await settle(); expect(resumedOf(app.run)).toEqual(["Echo, go on (resumed)"]); expect(names(app.service.snapshot().interrupted)).toEqual(["Birch"]); + const dismissed = { [bot]: { [asked.id]: channel } }; + expect(readInterrupted(app.storage)).not.toEqual(dismissed); app.service.dismissInterrupted(); - expect(readInterrupted(app.storage)).toEqual({ - [bot]: { [asked.id]: channel }, - }); + await vi.waitFor(() => + expect(readInterrupted(app.storage)).toEqual(dismissed), + ); }); it("stays saved when it cannot be read again, and is given on the next try", async () => { @@ -8761,15 +8866,18 @@ it("refuses drafts through a delivery handle its replaced type kept", async () = const f = await setup(); await f.service.create({ type: "example/echo", name: "Echo" }); await f.service.create({ type: "example/echo", name: "Child" }); - f.ctx - .extend({ pluginOwner: Object.freeze({ id: "other", revision: "one" }) }) - .agents.register<{ prompt: string }>({ + register<{ prompt: string }>( + f.ctx.extend({ + pluginOwner: Object.freeze({ id: "other", revision: "one" }), + }).agents, + { id: "relay", title: "Relay", defaults: () => ({ config: { prompt: "" } }), adopt: (carry, saved) => ({ prompt: carry.instructions ?? saved.prompt }), run: vi.fn(), - }); + }, + ); let delivery!: Delivery; const delivered = new Promise((resolve) => f.run.mockImplementationOnce((accepted) => { @@ -8787,6 +8895,7 @@ it("refuses drafts through a delivery handle its replaced type kept", async () = }), ], }); + await typeShown(f.service, "other/relay"); try { await delivered; await vi.waitFor(() => @@ -9105,7 +9214,7 @@ it("gives a run its owner's channel templates with the owner's agents each adds" }, ]); const { templates } = await handleOf(run, emit); - expect(templates()).toEqual([ + await expect(templates()).resolves.toEqual([ { name: "Launch", canvas: "# Plan", agents: [bot, sibling] }, ]); }); @@ -9320,6 +9429,7 @@ describe("Attention plugin sources", () => { expect(produce).not.toHaveBeenCalled(); active = true; for (const listener of listeners) listener(); + await typeShown(service, "example/echo"); await service.create({ type: "example/echo", name: "Echo" }); await vi.waitFor(() => expect(native.claim).toHaveBeenCalledWith(bot)); await service.save(bot, { attentionEnabled: true, attention: watches }); @@ -9981,9 +10091,10 @@ it("submits an unsaved change draft and applies only the owner's reviewed change const { service, native, plugin, ctx } = await setup(); await service.create({ type: "example/echo", name: "Parent" }); await service.create({ type: "example/echo", name: "Child" }); - ctx - .extend({ pluginOwner: Object.freeze({ id: "other", revision: "one" }) }) - .agents.register<{ prompt: string; model: string }>({ + register<{ prompt: string; model: string }>( + ctx.extend({ pluginOwner: Object.freeze({ id: "other", revision: "one" }) }) + .agents, + { id: "relay", title: "Relay", defaults: () => ({ config: { prompt: "", model: "small" } }), @@ -9993,7 +10104,8 @@ it("submits an unsaved change draft and applies only the owner's reviewed change }), carry: ({ prompt }) => ({ instructions: prompt }), run: vi.fn(), - }); + }, + ); await vi.waitFor(() => expect(plugin.agents.handle(bot)).toBeDefined()); const handle = plugin.agents.handle(bot); if (!handle) throw new Error("Missing handle"); @@ -10001,6 +10113,7 @@ it("submits an unsaved change draft and applies only the owner's reviewed change read: vi.fn(), memories: async () => ({ entries: [], partial: false }), }); + await typeShown(service, "other/relay"); const result = JSON.parse( (await callTool(client, {}, "agent_update", { agent: "Child", @@ -10660,8 +10773,10 @@ it.each(["abort", "community"])( } finally { release(); } + // The save answers over the Runner link, so the refusal can come first. + const refused = expect(pending).rejects.toThrow(); await ownerSave; - await expect(pending).rejects.toThrow(); + await refused; expect(service.find(bot)?.attention["interest/revoked"]).toBeUndefined(); }, ); @@ -10725,17 +10840,19 @@ it.each(["before", "during"])( "withholds connection memory when its agent type changes %s the read", async (phase) => { const { service, plugin, ctx, session } = await setup(); - ctx - .extend({ + register( + ctx.extend({ pluginOwner: Object.freeze({ id: "replacement", revision: "one" }), - }) - .agents.register({ + }).agents, + { id: "echo", title: "Replacement", defaults: () => ({ config: {} }), adopt: () => ({}), run: vi.fn(), - }); + }, + ); + await typeShown(service, "replacement/echo"); await service.create({ type: "example/echo", name: "Echo" }); await vi.waitFor(() => expect(plugin.agents.handle(bot)).toBeDefined()); let request!: import("../host/service").McpHandler; @@ -11583,7 +11700,7 @@ it.each(["spawn", "exit"])( it("lets an installed harness dispatch only its own hooks through the plugin API", async () => { const { plugin, ctx, type } = await setup(); - plugin.agents.register({ + register(plugin.agents, { ...type, id: "hooked", hooks: { events: ["BeforeTool"] }, @@ -11691,7 +11808,7 @@ it("reports unsupported hooks when their harness registers later", async () => { }); await fiber.await(); try { - plugin.agents.register({ + register(plugin.agents, { ...type, id: "later", hooks: { events: ["BeforeTool"] }, diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 0148ec7989..5ef64c5e09 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1,7 +1,7 @@ import type {} from "../navigation/service"; import { createLocalAgentActivity } from "./local-activity"; import { bindKeepAwake } from "./keep-awake"; -import { isSynced, resolveStart } from "./agent-start"; +import { applySynced, isSynced, resolveSynced } from "./agent-start"; import { type CommunityConfig, type ConfigAgent, @@ -31,8 +31,12 @@ import { type AgentHook, type AgentHookContext, } from "./hooks"; -import type { SettingsSchema, Subject } from "./agent-config"; -import type { Mode } from "./agent-start"; +import { + AgentConfigStore, + type SettingsSchema, + type Subject, +} from "./agent-config"; +import type { Mode, Synced } from "./agent-start"; import { TIMEOUT_LIMIT_MS } from "./runner"; // Agents: agents that are plugins from the start. A plugin registers an agent // type (its summary line, peek view, settings tabs and `run`); each agent made @@ -49,7 +53,6 @@ import { TIMEOUT_LIMIT_MS } from "./runner"; // can show it: as here, hosted, or on another device. import { agentStorage, type AgentStore } from "./state"; import { Service, type Context } from "@deepseek-ai/cordis"; -import { npubEncode } from "nostr-tools/nip19"; import { bytesToHex } from "nostr-tools/utils"; import type { ComponentType, ReactNode } from "react"; import type { DefinedIcon } from "../../shared/design-system/icons"; @@ -112,8 +115,15 @@ import { } from "./native"; import { agentKey, pubkeyOf, type AgentKey } from "./membership"; import { EMPTY, type Member } from "./records"; -import { AgentsRunner } from "./agents-runner"; +import { AgentsRunner, type RunnerStatus } from "./agents-runner"; import { RunnerPlugins } from "./runner-plugins"; +export { teamPrompt } from "./team-prompt"; +import { managerSide, routed, runnerSide, type TypeInfo } from "./runner-link"; +import type { RuntimeLink } from "./exposed"; +import type { Served } from "./link"; +import { PRESENTED, shownType } from "./shown-types"; + +type ManagerSide = ReturnType; import { originOf } from "./connections"; import { AgentPresence } from "./presence"; import { @@ -387,11 +397,13 @@ export type AgentHandle = Readonly<{ remember(slug: string, body: string, after: number): Promise; /** The owner's channel templates on this device, each with the agents it * adds that are the owner's agents in this agent's community. */ - templates(): readonly Readonly<{ - name: string; - canvas: string; - agents: readonly string[]; - }>[]; + templates(): Promise< + readonly Readonly<{ + name: string; + canvas: string; + agents: readonly string[]; + }>[] + >; /** The owner's thread tabs on this device in the agent's community; * undefined while it is not connected. */ tabs(): ReturnType | undefined; @@ -700,7 +712,8 @@ export type AgentType = { * `thread` it starts a thread of its own, with no session to act on. A DM or * Solo chat is one session either way. */ sessionControls?: { - scope(agent: Agent): "thread" | "channel"; + /** From the type's `present`. */ + scope?(agent: Agent): "thread" | "channel"; state(agent: Agent, place: SessionPlace): SessionState; run( agent: Agent, @@ -742,13 +755,14 @@ export type AgentType = { /** Makes the type hosted: its provider runs each agent, so it has no `run`, * `models` or `timeoutMs`, and its agents have no attention. */ remote?: RemoteAgentType; - /** Handles for the type's own UI, given to its plugin's `present`. Each is - * JSON, a store (`snapshot` and `subscribe`) or a call returning a promise, - * so that agents can later run in another page than their UI. */ - expose?: unknown; + /** The topics and commands the type's own UI reaches of its runtime, over + * the link its plugin's `present` is given. Only JSON crosses, as agents + * may run in another page than their UI. */ + expose?: Served; }; -/** What an agent type shows, from its plugin's UI entry (`present`); the - * rest of the type comes from its agent entry (`register`). */ +/** What an agent type shows and how its owner manages its agents, from its + * plugin's UI entry (`present`). A type that runs here gives the rest from + * its agent entry (`register`); a hosted type gives it all to `register`. */ export type AgentPresentation = Pick< AgentType, | "icon" @@ -756,46 +770,22 @@ export type AgentPresentation = Pick< | "Create" | "CreateModel" | "tabs" + | "adopt" | "importShared" | "exportShared" + | "carry" + | "describeMigration" + | "migrationWarning" + | "summary" > & Readonly<{ provider?: Pick; + /** The agent's session scope, for its session controls. */ + sessionScope?: NonNullable< + NonNullable["sessionControls"]>["scope"] + >; }>; -const PRESENTED = [ - "icon", - "Peek", - "Create", - "CreateModel", - "tabs", - "importShared", - "exportShared", -] as const; -/** The type as its readers see it: `type` with what `present` shows. */ -export function presented( - type: AgentType, - shown: AgentPresentation, -): AgentType { - const merged: AgentType = { ...type }; - for (const field of PRESENTED) - if (shown[field] !== undefined) - Object.assign(merged, { [field]: shown[field] }); - if (type.provider) { - // Keep getters, such as `attentionAction`, live. - const provider = Object.defineProperties( - {}, - Object.getOwnPropertyDescriptors(type.provider), - ) as AgentProvider; - if (shown.provider?.DialogBody) - Object.assign(provider, { DialogBody: shown.provider.DialogBody }); - if (shown.provider?.DefaultControl) - Object.assign(provider, { - DefaultControl: shown.provider.DefaultControl, - }); - merged.provider = provider; - } - return merged; -} + /** A hosted type's provider: it holds each agent's key and runs it. The app * calls these in order to create one (`provision`, then `activate`) and owns * the owner's agent list; every step is asked again after a failure, so each @@ -856,8 +846,9 @@ export type RemoteAgentType = Readonly<{ export type RegisteredAgentType = Contribution; type Presentation = Readonly<{ id: string; - present(exposed: unknown): AgentPresentation; + present(runtime: RuntimeLink): AgentPresentation; }>; +type Share = Readonly<{ id: string; served: Served }>; export type RetypePreview = Readonly<{ /** Destination settings after applying the target type's migration policy. */ config: unknown; @@ -1009,11 +1000,12 @@ export type Agents = { builtIn?: boolean; }>[]; register(type: AgentType): void; - /** Supplies what this plugin's type `typeId` shows, from its UI entry. - * `present` gets the type's `expose`. */ - present( + /** Supplies what this plugin's type `typeId` shows and how its agents are + * managed, from its UI entry. `present` gets the link to the type's + * runtime, which serves its `expose`. */ + present( typeId: string, - present: (exposed: Exposed) => AgentPresentation, + present: (runtime: RuntimeLink) => AgentPresentation, ): void; types(): readonly RegisteredAgentType[]; /** Adds a tab to the settings of every agent running on this device, @@ -1021,6 +1013,14 @@ export type Agents = { registerTab(tab: AgentAddedTab): void; /** The tabs active plugins add now. */ addedTabs(): readonly RegisteredAgentAddedTab[]; + + /** Shares `served` with this plugin's agent entry, from its UI entry: what + * its agents' tools need of the screens, such as a sign-in. Only JSON + * crosses, as between pages. A plugin shares once. */ + share(served: Served): void; + /** What this plugin's UI entry shares, reached from its agent entry. A + * topic reads as `null` while the UI shares none, and a call fails. */ + shared(): RuntimeLink; /** Adds a tool to every agent session in the app, beside the Buzz tools. * The tool acts as the agent, like the rest of a trusted plugin's code * acts as its owner. A session lists @@ -1226,42 +1226,6 @@ function defaults(pubkey: string, type: RegisteredAgentType) { } const message = (error: unknown) => String(error instanceof Error ? error.message : error); -/** The team sections for agent `self`: each team it is on, its members that - * still exist by name and npub, and the team's instructions. */ -export function teamPrompt( - teams: readonly Team[], - agents: readonly Pick[], - self: string, -) { - const names = new Map(agents.map((agent) => [agent.pubkey, agent.name])); - return teams - .filter((team) => team.members.includes(self)) - .map((team) => { - const roster = team.members.flatMap((pubkey) => { - const name = names.get(pubkey); - return name === undefined - ? [] - : [ - `- ${name} (${npubEncode(pubkey)})${pubkey === self ? ", you" : ""}`, - ]; - }); - return [ - "", - `You are on the team ${JSON.stringify(team.name)}. Its members, you included:`, - ...roster, - "Mention a teammate by name, with their npub, to reach them.", - ...(team.instructions.trim() - ? [ - "", - team.instructions.trim(), - "", - ] - : []), - "", - ].join("\n"); - }) - .join("\n\n"); -} export { fromOwner } from "./owner-attestation"; /** What Agents needs of the communities: the viewer, the selected one, and @@ -1382,16 +1346,23 @@ export class AgentsService extends Service implements Agents { private readonly tabContributions; private readonly presentations; - /** Each registered type with what it shows, worked out once per pair. */ - private readonly presentedTypes = new WeakMap< - RegisteredAgentType, - Readonly<{ from: Presentation; type: RegisteredAgentType }> + private readonly shares; + /** Each type that runs here as shown, with the presentation and link to + * its runtime it was shown with. */ + private readonly presentedTypes = new Map< + TypeInfo, + Readonly<{ + from: Contribution | undefined; + runtime: ReturnType | undefined; + shown: ReturnType; + }> >(); private merged: Readonly<{ - types: readonly RegisteredAgentType[]; + infos: readonly TypeInfo[]; + registered: readonly RegisteredAgentType[]; shown: readonly Contribution[]; result: readonly RegisteredAgentType[]; - }> = { types: [], shown: [], result: [] }; + }> = { infos: [], registered: [], shown: [], result: [] }; private readonly listeners = new Set<() => void>(); private requests: readonly AgentCreationRequest[] = []; private updates: readonly AgentUpdateRequest[] = []; @@ -1402,6 +1373,7 @@ export class AgentsService extends Service implements Agents { private teamList: readonly Team[]; /** Synced teams as discovery last found them, by home community. */ private readonly syncedTeams: SyncedTeams; + private readonly configStore: AgentConfigStore; /** The account whose synced teams `state.teams` shows. */ private teamsFor: string | undefined; /** Each new synced team's home, fixed at its first Save, by draft id. */ @@ -1413,8 +1385,19 @@ export class AgentsService extends Service implements Agents { private readonly runner: AgentsRunner; /** The tools and attention sources plugins add for agents: the Runner's. */ private readonly plugins: RunnerPlugins; + /** The Runner's side of the link between them. */ + private readonly runnerLink: ReturnType; + /** The Manager's side: what it sends the Runner, and what the Runner + * decided, as the link last sent it. */ + private readonly decided: ManagerSide; /** Counts list requests, so only the latest one is applied. */ private listed = 0; + /** Native's agent list was read, so `identities` is every membership. */ + private loaded = false; + /** Who holds each membership, and the agent types, as the screens last + * heard. */ + private held: RunnerStatus | undefined; + private shownTypes: readonly RegisteredAgentType[] | undefined; /** Each running cleanup of a deleted membership, by key, how to stop it, * and whether it finished. */ private cleaning = new Map< @@ -1462,11 +1445,7 @@ export class AgentsService extends Service implements Agents { const online = new Map>(); for (const agent of this.state.running) { const origin = originOf(agent.relay); - if ( - origin && - this.bindingOf(agent) && - this.runner.runners.runs(agent.key) - ) + if (origin && this.bindingOf(agent) && this.decided.runs(agent.key)) online.set( origin, (online.get(origin) ?? new Set()).add(agent.pubkey), @@ -1486,7 +1465,7 @@ export class AgentsService extends Service implements Agents { !identity.deleted && originOf(identity.relay) === origin, )?.key; - return !!key && this.runner.runners.yielded(key); + return !!key && this.decided.yielded(key); }, }); @@ -1510,32 +1489,56 @@ export class AgentsService extends Service implements Agents { this.contributions = createContributions(ctx); this.hookContributions = createAgentHooks(ctx); this.tabContributions = createContributions(ctx); + this.configStore = new AgentConfigStore(storage); this.presentations = createContributions(ctx); + this.shares = createContributions(ctx); this.runner = new AgentsRunner(storage, native, localActivity, decisions, { - ownershipChanged: () => this.notify(), changed: () => this.update(), open: (origin) => this.communities.open(origin), - types: () => this.types(), + types: () => this.contributions.snapshot(), defaults, - resolve: (agent, local) => this.resolve(agent, local), - finished: (key) => this.retakeType(key), + // What the Runner asks of the Manager crosses the link. + createAgent: (agent, input) => this.runnerLink.createAgent(agent, input), + updateAgent: (agent, input) => this.runnerLink.updateAgent(agent, input), + templates: (agent) => this.runnerLink.templates(agent), + publishType: (key, type, config) => + this.runnerLink.publishType(key, type, config), + finished: (key) => this.runnerLink.finished(key), navigation: ctx.navigation, - team: (key) => { - const member = this.liveMember(key); - const origin = member && originOf(member.relay); - // Only teammates in the same community, where it can reach them. - return member - ? teamPrompt( - this.allTeams(), - this.identities.filter( - (identity) => - !identity.deleted && originOf(identity.relay) === origin, - ), - member.pubkey, - ) - : ""; + }); + // The Runner decides from the memberships the Manager sends over the + // link, and the screens show what it sends back. Only JSON crosses, as it + // will between pages. + const channel = new MessageChannel(); + this.runnerLink = runnerSide(channel.port1, this.runner); + this.decided = managerSide(channel.port2, { + members: () => ({ + loaded: this.loaded, + identities: this.identities, + viewer: this.communities.snapshot().viewer ?? null, + teams: this.allTeams(), + synced: this.overlays(), + }), + defaults: ({ pubkey, type }) => { + const kind = this.types().find((entry) => entry.key === type); + if (kind) + try { + return defaults(pubkey, kind); + } catch (error) { + console.warn(`Agent type ${type} has invalid defaults`, error); + } + }, + // Shown with its new type once the Runner saved its config. + retyped: (types) => { + this.identities = this.identities.map((identity) => { + const type = types[identity.pubkey]; + return type && type !== identity.type + ? { ...identity, type } + : identity; + }); }, + changed: () => this.update(), createAgent: async (agent, input) => { const binding = this.bindingOf(agent); if (!binding || !this.liveMember(agent.key)) @@ -1612,7 +1615,7 @@ export class AgentsService extends Service implements Agents { this.notify(); return { requestId: id, saved: false }; }, - templates: (agent) => { + templates: async (agent) => { // The agents Agents shows for this community, as the create dialog // offers a template's. const origin = originOf(agent.relay); @@ -1630,7 +1633,15 @@ export class AgentsService extends Service implements Agents { agents: templateAgents(template, this.allTeams(), here), })); }, - classifier: () => this.classifier(), + publishType: async (key, type, config) => { + const identity = this.liveMember(key); + if (identity) await this.publishType(identity, type, config as object); + }, + finished: (key) => this.retakeType(key), + shared: (plugin) => + this.shares.snapshot().find((entry) => entry.pluginId === plugin) + ?.served, + subscribeShared: (listener) => this.shares.subscribe(listener), }); this.plugins = new RunnerPlugins(ctx, this.runner); if (native) @@ -1651,7 +1662,7 @@ export class AgentsService extends Service implements Agents { }); this.teamsChanged(); // A synced copy that changes reaches running agents as a local save does. - this.runner.configStore.subscribe(() => { + this.configStore.subscribe(() => { this.teamsChanged(); this.update(); }); @@ -1659,10 +1670,12 @@ export class AgentsService extends Service implements Agents { const stops = [ this.contributions.subscribe(() => { this.hookContributions.validate(this.contributions.snapshot()); + this.runner.typesChanged(); this.update(); this.notify(); }), this.presentations.subscribe(() => { + this.runner.typesChanged(); this.update(); this.notify(); }), @@ -1687,6 +1700,7 @@ export class AgentsService extends Service implements Agents { this.updates = updates; this.notify(); } + this.runner.communitiesChanged(viewer); this.watch(); // Synced teams are the signed-in account's. if (viewer !== this.teamsFor) this.teamsChanged(); @@ -1711,6 +1725,9 @@ export class AgentsService extends Service implements Agents { this.listed++; for (const stop of stops) stop(); for (const origin of [...this.bound.keys()]) this.unbind(origin); + // Nothing the link sends now may start runners again. + this.runnerLink.close(); + this.decided.close(); this.runner.clear(); this.presence.clear(); // Clear what is shown without reconciling, which would make new runners. @@ -1756,11 +1773,22 @@ export class AgentsService extends Service implements Agents { throw new Error( "A hosted agent type has provision, activate, profile and remove, and no run, models or timeoutMs", ); + // A type that runs here may run in another page than its UI. + const shown = remote + ? undefined + : (PRESENTED.find((field) => type[field] !== undefined) ?? + (type.sessionControls?.scope && "sessionControls.scope") ?? + (type.provider?.DialogBody && "provider.DialogBody") ?? + (type.provider?.DefaultControl && "provider.DefaultControl")); + if (shown) + throw new Error( + `An agent type that runs here gives ${shown} from its plugin's present`, + ); this.contributions.register(this.ctx, type as unknown as AgentType); } - present( + present( typeId: string, - present: (exposed: Exposed) => AgentPresentation, + present: (runtime: RuntimeLink) => AgentPresentation, ) { if (typeof present !== "function") throw new Error("present needs a function"); @@ -1769,31 +1797,67 @@ export class AgentsService extends Service implements Agents { present: present as Presentation["present"], }); } + share(served: Served) { + if (!served || typeof served !== "object") + throw new Error("share needs the topics and commands to share"); + this.shares.register(this.ctx, { id: "shared", served }); + } + shared() { + const plugin = this.ctx.pluginOwner?.id; + if (!plugin) throw new Error("Only a plugin reaches what its UI shares"); + const reached = routed(this.runnerLink, "plugin", { plugin }); + this.ctx.effect(() => () => reached.close()); + return reached as RuntimeLink; + } + /** Whether a plugin presents this type but the Runner has not sent it yet: + * the plugin is on and the type is on its way over the link. */ + typePending = (key: string) => + this.presentations.snapshot().some((entry) => entry.key === key) && + !this.types().some((type) => type.key === key); + /** The agent types as the screens and management see them: each that runs + * here as the Runner says, with what its plugin presents, then each hosted + * type as registered. */ types = () => { - const types = this.contributions.snapshot(); + const infos = this.decided.types(); + const registered = this.contributions.snapshot(); const shown = this.presentations.snapshot(); - if (types === this.merged.types && shown === this.merged.shown) + if ( + infos === this.merged.infos && + registered === this.merged.registered && + shown === this.merged.shown + ) return this.merged.result; - const result = Object.freeze( - types.map((type) => { - const from = shown.find((entry) => entry.key === type.key); - if (!from) return type; - const known = this.presentedTypes.get(type); - if (known?.from === from) return known.type; - let merged = type; - try { - merged = Object.freeze( - presented(type, from.present(type.expose)), - ) as RegisteredAgentType; - } catch (error) { - // Its agents still run; only what the type shows is missing. - console.error(`Agent type ${type.key} could not be shown`, error); - } - this.presentedTypes.set(type, { from, type: merged }); - return merged; - }), - ); - this.merged = { types, shown, result }; + const kept = new Set(); + const local = infos.map((info) => { + const from = shown.find((entry) => entry.key === info.key); + const known = this.presentedTypes.get(info); + kept.add(info); + if (known && known.from === from) return known.shown.type; + known?.shown.close(); + known?.runtime?.close(); + const runtime = from && this.decided.runtime(info); + let presentation: AgentPresentation | undefined; + try { + presentation = runtime && from?.present(runtime); + } catch (error) { + // Its agents still run; only what the type shows is missing. + console.error(`Agent type ${info.key} could not be shown`, error); + } + const made = shownType(info, this.decided, presentation); + this.presentedTypes.set(info, { from, runtime, shown: made }); + return made.type; + }); + for (const [info, known] of this.presentedTypes) + if (!kept.has(info)) { + known.shown.close(); + known.runtime?.close(); + this.presentedTypes.delete(info); + } + const result = Object.freeze([ + ...local, + ...registered.filter((type) => type.remote), + ]); + this.merged = { infos, registered, shown, result }; return result; }; registerTab(tab: AgentAddedTab) { @@ -1827,12 +1891,14 @@ export class AgentsService extends Service implements Agents { }; }; resumeInterrupted = () => { - this.runner.runners.resumeInterrupted(this.interruptedKeys()); - this.update(); + this.decided + .resume(this.interruptedKeys()) + .catch((error) => console.warn("Turns could not be resumed", error)); }; dismissInterrupted = () => { - this.runner.runners.dismissInterrupted(this.interruptedKeys()); - this.update(); + this.decided + .dismiss(this.interruptedKeys()) + .catch((error) => console.warn("Turns could not be dismissed", error)); }; /** The agents the notice names, and only they, are resumed or forgotten. */ private interruptedKeys() { @@ -2402,7 +2468,7 @@ export class AgentsService extends Service implements Agents { record = { ...record, config: made.config }; if (carry && kind.adopt) record = { ...record, config: kind.adopt(carry, record.config) }; - await this.runner.records.edit(identity.pubkey, { + await this.decided.records.edit(identity.pubkey, { kind: "create", record, }); @@ -2482,8 +2548,6 @@ export class AgentsService extends Service implements Agents { return this.exclusive(key, () => this.saveHosted(key, change)); if (!this.native) throw new Error("No such agent on this device"); const { pubkey } = identity; - if (change.config !== undefined && this.runner.runners.transitioning(key)) - throw new Error(`${identity.name}'s provider is already changing`); const name = change.name?.trim(); if (name === "") throw new Error("Name the agent"); // Synced sides save to the relay first: an edit that isn't saved there @@ -2491,7 +2555,7 @@ export class AgentsService extends Service implements Agents { if (change.config !== undefined) await this.saveSynced(identity, change.config as object, change.base); const { name: _, picture: __, base: ___, ...settings } = change; - await this.runner.records.edit(key, { + await this.decided.records.edit(key, { kind: "settings", member: identity, change: settings, @@ -2511,8 +2575,7 @@ export class AgentsService extends Service implements Agents { * names another type. */ private otherType(identity: AgentIdentity) { const settings = this.configBinding(identity).settings; - const head = - settings && this.runner.configStore.state(settings.subject).head; + const head = settings && this.configStore.state(settings.subject).head; const named = head && settingsType(head); return named && named !== identity.type ? `This agent's settings are for its ${named} type now; change its type here to edit them` @@ -2535,14 +2598,9 @@ export class AgentsService extends Service implements Agents { const subject = binding[side]?.subject; return ( subject && - isSynced(this.runner.configStore, binding[side]) && + isSynced(this.configStore, binding[side]) && applies(side) && - changes( - side, - agent, - config, - this.runner.configStore.state(subject).head, - ) + changes(side, agent, config, this.configStore.state(subject).head) ); }); if (!edited.length) return; @@ -2555,10 +2613,7 @@ export class AgentsService extends Service implements Agents { problem && edited.some((side) => { const subject = binding[side]?.subject; - return ( - !!subject && - this.runner.configStore.state(subject).state !== "present" - ); + return !!subject && this.configStore.state(subject).state !== "present"; }) ) throw new Error(problem); @@ -2629,11 +2684,7 @@ export class AgentsService extends Service implements Agents { sessionState(key: AgentKey, place: SessionPlace): SessionState { const agent = this.agent(key); const type = agent && this.types().find((kind) => kind.key === agent.type); - if ( - !agent || - !this.runner.runners.runs(key) || - this.runner.runners.transitioning(key) - ) + if (!agent || !this.decided.runs(key) || this.decided.transitioning(key)) return "unavailable"; return type?.sessionControls?.state(agent, place) ?? "unavailable"; } @@ -2654,12 +2705,7 @@ export class AgentsService extends Service implements Agents { throw new Error( "Use session controls in the Buzz app that runs this agent.", ); - await this.runner.runners.control(key, async () => { - const current = shown(); - if (current?.type !== agent.type || !this.types().includes(type)) - throw new Error("The agent's harness changed. Try again."); - await type.sessionControls?.run(current, place, action); - }); + await this.decided.session(key, agent.type, place, action); } /** Whether the owner can stop a hosted agent's turn: its provider can. A @@ -2720,7 +2766,7 @@ export class AgentsService extends Service implements Agents { throw new Error("A hosted provider cannot take over a local agent key"); if (type === identity.type) return []; this.refuseWhileMoving(identity); - const retyped = async () => { + return this.typeChange(identity, async () => { // Its home can move its settings in while custody is claimed. this.refuseWhileMoving(identity); const { adopted, preview } = this.retypePlan( @@ -2728,15 +2774,16 @@ export class AgentsService extends Service implements Agents { kinds.get(identity.type), kind, ); - const config = change ? change.config : adopted; // Its other devices take the type its synced settings name. - await this.publishType(identity, type, config as object); - await this.applyType(identity, type, config); + await this.decided.retype( + key, + type, + change ? change.config : adopted, + true, + ); + await this.refresh(); return preview.dropped; - }; - return this.runner.runners.retype(key, identity.name, () => - this.typeChange(identity, retyped), - ); + }); } /** Runs a change of `identity`'s type once its home's attach in flight * settles, with no attach of it anywhere until the change ends. */ @@ -2764,8 +2811,8 @@ export class AgentsService extends Service implements Agents { config: object, ) { const settings = this.configBinding(identity).settings; - if (!settings || !isSynced(this.runner.configStore, settings)) return; - const head = this.runner.configStore.state(settings.subject).head; + if (!settings || !isSynced(this.configStore, settings)) return; + const head = this.configStore.state(settings.subject).head; if (head && settingsType(head) === type) return; const live = this.communityConfigs.get(this.configHome(identity)); if (!live) throw new Error("Connect to the community to change its type"); @@ -2782,43 +2829,6 @@ export class AgentsService extends Service implements Agents { : "This agent's config isn't synced here yet; try again", ); } - /** Makes the agent `type` here with `config`, as its type's change saves. */ - private async applyType( - identity: AgentIdentity, - type: string, - config: unknown, - ) { - const native = this.native; - if (!native) throw new Error("No such agent on this device"); - const { pubkey } = identity; - await native.setType(pubkey, type); - try { - await this.runner.records.change( - pubkey, - (saved) => ({ - ...(saved ?? this.runner.records.settingsOf(identity)), - config, - }), - // Shown with its new type, only once its config is saved. - () => { - this.identities = this.identities.map((saved) => - saved.pubkey === pubkey ? { ...saved, type } : saved, - ); - }, - ); - } catch (error) { - try { - await native.setType(pubkey, identity.type); - } catch (rollback) { - throw new AggregateError( - [error, rollback], - `Could not save or restore ${identity.name}'s provider`, - ); - } - throw error; - } - await this.refresh(); - } /** Another device changed the agent's type: its synced settings name the * new one, which this device takes too when it has it. So does the device * that saved them when its own change failed after. Each head is tried @@ -2828,14 +2838,14 @@ export class AgentsService extends Service implements Agents { private followType(identity: Member) { const settings = this.configBinding(identity).settings; if (settings?.mode !== "synced") return; - const head = this.runner.configStore.state(settings.subject).head; + const head = this.configStore.state(settings.subject).head; const type = head && settingsType(head); if ( !head || !type || type === identity.type || this.followedType.get(identity.pubkey) === head.id || - this.runner.runners.transitioning(identity.key) || + this.decided.transitioning(identity.key) || // The change running now calls `update` as it ends. this.retyping.has(identity.pubkey) ) @@ -2850,14 +2860,11 @@ export class AgentsService extends Service implements Agents { this.types().find((candidate) => candidate.key === identity.type), kind, ); - const exclusive = () => - this.typeChange(identity, () => this.applyType(identity, type, adopted)); - // The copy that runs it holds its runners while its type changes. - void ( - this.runner.runners.runs(identity.key) - ? this.runner.runners.retype(identity.key, identity.name, exclusive) - : exclusive() - ).catch(failed); + // The Runner holds its runners while its type changes. + void this.typeChange(identity, async () => { + await this.decided.retype(identity.key, type, adopted, false); + await this.refresh(); + }).catch(failed); } /** A run of the agent ended: a type it couldn't take, as while it was @@ -2880,7 +2887,7 @@ export class AgentsService extends Service implements Agents { throw new Error("A hosted provider cannot take over a local agent key"); if (type === identity.type) return { - config: this.runner.records.settingsOf(identity).config, + config: this.decided.records.settingsOf(identity).config, moved: [], dropped: [], settings: [], @@ -3075,7 +3082,7 @@ export class AgentsService extends Service implements Agents { if (shown.has(pubkey) && member.key !== pubkey) continue; shown.set( pubkey, - this.view(member, this.runner.records.recordOf(member)), + this.view(member, this.decided.records.recordOf(member)), ); } return [...shown.values()]; @@ -3176,7 +3183,7 @@ export class AgentsService extends Service implements Agents { ); // Its home record keeps the agent's settings, but not what it paid // attention to there. - if (key === pubkey) await this.runner.records.edit(key, { kind: "leave" }); + if (key === pubkey) await this.decided.records.edit(key, { kind: "leave" }); await this.refresh(); } @@ -3336,8 +3343,7 @@ export class AgentsService extends Service implements Agents { return this.syncedTeams.all().flatMap(({ owner, community, team }) => { if (owner !== viewer) return []; const subject = teamArtifact(owner, community, team); - const head = - !team.archived && this.runner.configStore.state(subject).head; + const head = !team.archived && this.configStore.state(subject).head; const body = head && teamBody(head.content); return body ? [ @@ -3381,6 +3387,8 @@ export class AgentsService extends Service implements Agents { ) return; this.state = Object.freeze({ ...this.state, teams }); + // The Runner gets them with the memberships. + this.decided.changed(); this.notify(); } @@ -3420,10 +3428,15 @@ export class AgentsService extends Service implements Agents { private writeTeams(teams: readonly Team[]) { const saving = writeTeams(this.storage, teams); const shown = this.showTeams(teams); - return saving.catch((error: unknown) => { - if (this.teamList === shown) this.showTeams(readTeams(this.storage)); - throw error; - }); + return ( + saving + .catch((error: unknown) => { + if (this.teamList === shown) this.showTeams(readTeams(this.storage)); + throw error; + }) + // Settles once the Runner has them, so the next turn uses them. + .then(() => this.decided.decided()) + ); } private showTeams(teams: readonly Team[]) { this.teamList = Object.freeze(teams); @@ -3708,7 +3721,7 @@ export class AgentsService extends Service implements Agents { source: RegisteredAgentType | undefined, kind: RegisteredAgentType, ) { - const saved = this.runner.records.settingsOf(identity); + const saved = this.decided.records.settingsOf(identity); // Its synced fields are its config's, not the local copy's. const record = { ...saved, @@ -3820,9 +3833,8 @@ export class AgentsService extends Service implements Agents { channel: "", type: SIDE_TYPE[side], }; - const channel = this.runner.configStore.boundChannel(unbound); - return channel !== undefined || - this.runner.configStore.hadVersion(unbound) + const channel = this.configStore.boundChannel(unbound); + return channel !== undefined || this.configStore.hadVersion(unbound) ? { mode: "synced" as const, subject: { ...unbound, channel: channel ?? "" }, @@ -3836,56 +3848,70 @@ export class AgentsService extends Service implements Agents { }), ); } - private resolve( + /** What the agent's synced sides give its start, which the Runner lays + * over its own row as `resolve` does here. */ + private overlay( agent: Pick, - local: unknown, - ) { + ): Synced { const binding = this.configBinding(agent); const { settings, instructions } = binding; // Its config syncs only through its home membership: without one, no // channel keeps the confirmed copy current. if (Object.keys(binding).length && !this.homeRemains(agent.pubkey)) return { - ok: false as const, - state: "unavailable" as const, + ok: false, + state: "unavailable", reason: "This agent left the community that keeps its config", }; const merge = this.types().find( (type) => type.key === agent.type, )?.syncedSettings; - if (!merge && isSynced(this.runner.configStore, settings)) + if (!merge && isSynced(this.configStore, settings)) return { - ok: false as const, - state: "invalid" as const, + ok: false, + state: "invalid", reason: "This agent type has no synced settings", }; - return resolveStart({ - store: this.runner.configStore, - local: local as object, + return resolveSynced({ + store: this.configStore, agentType: agent.type, ...(settings && merge - ? { - settings: { - ...settings, - schema: merge.schema, - withSynced: merge.withSynced as ( - config: object, - synced: Readonly>, - ) => object, - }, - } + ? { settings: { ...settings, schema: merge.schema } } : {}), ...(instructions ? { instructions } : {}), teams: teamSources(this.syncedTeams, agent.owner, agent.pubkey), }); } + /** Each membership's overlay, which the Runner gets with them. */ + private overlays(): Record { + return Object.fromEntries( + this.identities + .filter((identity) => !identity.deleted) + .map((identity) => [identity.key, this.overlay(identity)]), + ); + } + private resolve( + agent: Pick, + local: unknown, + ) { + const merge = this.types().find( + (type) => type.key === agent.type, + )?.syncedSettings; + return applySynced( + local as object, + this.overlay(agent), + merge?.withSynced as + | ((config: object, synced: Readonly>) => object) + | undefined, + ); + } /** For an agent whose config syncs: its row with each synced side's * confirmed copy, as editors show it and running agents use it, and those * heads as the base a Save revises. Undefined while every side is local. */ private editorConfig(identity: AgentIdentity, local: object) { const binding = this.configBinding(identity); const sides = SIDES.filter((side) => - isSynced(this.runner.configStore, binding[side]), + isSynced(this.configStore, binding[side]), ); if (!sides.length) return undefined; const base: ConfigBase = Object.fromEntries( @@ -3893,7 +3919,7 @@ export class AgentsService extends Service implements Agents { const subject = binding[side]?.subject; return [ side, - (subject && this.runner.configStore.state(subject).head?.id) ?? null, + (subject && this.configStore.state(subject).head?.id) ?? null, ]; }), ); @@ -3910,7 +3936,8 @@ export class AgentsService extends Service implements Agents { ); return { config: Object.fromEntries( - Object.entries(local).filter(([name]) => !stale.has(name)), + // Unset until its type's defaults reach the Manager. + Object.entries(local ?? {}).filter(([name]) => !stale.has(name)), ), base: Object.freeze(base), problem: resolved.reason, @@ -3953,7 +3980,7 @@ export class AgentsService extends Service implements Agents { } /** A running agent, in any community. */ private agent(key: AgentKey) { - return this.runner.agent(key); + return this.decided.agent(key); } /** Reads the agent list from native. Every change is made there first and @@ -3971,20 +3998,12 @@ export class AgentsService extends Service implements Agents { // A read that succeeds ends the failure the last one reported. const { error: _, ...state } = this.state; this.state = Object.freeze({ ...state, status: "ready" }); - // Settings whose agent is gone, or that a community it left kept, do - // nothing. Its home record keeps its config while it is anywhere. + this.loaded = true; const live = new Set( identities .filter((saved) => !saved.deleted) .flatMap((saved) => [saved.key, saved.pubkey]), ); - const gone = this.runner.records.keys().filter((key) => !live.has(key)); - for (const key of gone) - void this.runner.records - .edit(key, { kind: "remove" }) - .catch((error) => - console.warn(`Agent ${key} settings were not removed`, error), - ); // Nor do change requests, which would hold review slots unseen. this.updates = this.updates.filter((request) => live.has(request.agent)); // A deleted agent leaves its teams; the teams stay. @@ -4011,6 +4030,8 @@ export class AgentsService extends Service implements Agents { this.update(); this.notify(); this.sync(); + // What runs is decided from this list. + await this.decided.decided(); } /** Brings the relay in line with the saved identities: publishes each name @@ -4110,9 +4131,6 @@ export class AgentsService extends Service implements Agents { * finish; lets go of the rest. */ private watch() { const { viewer, selected } = this.communities.snapshot(); - // The Runner's own sessions; each change to one shows and reconciles - // what runs again. - this.runner.watch(this.identities, viewer); const origins = new Set(); const shown = viewer && originOf(selected ?? ""); if (shown) origins.add(shown); @@ -4210,7 +4228,6 @@ export class AgentsService extends Service implements Agents { // Its sync reads the community's agent records too. if (changed && entry.binding) { this.sync(); - this.runner.refreshCore(origin); void this.startConfig(origin, entry.binding); } } @@ -4224,7 +4241,7 @@ export class AgentsService extends Service implements Agents { session: binding.session, owner: binding.viewer, community: origin, - store: this.runner.configStore, + store: this.configStore, teams: this.syncedTeams, info: (signal) => nativeRelayInfo(origin, signal), changed: () => { @@ -4256,13 +4273,14 @@ export class AgentsService extends Service implements Agents { } private update() { - const { viewer, selected } = this.communities.snapshot(); - // The Runner decides what runs; the screens show what it decided. - this.runner.update(this.identities, viewer); + const { selected } = this.communities.snapshot(); + // The Runner decides what runs from these; the screens show what it + // decided once the link sends it. + this.decided.changed(); const alive: Agent[] = []; for (const identity of this.identities) { if (identity.deleted || !this.alive(identity)) continue; - const record = this.runner.records.recordOf(identity); + const record = this.decided.records.recordOf(identity); // Reuse the frozen agent while nothing visible changed, so subscribers see // a stable snapshot: as the Runner runs it or, when that is not what is // shown, as it was shown. @@ -4285,10 +4303,7 @@ export class AgentsService extends Service implements Agents { const before = this.state.agents.find( (agent) => agent.key === identity.key && agent.place === "here", ); - alive.push( - [this.agent(identity.key), before].find(fits) ?? - this.view(identity, record), - ); + alive.push(before && fits(before) ? before : this.view(identity, record)); } const shown = originOf(selected ?? ""); const agents = [ @@ -4301,8 +4316,10 @@ export class AgentsService extends Service implements Agents { const views = new Map(alive.map((agent) => [agent.key, agent])); const asShown = (ran: readonly Agent[]) => ran.map((agent) => views.get(agent.key) ?? agent); - const running = asShown(this.runner.running()); - const interrupted = asShown(this.runner.interrupted()); + const running = asShown(this.decided.running()); + const interrupted = asShown(this.decided.interrupted()); + const held = this.decided.status(); + const types = this.types(); if ( !same(agents, this.state.agents) || !same(running, this.state.running) || @@ -4315,7 +4332,9 @@ export class AgentsService extends Service implements Agents { interrupted: Object.freeze(interrupted), }); this.notify(); - } + } else if (held !== this.held || types !== this.shownTypes) this.notify(); + this.held = held; + this.shownTypes = types; this.presence.announce(); for (const [origin, config] of this.communityConfigs) void config.attach( @@ -4329,7 +4348,7 @@ export class AgentsService extends Service implements Agents { .map((agent) => this.configAgent( agent, - (this.runner.records.saved(agent.pubkey) ?? agent) + (this.decided.records.saved(agent.pubkey) ?? agent) .config as object, ), ), diff --git a/src/features/agents/shown-types.ts b/src/features/agents/shown-types.ts new file mode 100644 index 0000000000..cd6226c3bf --- /dev/null +++ b/src/features/agents/shown-types.ts @@ -0,0 +1,186 @@ +// An agent type as the Manager shows it: what the Runner says of the type +// (`TypeInfo`), with what its plugin's UI entry presents. What needs the +// runtime (models, usage, session state, the provider's status) is asked of +// the Runner over the link. +import { withSynced } from "./agent-config"; +import { abortable } from "./exposed"; +import type { managerSide, ProviderStatus, TypeInfo } from "./runner-link"; +import type { + Agent, + AgentPresentation, + AgentProvider, + AgentType, + Models, + RegisteredAgentType, + SessionPlace, + SessionState, + SessionUsage, +} from "./service"; + +type Runner = Pick< + ReturnType, + | "models" + | "usage" + | "session" + | "sessionState" + | "watchSession" + | "provider" + | "watchProvider" + | "check" + | "attend" +>; + +/** What only `present` gives for a type that runs here. */ +export const PRESENTED = [ + "icon", + "Peek", + "Create", + "CreateModel", + "tabs", + "adopt", + "importShared", + "exportShared", + "carry", + "describeMigration", + "migrationWarning", + "summary", +] as const; + +const CHECKING = Object.freeze({ readiness: "checking" as const }); + +/** The type the Manager's readers see, until `close`. */ +export function shownType( + info: TypeInfo, + runner: Runner, + shown: AgentPresentation | undefined, +): Readonly<{ type: RegisteredAgentType; close(): void }> { + const stops = new Set<() => void>(); + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + // One watch for each agent and place read, of the agent as last read. + const sessions = new Map>(); + const state = (agent: Agent, place: SessionPlace): SessionState => { + const id = `${agent.key}\n${JSON.stringify(place)}`; + const json = JSON.stringify(agent); + const watched = sessions.get(id); + if (watched?.agent !== json) { + if (watched) { + stops.delete(watched.stop); + watched.stop(); + } + const stop = runner.watchSession(info, agent, place, notify); + stops.add(stop); + sessions.set(id, { agent: json, stop }); + } + return runner.sessionState(info, agent, place) ?? "unavailable"; + }; + let status: ProviderStatus | undefined; + if (info.has.provider) + stops.add( + runner.watchProvider(info, () => { + status = runner.provider(info) ?? undefined; + notify(); + }), + ); + const provider: AgentProvider | undefined = info.has.provider + ? { + snapshot: () => status?.snapshot ?? CHECKING, + subscribe, + check: () => runner.check(info), + get attentionAction() { + return status?.attend ? () => void runner.attend(info) : undefined; + }, + ...(shown?.provider?.DialogBody && { + DialogBody: shown.provider.DialogBody, + }), + ...(shown?.provider?.DefaultControl && { + DefaultControl: shown.provider.DefaultControl, + }), + } + : undefined; + // Only what a type shows and how it is managed comes from `present`. + const presented: Partial = {}; + for (const field of PRESENTED) + if (shown?.[field] !== undefined) + Object.assign(presented, { [field]: shown[field] }); + const sessionScope = shown?.sessionScope; + const type: AgentType = { + ...presented, + id: info.id, + title: info.title, + ...(info.description !== undefined && { description: info.description }), + ...(info.timeoutMs !== undefined && { timeoutMs: info.timeoutMs }), + ...(info.resumes !== undefined && { resumes: info.resumes }), + ...(info.syncedSettings && { + syncedSettings: { + schema: info.syncedSettings, + // As each type lays them over its row: a synced field it leaves out + // takes the type's default. + withSynced: ( + config: object, + settings: Readonly>, + ) => + withSynced( + info.syncedSettings ?? {}, + ((info.defaults as { config?: object } | undefined)?.config ?? + {}) as object, + config, + settings, + ), + }, + }), + defaults: () => { + if (info.defaults === undefined) + throw new Error(`Agent type ${info.key} has invalid defaults`); + return structuredClone(info.defaults) as ReturnType< + AgentType["defaults"] + >; + }, + ...(info.has.models && { + models: async (agent: Agent) => + (await runner.models(info, agent)) as Models, + }), + ...(info.has.usage && { + usage: async ( + agent: Agent, + { since, signal }: Readonly<{ since: number; signal: AbortSignal }>, + ) => + (await abortable( + runner.usage(info, agent, since), + signal, + )) as readonly SessionUsage[], + }), + ...(info.has.sessionControls && { + sessionControls: { + ...(sessionScope && { scope: sessionScope }), + state, + run: (agent, place, action) => + runner.session(agent.key, info.key, place, action), + subscribe, + }, + }), + ...(provider && { provider }), + }; + return { + type: Object.freeze({ + ...type, + key: info.key, + pluginId: info.pluginId, + revision: info.revision, + }) as RegisteredAgentType, + close() { + for (const stop of stops) stop(); + stops.clear(); + sessions.clear(); + listeners.clear(); + }, + }; +} diff --git a/src/features/agents/team-prompt.ts b/src/features/agents/team-prompt.ts new file mode 100644 index 0000000000..d09d3ad078 --- /dev/null +++ b/src/features/agents/team-prompt.ts @@ -0,0 +1,39 @@ +import { npubEncode } from "nostr-tools/nip19"; +import type { Team } from "./store"; + +/** The team sections for agent `self`: each team it is on, its members that + * still exist by name and npub, and the team's instructions. */ +export function teamPrompt( + teams: readonly Team[], + agents: readonly Readonly<{ pubkey: string; name: string }>[], + self: string, +) { + const names = new Map(agents.map((agent) => [agent.pubkey, agent.name])); + return teams + .filter((team) => team.members.includes(self)) + .map((team) => { + const roster = team.members.flatMap((pubkey) => { + const name = names.get(pubkey); + return name === undefined + ? [] + : [ + `- ${name} (${npubEncode(pubkey)})${pubkey === self ? ", you" : ""}`, + ]; + }); + return [ + "", + `You are on the team ${JSON.stringify(team.name)}. Its members, you included:`, + ...roster, + "Mention a teammate by name, with their npub, to reach them.", + ...(team.instructions.trim() + ? [ + "", + team.instructions.trim(), + "", + ] + : []), + "", + ].join("\n"); + }) + .join("\n\n"); +} diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx index acdc7f179a..3fe6026dc9 100644 --- a/src/features/panels/service.test.tsx +++ b/src/features/panels/service.test.tsx @@ -40,6 +40,12 @@ it("independently unloads panels without removing the page or shared data", asyn snapshot: () => ({ agents: [], running: [], interrupted: [] }), addTool: () => {}, addAttentionSource: () => {}, + share: () => {}, + shared: () => ({ + read: () => undefined, + watch: () => () => {}, + call: async () => null, + }), }); root.provide("communityReader", { subscribe: () => () => {}, diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 5da93a7163..69a26e39ac 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -114,6 +114,7 @@ export type { Agents, AgentAddedTab, AgentType, + AgentPresentation, AgentSession, AgentSessions, } from "../features/agents/service"; @@ -133,3 +134,6 @@ export type { SoloCardProps, SoloDisclosureProps, } from "../features/channel-navigation/solo-navigation-contract"; + +export type { RuntimeLink } from "../features/agents/exposed"; +export type { Json, Served, Topic, Command } from "../features/agents/link"; diff --git a/tests/browser/conversation-name.fixture.tsx b/tests/browser/conversation-name.fixture.tsx index 349a179b76..54d8461911 100644 --- a/tests/browser/conversation-name.fixture.tsx +++ b/tests/browser/conversation-name.fixture.tsx @@ -1,5 +1,6 @@ // The real Claude Activity tab, with a long private channel among its saved -// conversations. Activity reads only saved sessions and the channel labels. +// conversations. Activity reads only saved sessions and the channel labels, +// over the link from the runtime as the app's UI does. import { createRoot } from "react-dom/client"; import { ClaudeDefaults } from "../../src/bundled/claude-code/defaults"; import { @@ -8,7 +9,9 @@ import { DEFAULT_CONFIG, } from "../../src/bundled/claude-code/runtime"; import { ClaudeSetup } from "../../src/bundled/claude-code/setup"; +import { linkClaude, serveClaude } from "../../src/bundled/claude-code/exposed"; import { createTabs } from "../../src/bundled/claude-code/tabs"; +import { runtimeLink } from "../../src/features/agents/plugin-testing"; import type { AgentReads } from "../../src/features/agents/reads"; import type { Agent } from "../../src/features/agents/service"; import "../../src/shared/styles/globals.css"; @@ -29,12 +32,19 @@ const reads = { id === "planning" ? { name: LONG, private: true, channelType: "stream" } : { name: "general", channelType: "stream" }, + subscribeChannels: () => () => {}, } as unknown as AgentReads; -const { ActivityTab } = createTabs({ - setup: new ClaudeSetup(undefined), - defaults: new ClaudeDefaults(), - runtime: new ClaudeRuntime({} as never, () => reads, {} as never), -}); +const { ActivityTab } = createTabs( + linkClaude( + runtimeLink( + serveClaude( + new ClaudeSetup(undefined), + new ClaudeDefaults(), + new ClaudeRuntime({} as never, () => reads, {} as never), + ), + ), + ), +); const width = Number(new URLSearchParams(location.search).get("width")); const root = document.getElementById("root"); diff --git a/tests/browser/local-agents-fixture.ts b/tests/browser/local-agents-fixture.ts index 2bdce8f0d9..6c3645fd16 100644 --- a/tests/browser/local-agents-fixture.ts +++ b/tests/browser/local-agents-fixture.ts @@ -98,12 +98,45 @@ export const localAgentPlugin: BundledPlugin = { enabledByDefault: true, module: { inject: ["agents", "relay"], + // What runs is the agent entry's; what the screens show is the UI's. + agent: { + inject: ["agents"], + apply(ctx) { + ctx.agents.register({ + id: "tester", + title: "Local tester", + defaults: () => ({ config: {} }), + run(delivery) { + if (delivery.trigger.type !== "mention") + throw new Error("Unexpected trigger"); + let handover!: () => void, finish!: (result: WorkOutcome) => void; + const done = new Promise((resolve) => { + finish = resolve; + }); + delivery.workingUntil?.(done); + const transferred = new Promise((resolve) => { + handover = resolve; + }); + const id = delivery.trigger.event.id; + // Like a harness, the stop ends the turn as it settles. + delivery.interruptible?.(async () => { + stopped.push(id); + finish({ ok: true, stopped: true }); + }); + pending.push({ + id, + handover, + step: (tool, target) => delivery.step?.(tool, target), + finish, + }); + return transferred; + }, + }); + }, + }, apply(ctx) { - ctx.agents.register({ - id: "tester", - title: "Local tester", - defaults: () => ({ config: {} }), - // The remote agent type's Activity tab, so published turns can be seen. + // The remote agent type's Activity tab, so published turns can be seen. + ctx.agents.present("tester", () => ({ tabs: [ { id: "activity", @@ -112,38 +145,17 @@ export const localAgentPlugin: BundledPlugin = { component: createActivityTab(ctx.relay), }, ], - run(delivery) { - if (delivery.trigger.type !== "mention") - throw new Error("Unexpected trigger"); - let handover!: () => void, finish!: (result: WorkOutcome) => void; - const done = new Promise((resolve) => { - finish = resolve; - }); - delivery.workingUntil?.(done); - const transferred = new Promise((resolve) => { - handover = resolve; - }); - const id = delivery.trigger.event.id; - // Like a harness, the stop ends the turn as it settles. - delivery.interruptible?.(async () => { - stopped.push(id); - finish({ ok: true, stopped: true }); - }); - pending.push({ - id, - handover, - step: (tool, target) => delivery.step?.(tool, target), - finish, - }); - return transferred; - }, - }); + })); let creating = false; const ensure = () => { if ( creating || ctx.relay.snapshot().status !== "ready" || ctx.agents.snapshot().status !== "ready" || + // The type is shown once the Runner says it runs here. + !ctx.agents + .types() + .some((type) => type.key === "fixture.local-agent/tester") || ctx.agents.find(localAgentKey) ) return; diff --git a/tests/browser/plugin-fixtures.tsx b/tests/browser/plugin-fixtures.tsx index 120a389957..ebc1d13413 100644 --- a/tests/browser/plugin-fixtures.tsx +++ b/tests/browser/plugin-fixtures.tsx @@ -144,6 +144,15 @@ export const fixturePlugins: readonly BundledPlugin[] = [ inject: ["agents"], apply(ctx) { const state = { readiness: "ready" as const }; + ctx.agents.present("sample", () => ({ + provider: { + DialogBody: ({ onClose }) => ( + + ), + }, + })); ctx.agents.register({ id: "sample", title: "Fixture provider", @@ -152,11 +161,6 @@ export const fixturePlugins: readonly BundledPlugin[] = [ snapshot: () => state, subscribe: () => () => {}, check() {}, - DialogBody: ({ onClose }) => ( - - ), }, }); }, @@ -375,6 +379,9 @@ export const fixturePlugins: readonly BundledPlugin[] = [ gate.release = resolve; }); const state = { readiness: "ready" as const }; + ctx.agents.present("provider", () => ({ + provider: { DialogBody: () =>

Delayed provider controls

}, + })); ctx.agents.register({ id: "provider", title: "Delayed provider", @@ -383,7 +390,6 @@ export const fixturePlugins: readonly BundledPlugin[] = [ snapshot: () => state, subscribe: () => () => {}, check() {}, - DialogBody: () =>

Delayed provider controls

, }, }); ctx.pages.register({