From 471470cec9ac2998d3c0d5b98ffccec2447266c7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 29 Oct 2026 08:41:11 -0400 Subject: [PATCH] Cross between the Manager and the Runner only over the JSON link The Manager (screens and agent management) and the Runner now reach each other only through link.ts topics and commands: - memberships go in, and run state comes out; - records are a topic, and edits are commands; - session controls and provider changes are Runner commands; - each harness exposes its UI as JSON topics and commands; - plugin agent tools run from agent entries, with what the UI shares; - the Manager keeps config sync, and sends each membership's synced settings, instructions and teams with it; the Runner lays them over its own row when an agent starts; - what the Runner needs of the Manager (requests, templates, saving a new type's synced settings) are Manager commands. Inert: both sides still run in one page. Squashes #1833, #1836, #1839, #1861, #1862 and #1865. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- docs/plugin-architecture.md | 33 +- src/app/navigation.test.tsx | 4 +- src/app/navigation.ts | 14 +- src/bundled/agents/AgentsPage.test.tsx | 2 + src/bundled/bestie/index.test.tsx | 8 +- src/bundled/bestie/setup.test.ts | 9 +- src/bundled/bestie/testing.ts | 13 + src/bundled/builderlab/apps/client.ts | 2 +- src/bundled/builderlab/apps/index.test.ts | 15 +- src/bundled/builderlab/apps/index.ts | 60 +- src/bundled/builderlab/index.tsx | 9 +- src/bundled/claude-code/defaults.ts | 4 +- src/bundled/claude-code/exposed.ts | 186 +++++ src/bundled/claude-code/index.ts | 67 +- src/bundled/claude-code/run.ts | 3 +- src/bundled/claude-code/runtime.ts | 5 + src/bundled/claude-code/setup.ts | 23 +- src/bundled/claude-code/tabs.test.tsx | 110 ++- src/bundled/claude-code/tabs.tsx | 33 +- src/bundled/codex/exposed.ts | 184 +++++ src/bundled/codex/index.ts | 166 ++-- src/bundled/codex/run.ts | 3 +- src/bundled/codex/runtime.ts | 5 + src/bundled/codex/setup.ts | 17 +- src/bundled/codex/tabs.test.tsx | 109 ++- src/bundled/codex/tabs.tsx | 89 ++- src/bundled/github/SoloPull.test.tsx | 7 + src/bundled/github/index.tsx | 48 +- src/bundled/goose/exposed.test.ts | 72 ++ src/bundled/goose/exposed.ts | 160 ++++ src/bundled/goose/index.ts | 43 +- src/bundled/goose/run.ts | 3 +- src/bundled/goose/tabs.tsx | 19 +- src/bundled/pi/exposed.test.ts | 38 + src/bundled/pi/exposed.ts | 139 ++++ src/bundled/pi/index.ts | 47 +- src/features/agents/OpenedConversation.tsx | 12 +- src/features/agents/agent-start.ts | 93 ++- src/features/agents/agents-runner.test.ts | 60 ++ src/features/agents/agents-runner.ts | 346 ++++++++- src/features/agents/connections.test.ts | 53 +- src/features/agents/connections.ts | 18 +- src/features/agents/exposed.test.ts | 84 ++ src/features/agents/exposed.ts | 115 +++ src/features/agents/launch-boundary.test.ts | 18 +- src/features/agents/link.test.ts | 20 + src/features/agents/plugin-testing.ts | 91 ++- src/features/agents/react.ts | 4 +- src/features/agents/runner-link.test.ts | 708 ++++++++++++++++- src/features/agents/runner-link.ts | 816 +++++++++++++++++++- src/features/agents/service.test.ts | 239 ++++-- src/features/agents/service.ts | 623 +++++++-------- src/features/agents/shown-types.ts | 186 +++++ src/features/agents/team-prompt.ts | 39 + src/features/panels/service.test.tsx | 6 + src/plugins/author.ts | 4 + tests/browser/conversation-name.fixture.tsx | 22 +- tests/browser/local-agents-fixture.ts | 74 +- tests/browser/plugin-fixtures.tsx | 18 +- 59 files changed, 4568 insertions(+), 830 deletions(-) create mode 100644 src/bundled/claude-code/exposed.ts create mode 100644 src/bundled/codex/exposed.ts create mode 100644 src/bundled/goose/exposed.test.ts create mode 100644 src/bundled/goose/exposed.ts create mode 100644 src/bundled/pi/exposed.test.ts create mode 100644 src/bundled/pi/exposed.ts create mode 100644 src/features/agents/agents-runner.test.ts create mode 100644 src/features/agents/exposed.test.ts create mode 100644 src/features/agents/exposed.ts create mode 100644 src/features/agents/shown-types.ts create mode 100644 src/features/agents/team-prompt.ts 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({