From bf4b726c420d9a2140d61d2a3a3899192806f4a7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 11:44:33 -0400 Subject: [PATCH] refactor(agents): keyed reads for harness Activity tabs The Activity tabs of Claude, Codex, goose and Pi read sessions, conversations and working turns by agent key, synchronously, from the runtime object. A keyed read (`keyedRead`) is a handle with `read(key)` and `subscribe`; equal content keeps its identity. Each runtime module builds its tabs' handle (`activityHandle`), and the tabs read only through it, so a later change can serve these reads across pages. Claude and Codex conversations now come from the store too, so the list also refreshes when the runtime notifies. Codex's config resolution is a pure function of the provider snapshot, so the Settings tab no longer calls the provider for it. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/plugin-architecture.md | 6 ++- src/bundled/claude-code/index.ts | 10 +++- src/bundled/claude-code/runtime.ts | 19 ++++++++ src/bundled/claude-code/tabs.tsx | 17 ++++--- src/bundled/codex/index.ts | 4 +- src/bundled/codex/runtime.ts | 17 +++++++ src/bundled/codex/tabs.test.tsx | 6 +-- src/bundled/codex/tabs.tsx | 74 +++++++++++++++++------------ src/bundled/goose/index.ts | 14 +++++- src/bundled/goose/runtime.ts | 11 +++++ src/bundled/goose/tabs.test.tsx | 13 +++-- src/bundled/goose/tabs.tsx | 9 ++-- src/bundled/pi/index.ts | 13 ++++- src/bundled/pi/runtime.ts | 14 ++++++ src/bundled/pi/tabs.test.tsx | 23 +++++++-- src/bundled/pi/tabs.tsx | 9 ++-- src/features/agents/handles.test.ts | 23 +++++++++ src/features/agents/handles.ts | 30 ++++++++++++ 18 files changed, 240 insertions(+), 72 deletions(-) create mode 100644 src/features/agents/handles.test.ts create mode 100644 src/features/agents/handles.ts diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 8ba9eb02d0..3bf84684b6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -250,8 +250,10 @@ 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. +`subscribe`), a call returning a promise, or a keyed read (`keyedRead` in +`features/agents/handles.ts`: `read(key)` and `subscribe`, such as one agent's +sessions), so it can later cross to another page. Claude is the first harness +split this way. The type's functions of config alone (`defaults`, `importShared`, `adopt`, `carry`, `describeMigration`, `migrationWarning` and `summary`) go to both diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index a1ed09c6a3..ad78b50354 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -10,7 +10,13 @@ import type { PluginModule } from "../../plugins/api"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; import { claudeRun, TYPE_ID } from "./run"; import { ClaudeDefaults } from "./defaults"; -import { ClaudeRuntime, type Config, config, DEFAULT_CONFIG } from "./runtime"; +import { + activityHandle, + ClaudeRuntime, + type Config, + config, + DEFAULT_CONFIG, +} from "./runtime"; import { ClaudeSetup, withScript } from "./setup"; import { createTabs } from "./tabs"; import { readUsage } from "./usage"; @@ -160,7 +166,7 @@ function applyAgent(ctx: Parameters[0]) { expose: { setup, defaults: providerDefaults, - runtime, + runtime: activityHandle(runtime), } satisfies ClaudeExposed, ...(spawn && readFile && { diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 821a963128..1effe8b045 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -5,6 +5,7 @@ // so one agent works in several conversations at once. Claude acts in Buzz with // the in-process Buzz tools, signed by the agent's native-held key; the runtime // only reports a turn that fails. +import { keyedRead } from "../../features/agents/handles"; import type { Context } from "../../buzz-mcp/client"; import { RESUMED_PROMPT } from "../../buzz-mcp/prompt"; import { type Listing, respond } from "../../buzz-mcp/rpc"; @@ -941,3 +942,21 @@ export function modelName(model: string) { const name = family[0]?.toUpperCase() + family.slice(1); return id ? `${name} ${id[2]}${id[3] ? `.${id[3]}` : ""}` : name; } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: ClaudeRuntime) => + Object.freeze({ + find: (...args: Parameters) => runtime.find(...args), + transcript: (...args: Parameters) => + runtime.transcript(...args), + sessions: keyedRead( + (key) => runtime.sessions(key), + (listener) => runtime.subscribe(listener), + [], + ), + conversations: keyedRead( + (key) => runtime.conversations(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 372d72abd4..becc9145e9 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -38,7 +38,7 @@ import { Field } from "../../shared/design-system/ui/Field"; import { Input } from "../../shared/design-system/ui/Input"; import { Select } from "../../shared/design-system/ui/Select"; import { - type ClaudeRuntime, + type activityHandle, type Config, config as readConfig, effortChoices, @@ -64,10 +64,7 @@ type SetupHandle = Pick< | "cancel" >; type DefaultsHandle = Pick; -type ActivityHandle = Pick< - ClaudeRuntime, - "subscribe" | "sessions" | "conversations" | "find" | "transcript" ->; +type ActivityHandle = ReturnType; import { TURN_LIMIT } from "./transcript"; const message = (error: unknown) => @@ -716,11 +713,13 @@ export function createTabs({ agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; }) { - const sessions = useSyncExternalStore( - (listener) => runtime.subscribe(listener), - () => runtime.sessions(agent.key), + const sessions = useSyncExternalStore(runtime.sessions.subscribe, () => + runtime.sessions.read(agent.key), + ); + const conversations = useSyncExternalStore( + runtime.conversations.subscribe, + () => runtime.conversations.read(agent.key), ); - const conversations = runtime.conversations(agent.key); const busy = new Map( sessions.map((session) => [session.key, session.busy]), ); diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 8045d405e4..ff5ad296be 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -7,7 +7,7 @@ import type { PluginModule } from "../../plugins/api"; import { CodexLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, defaults, validWorkspace } from "./config"; import { codexRun, TYPE_ID } from "./run"; -import { CodexRuntime } from "./runtime"; +import { activityHandle, CodexRuntime } from "./runtime"; import { CodexSetup, withScript } from "./setup"; import { CodexProvider, createTabs, transcripts } from "./tabs"; import { readUsage } from "./usage"; @@ -198,7 +198,7 @@ function applyAgent(ctx: Parameters[0]) { ...typeConfig, expose: { provider: providerController, - runtime, + runtime: activityHandle(runtime), transcript: transcripts(start, runtime), setup, } satisfies CodexExposed, diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index af674ccc91..024c671458 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1,3 +1,4 @@ +import { keyedRead } from "../../features/agents/handles"; import type { WorkOutcome } from "../../features/agents/local-activity"; import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; import { @@ -1857,3 +1858,19 @@ export function programs(command: string | undefined): string | undefined { ? `${list.slice(0, 3).join(", ")} and ${list.length - 3} more` : list.join(", "); } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: CodexRuntime) => + Object.freeze({ + find: (...args: Parameters) => runtime.find(...args), + sessions: keyedRead( + (key) => runtime.sessions(key), + (listener) => runtime.subscribe(listener), + [], + ), + conversations: keyedRead( + (key) => runtime.conversations(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index a1fe89c0e7..678cd64067 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -31,7 +31,7 @@ import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import { defaults, type Config } from "./config"; import { sessionTag } from "./prompt"; import { CodexProvider, createTabs, transcripts } from "./tabs"; -import { CodexRuntime } from "./runtime"; +import { activityHandle, CodexRuntime } from "./runtime"; import type { Wire } from "./rpc"; import { CodexSetup } from "./setup"; function deferred() { @@ -202,7 +202,7 @@ function fixture({ ); const tabs = createTabs( provider, - runtime, + activityHandle(runtime), transcripts(spawn, runtime), setup, ); @@ -1295,7 +1295,7 @@ it("installs and signs in Buzz's copy of Codex from its provider settings", asyn const provider = new CodexProvider(f.spawn, localStore(), f.setup); const { ProviderDialogBody } = createTabs( provider, - f.runtime, + activityHandle(f.runtime), transcripts(f.spawn, f.runtime), f.setup, ); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index 82d8164a96..30849aa0fb 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -50,7 +50,11 @@ import { type Model, } from "./config"; import { AppServer, listModels, needsSignIn, type Spawn } from "./rpc"; -import type { CodexRuntime, SavedConversation } from "./runtime"; +import type { + activityHandle, + CodexRuntime, + SavedConversation, +} from "./runtime"; import type { CodexSetup } from "./setup"; import { readTranscript, @@ -214,26 +218,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(raw, this.state); } dispose() { this.offSetup?.(); @@ -291,6 +276,32 @@ const catalogModel = (model: string, data?: Catalog) => { const resolved = resolveModel(model, data.models); return data.models.find((choice) => choice.model === resolved); }; +/** `raw` with the provider's defaults filled in, as the catalog in `state` + * allows. */ +function resolveConfig( + raw: unknown, + { defaults, data }: Pick, +): Config { + const explicit = readConfig(raw); + const inheritedModel = defaults.model; + const unavailable = Boolean( + inheritedModel && data && !catalogModel(inheritedModel, data), + ); + const model = explicit.model || (unavailable ? "" : inheritedModel); + if (explicit.effort) return { ...explicit, model }; + const selected = catalogModel(model, data); + const preferred = unavailable && !explicit.model ? "" : defaults.effort; + const effort = !preferred + ? "" + : !data + ? preferred + : selected?.supportedReasoningEfforts.some( + (choice) => choice.reasoningEffort === preferred, + ) + ? preferred + : (selected?.defaultReasoningEffort ?? ""); + return { ...explicit, model, effort }; +} const providerDefaultLabel = (model: string, data?: Catalog) => { const selected = catalogModel(model, data); const name = selected?.displayName || selected?.model; @@ -421,12 +432,9 @@ function CodexSignIn({ // as an agent type's `expose` allows. type ProviderHandle = Pick< CodexProvider, - "snapshot" | "subscribe" | "check" | "resolve" | "saveDefaults" ->; -type ActivityHandle = Pick< - CodexRuntime, - "subscribe" | "sessions" | "conversations" | "find" + "snapshot" | "subscribe" | "check" | "saveDefaults" >; +type ActivityHandle = ReturnType; type SetupHandle = Pick< CodexSetup, "snapshot" | "subscribe" | "run" | "cancel" @@ -850,10 +858,13 @@ export function createTabs( agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; }) { - const sessions = useSyncExternalStore(runtime.subscribe, () => - runtime.sessions(agent.key), + const sessions = useSyncExternalStore(runtime.sessions.subscribe, () => + runtime.sessions.read(agent.key), + ); + const conversations = useSyncExternalStore( + runtime.conversations.subscribe, + () => runtime.conversations.read(agent.key), ); - const conversations = runtime.conversations(agent.key); const { session: here, searching, @@ -991,16 +1002,17 @@ export function createTabs( useEffect(() => { if (error) saveErrorRef.current?.focus(); }, [error]); + const state = useProvider(); const { data, error: catalogError, checking, defaults: providerDefaults, - } = useProvider(); + } = state; useEffect(() => { setDraft(readConfig(agent.config)); }, [agent.config]); - const effective = provider.resolve({ ...draft, effort: "" }); + const effective = resolveConfig({ ...draft, effort: "" }, state); const selected = catalogModel(effective.model, data); const supported = selected?.supportedReasoningEfforts ?? []; const efforts = [ diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index 1585942e55..d7e9909b14 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -7,7 +7,13 @@ import { isTauri } from "@tauri-apps/api/core"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { GooseLogoIcon } from "../../shared/design-system/icons"; -import { type Config, config, DEFAULT_CONFIG, GooseRuntime } from "./runtime"; +import { + activityHandle, + type Config, + config, + DEFAULT_CONFIG, + GooseRuntime, +} from "./runtime"; import { gooseRun } from "./run"; import { GooseSetup } from "./setup"; import { createTabs } from "./tabs"; @@ -133,7 +139,11 @@ 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.", ...typeConfig, - expose: { setup, runtime, history } satisfies GooseExposed, + expose: { + setup, + runtime: activityHandle(runtime), + history, + } satisfies GooseExposed, provider: { snapshot: setup.snapshot, subscribe: setup.subscribe, diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts index 9a0c8891a4..8b851eb056 100644 --- a/src/bundled/goose/runtime.ts +++ b/src/bundled/goose/runtime.ts @@ -7,6 +7,7 @@ // goose acts in Buzz with the in-process Buzz tools over loopback HTTP MCP, // signed by the agent's native-held key; the runtime only reports a turn that // fails. +import { keyedRead } from "../../features/agents/handles"; import type { Context } from "../../buzz-mcp/client"; import { MemoryReads, @@ -829,3 +830,13 @@ export class GooseRuntime { if (reads) this.memoryReads.forget(reads.connection, pubkey); } } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: GooseRuntime) => + Object.freeze({ + sessions: keyedRead( + (key) => runtime.sessions(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/goose/tabs.test.tsx b/src/bundled/goose/tabs.test.tsx index 5f3713864c..57d4fd26cb 100644 --- a/src/bundled/goose/tabs.test.tsx +++ b/src/bundled/goose/tabs.test.tsx @@ -19,14 +19,19 @@ import type { } from "../../features/agents/service"; import type { Agent } from "../../features/agents/service"; import { fakeSpawn } from "./goose-testing"; -import { type Config, DEFAULT_CONFIG, type GooseRuntime } from "./runtime"; +import { + activityHandle, + type Config, + DEFAULT_CONFIG, + type GooseRuntime, +} from "./runtime"; import { GooseSetup } from "./setup"; import { createTabs } from "./tabs"; import { GooseHistory } from "./transcript"; /** New agent's fields read no sessions. */ const unused = { - runtime: {} as GooseRuntime, + runtime: activityHandle({} as GooseRuntime), history: new GooseHistory(undefined), }; @@ -727,10 +732,10 @@ it("lists the agent's goose sessions and shows one's transcript", async () => { }); // The runtime keeps one snapshot until its sessions change. const none: never[] = []; - const runtime = { + const runtime = activityHandle({ subscribe: () => () => {}, sessions: () => none, - } as unknown as GooseRuntime; + } as unknown as GooseRuntime); const { ActivityTab } = createTabs({ setup: new GooseSetup(fake), runtime, diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index 51c3a29a60..651a09f721 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -39,7 +39,7 @@ import { validWorkspace } from "../codex/config"; import { type Config, DEFAULT_CONFIG, - type GooseRuntime, + type activityHandle, config as readConfig, } from "./runtime"; import { @@ -154,7 +154,7 @@ export function createTabs({ history, }: Readonly<{ setup: SetupHandle; - runtime: Pick; + runtime: ReturnType; history: Pick; }>) { function DefaultControl({ onConfigure }: { onConfigure(): void }) { @@ -828,9 +828,8 @@ export function createTabs({ conversation?: AgentViewProps["conversation"]; }) { const workspace = readConfig(agent.config).workspace; - const live = useSyncExternalStore( - (listener) => runtime.subscribe(listener), - () => runtime.sessions(agent.key), + const live = useSyncExternalStore(runtime.sessions.subscribe, () => + runtime.sessions.read(agent.key), ); const running = live.map( (session): Row => ({ diff --git a/src/bundled/pi/index.ts b/src/bundled/pi/index.ts index 6ff9492f2a..f980bb9de4 100644 --- a/src/bundled/pi/index.ts +++ b/src/bundled/pi/index.ts @@ -9,7 +9,13 @@ import { repositoriesContext } from "../../features/communities/repositories"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { PiLogoIcon } from "../../shared/design-system/icons"; -import { type Config, config, DEFAULT_CONFIG, PiRuntime } from "./runtime"; +import { + activityHandle, + type Config, + config, + DEFAULT_CONFIG, + PiRuntime, +} from "./runtime"; import { createTabs } from "./tabs"; export const TYPE_ID = "pi"; @@ -110,7 +116,10 @@ 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.", ...typeConfig, - expose: { setup: runtime.setup, runtime } satisfies PiExposed, + expose: { + setup: runtime.setup, + runtime: activityHandle(runtime), + } satisfies PiExposed, ...(host.spawn && { usage: (agent, { since, signal }) => runtime.usage(agent.key, since, signal), diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts index 0707b92257..5aa3dbc7e0 100644 --- a/src/bundled/pi/runtime.ts +++ b/src/bundled/pi/runtime.ts @@ -7,6 +7,7 @@ // Buzz tools, signed by the agent's native-held key. The runtime reports a // turn that fails, and posts the answer of a mention's turn that never used // `send`. +import { keyedRead } from "../../features/agents/handles"; import { appClient } from "../../buzz-mcp/app-client"; import type { Context } from "../../buzz-mcp/client"; import { content, errorText } from "../../buzz-mcp/rpc"; @@ -1360,3 +1361,16 @@ function unanswered({ if (reason === "aborted") return "The turn was stopped"; return `Pi did not answer (${reason ?? "unknown"})`; } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: PiRuntime) => + Object.freeze({ + places: (key: AgentKey) => runtime.places(key), + transcript: (...args: Parameters) => + runtime.transcript(...args), + working: keyedRead( + (key) => runtime.working(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/pi/tabs.test.tsx b/src/bundled/pi/tabs.test.tsx index af7e11d568..53aeeee1a5 100644 --- a/src/bundled/pi/tabs.test.tsx +++ b/src/bundled/pi/tabs.test.tsx @@ -12,7 +12,13 @@ import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { Agent } from "../../features/agents/service"; -import { type Config, DEFAULT_CONFIG, type Place } from "./runtime"; +import { + activityHandle, + type Config, + DEFAULT_CONFIG, + type PiRuntime, + type Place, +} from "./runtime"; import { type Provider, type Providers, PiSetup } from "./setup"; import { createTabs } from "./tabs"; import { piTurns } from "./transcript"; @@ -91,6 +97,10 @@ function fakePi( } /** The runtime's conversations: a thread Pi works in, and a channel. */ +/** The tabs' handle on a fake runtime. */ +const handle = (runtime: ReturnType) => + activityHandle(runtime as unknown as PiRuntime); + function fakeRuntime() { const places: Place[] = [ { @@ -146,7 +156,7 @@ function fakeRuntime() { function created(pi = fakePi(), modelAppearance?: "glass-ghost") { const { Create, CreateModel } = createTabs({ setup: new PiSetup(pi), - runtime: fakeRuntime(), + runtime: handle(fakeRuntime()), }); const changes: Config[] = []; function Harness() { @@ -382,7 +392,7 @@ it("keeps Try again's providers for what the dialog's Use allows", async () => { it("shows the provider, model and thinking level in Identity, and the provider's setup in Settings", async () => { const { SettingsTab } = createTabs({ setup: new PiSetup(fakePi()), - runtime: fakeRuntime(), + runtime: handle(fakeRuntime()), }); const save = vi.fn(async () => {}); // As the agent's profile shows it: one form, a section at a time. @@ -423,7 +433,7 @@ it("saves a key on Enter without saving the agent's settings", async () => { const pi = fakePi(); const { SettingsTab } = createTabs({ setup: new PiSetup(pi), - runtime: fakeRuntime(), + runtime: handle(fakeRuntime()), }); const save = vi.fn(); render(); @@ -443,7 +453,10 @@ it("saves a key on Enter without saving the agent's settings", async () => { it("lists Pi's conversations in the Activity tab, and shows a transcript", async () => { const runtime = fakeRuntime(); - const { ActivityTab } = createTabs({ setup: new PiSetup(fakePi()), runtime }); + const { ActivityTab } = createTabs({ + setup: new PiSetup(fakePi()), + runtime: handle(runtime), + }); render( ; - runtime: Pick; + runtime: ReturnType; }>) { /** The provider, its key and its model, and a test that they answer. As * `rows` of the agent's settings, only the provider and model: the rest is @@ -690,9 +690,8 @@ export function createTabs({ agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; }) { - const working = useSyncExternalStore( - (listener) => runtime.subscribe(listener), - () => runtime.working(agent.key), + const working = useSyncExternalStore(runtime.working.subscribe, () => + runtime.working.read(agent.key), ); // Read again as turns start and end, which can add a conversation. const [listing, reload] = useRead( diff --git a/src/features/agents/handles.test.ts b/src/features/agents/handles.test.ts new file mode 100644 index 0000000000..c8ac41309a --- /dev/null +++ b/src/features/agents/handles.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "vitest"; +import { keyedRead } from "./handles"; + +it("keeps what a key read before while its content is the same", () => { + let sessions: Record = { + a: [{ key: "one", busy: false }], + b: [{ key: "two", busy: true }], + }; + // Each read builds new arrays, as the runtimes' saved-conversation reads do. + const handle = keyedRead( + (key) => (sessions[key] ?? []).map((session) => ({ ...session })), + () => () => {}, + [], + ); + const a = handle.read("a"); + const b = handle.read("b"); + expect(handle.read("a")).toBe(a); + sessions = { ...sessions, a: [{ key: "one", busy: true }] }; + const changed = handle.read("a"); + expect(changed).not.toBe(a); + expect(changed).toEqual([{ key: "one", busy: true }]); + expect(handle.read("b")).toBe(b); +}); diff --git a/src/features/agents/handles.ts b/src/features/agents/handles.ts new file mode 100644 index 0000000000..acaaa93de3 --- /dev/null +++ b/src/features/agents/handles.ts @@ -0,0 +1,30 @@ +// Handles a type's runtime exposes to its screens. Each is JSON, a store +// (`snapshot` and `subscribe`), a call returning a promise, or a keyed read, +// so that screens can later reach them from another page. +import { shared } from "./port"; + +/** A synchronous read by key, such as one agent's sessions, that screens use + * as a store: `useSyncExternalStore(subscribe, () => read(key))`. Equal content + * keeps its identity. Across pages a key reads as `empty` until it arrives. */ +export type KeyedRead = Readonly<{ + read(key: string): T; + subscribe(listener: () => void): () => void; + empty: T; +}>; + +export function keyedRead( + read: (key: string) => T, + subscribe: (listener: () => void) => () => void, + empty: T, +): KeyedRead { + const last = new Map(); + return Object.freeze({ + read(key: string) { + const value = shared(last.get(key), read(key)); + last.set(key, value); + return value; + }, + subscribe, + empty, + }); +}