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