From fda8080e70b0416e7b9b7c4bfa7c4429630c2be0 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 17:10:10 -0700 Subject: [PATCH 1/2] [model] pick a mentioned agent's model and effort for its next run Mentioning your own Claude Code or Codex agent shows a gauge in the composer. Its popover is an effort slider with the model name in the slider's tab; the name opens a compact menu of the agent's models. The pick rides on that message as `model` and `effort` tags for that agent, and the host honors them only from the owner and only for listed models and levels. - Codex passes the pick to `turn/start` for that turn only. - Claude Code lists models from `initialize`, sends `set_model` and `apply_flag_settings` ahead of the picked turn, and restores its own on the next unpicked one. - The slider opens at the effort an unpicked run uses (Claude Code's session effort, Codex's configured effort or the model's default); moving back to it is no pick. - A configured Codex model its CLI does not list keeps the picker, with the listed models still pickable. Co-Authored-By: Claude Opus 5.5 Squashed-from: baxen/model Squashed-head: 053cee0dba93bb946f544c0c87804ceee90a49ef Signed-off-by: Bradley Axen Co-authored-by: Codex Signed-off-by: Matt Toohey --- src/app/App.tsx | 300 +++++++++--------- src/bundled/claude-code/claude-testing.ts | 74 ++++- src/bundled/claude-code/claude.test.ts | 68 ++++ src/bundled/claude-code/claude.ts | 155 ++++++++- src/bundled/claude-code/index.ts | 1 + src/bundled/claude-code/runtime.test.ts | 100 +++++- src/bundled/claude-code/runtime.ts | 85 ++++- src/bundled/claude-code/sessions.test.ts | 46 +++ src/bundled/claude-code/sessions.ts | 93 +++++- src/bundled/codex/index.ts | 1 + src/bundled/codex/runtime.test.ts | 133 ++++++++ src/bundled/codex/runtime.ts | 119 +++++-- src/features/agents2/react.test.tsx | 117 ++++++- src/features/agents2/react.ts | 90 +++++- src/features/agents2/service.test.ts | 45 +++ src/features/agents2/service.ts | 66 +++- .../messages/ComposerModels.module.css | 196 ++++++++++++ src/features/messages/ComposerModels.tsx | 282 ++++++++++++++++ .../messages/MessageComposer.test.tsx | 245 +++++++++++++- src/features/messages/MessageComposer.tsx | 34 +- src/features/relay/mentions.test.ts | 52 +++ src/features/relay/messages.ts | 24 ++ src/shared/design-system/DESIGN.md | 5 +- src/shared/design-system/icons/index.ts | 3 + .../design-system/styles/components.css | 14 + src/shared/design-system/ui/Button.tsx | 2 +- src/shared/design-system/ui/registry.ts | 2 +- .../design-system/ui/ButtonSpecimens.tsx | 11 + 28 files changed, 2160 insertions(+), 203 deletions(-) create mode 100644 src/features/messages/ComposerModels.module.css create mode 100644 src/features/messages/ComposerModels.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 3fe917608..63cab60d1 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,7 @@ import { IdentitySetup } from "../features/identity/IdentitySetup"; import { MeSidebar } from "../bundled/me/MeSidebar"; import { ChannelSidebar } from "../features/channel-navigation/ChannelSidebar"; import { ChannelNavigationProvider } from "../features/channel-navigation/ChannelNavigationState"; +import { Agents2Context } from "../features/agents2/react"; import { ToastProvider } from "../shared/design-system/ui/Toast"; import { Button } from "../shared/design-system/ui/Button"; import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; @@ -148,16 +149,147 @@ function ConnectedApp({ services }: { services: AppServices }) { portalContainer={document.getElementById("buzz-toast-root") ?? undefined} > - - settings ? ( - + + settings ? ( + { + const client = services.communities.snapshot(); + void services.navigation.open({ + version: 1, + kind: "settings", + section, + ...(client.viewer && client.selected + ? { + scope: { + viewer: client.viewer, + communityOrigin: communityDestination( + client.selected, + ).url, + }, + } + : { scope: null }), + }); + }} + /> + ) : route.selected === "buzz.me/me" ? ( + + ) : ( + page.key === "buzz.me/me", + )} + sessionsEnabled={route.pages.some( + (page) => page.pluginId === "buzz.sessions", + )} + > + {pageNavigation} + + ) + } + navigationControls={ + + } + onCommunitySelect={(id) => { + const recovering = + services.navigation.snapshot().ingress && + services.navigation.snapshot().retryable; + services.communities.select(id); + if (!recovering) select("buzz.channels/channels"); + }} + // A scoped Settings target selects its community on the way. + onOpenTarget={(target) => void services.navigation.open(target)} + communities={services.communities} + invite={invite?.viewer === client.viewer ? invite : undefined} + onInviteClose={services.invites.clear} + settingsCards={services.settingsCards} + accountActions={services.accountActions} + onProfile={ + ownProfile && launcher.canOpen(ownProfile) + ? (trigger) => launcher.open(ownProfile, trigger) + : undefined + } + searchServices={services} + launchers={ + + } + companion={pageOwnsCompanion ? undefined : companion} + pages={startup === "ready" ? route.pages : []} + selected={route.selected} + navigationAttempt={route.state.attempt.id} + onSelect={select} + tone={presentation.tone} + workspace={ + startup === "ready" && route.page?.layout === "workspace" + } + > + + + + + {startup === "recovery" && !settings ? ( + + ) : (!route.state.ingress && route.failure) || + route.state.status === "failed" ? ( +
+

This destination couldn’t open

+

+ {(route.state.reason ?? route.failure) === "denied" + ? "This target needs its original account and an already joined community." + : route.state.ingress && !route.state.retryable + ? "This link is invalid or unsupported." + : "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."} +

+ {(!route.state.ingress || route.state.retryable) && ( + + )} + +
+ ) : route.waiting ? ( +

Opening destination…

+ ) : settings ? ( + { const client = services.communities.snapshot(); void services.navigation.open({ @@ -177,146 +309,20 @@ function ConnectedApp({ services }: { services: AppServices }) { }); }} /> - ) : route.selected === "buzz.me/me" ? ( - Opening destination…

+ ) : route.page ? ( + - ) : ( - page.key === "buzz.me/me", - )} - sessionsEnabled={route.pages.some( - (page) => page.pluginId === "buzz.sessions", - )} - > - {pageNavigation} - - ) - } - navigationControls={ - - } - onCommunitySelect={(id) => { - const recovering = - services.navigation.snapshot().ingress && - services.navigation.snapshot().retryable; - services.communities.select(id); - if (!recovering) select("buzz.channels/channels"); - }} - // A scoped Settings target selects its community on the way. - onOpenTarget={(target) => void services.navigation.open(target)} - communities={services.communities} - invite={invite?.viewer === client.viewer ? invite : undefined} - onInviteClose={services.invites.clear} - settingsCards={services.settingsCards} - accountActions={services.accountActions} - onProfile={ - ownProfile && launcher.canOpen(ownProfile) - ? (trigger) => launcher.open(ownProfile, trigger) - : undefined - } - searchServices={services} - launchers={ - - } - companion={pageOwnsCompanion ? undefined : companion} - pages={startup === "ready" ? route.pages : []} - selected={route.selected} - navigationAttempt={route.state.attempt.id} - onSelect={select} - tone={presentation.tone} - workspace={startup === "ready" && route.page?.layout === "workspace"} - > - - - - - {startup === "recovery" && !settings ? ( - - ) : (!route.state.ingress && route.failure) || - route.state.status === "failed" ? ( -
-

This destination couldn’t open

-

- {(route.state.reason ?? route.failure) === "denied" - ? "This target needs its original account and an already joined community." - : route.state.ingress && !route.state.retryable - ? "This link is invalid or unsupported." - : "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."} -

- {(!route.state.ingress || route.state.retryable) && ( - - )} - -
- ) : route.waiting ? ( -

Opening destination…

- ) : settings ? ( - { - const client = services.communities.snapshot(); - void services.navigation.open({ - version: 1, - kind: "settings", - section, - ...(client.viewer && client.selected - ? { - scope: { - viewer: client.viewer, - communityOrigin: communityDestination(client.selected) - .url, - }, - } - : { scope: null }), - }); - }} - /> - ) : startup === "loading" ? ( -

Opening destination…

- ) : route.page ? ( - - ) : null} -
+ ) : null} +
+
); diff --git a/src/bundled/claude-code/claude-testing.ts b/src/bundled/claude-code/claude-testing.ts index af8085c0e..20ea3fa02 100644 --- a/src/bundled/claude-code/claude-testing.ts +++ b/src/bundled/claude-code/claude-testing.ts @@ -11,6 +11,14 @@ export type FakeClaude = { received: Record[]; /** User messages it has taken, in order. */ prompts: string[]; + /** The session's effort, which `apply_flag_settings` changes. */ + effort: string; + /** The effort each user message was taken at. */ + efforts: string[]; + /** The session's model, which `set_model` changes. */ + model: string; + /** The model each user message was taken on. */ + models: string[]; /** When set, a turn stays open until `finish()`. */ hold: boolean; killed: boolean; @@ -19,6 +27,26 @@ export type FakeClaude = { exit(code: number | null, stderr?: string): void; }; +/** The models the fake lists, as Claude Code's `initialize` answer does. */ +export const MODELS = [ + { value: "default", resolvedModel: "claude-opus-5-5" }, + { + value: "opus", + resolvedModel: "claude-opus-5-5", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + }, + { + value: "haiku", + resolvedModel: "claude-haiku-5-5", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }, + { value: "legacy", resolvedModel: "claude-legacy-1", supportsEffort: false }, +]; +const resolved = (model: string) => + MODELS.find((item) => item.value === model)?.resolvedModel ?? model; + /** A spawn whose `claude` processes answer like Claude Code. Sessions named in * `known` (or created by a process) can be resumed; others fail to load. */ export function fakeSpawn(known = new Set()) { @@ -43,6 +71,10 @@ export function fakeSpawn(known = new Set()) { options, received: [], prompts: [], + effort: "high", + efforts: [], + model: flag("--model") ?? "default", + models: [], hold: false, killed: false, emit(message) { @@ -78,9 +110,40 @@ export function fakeSpawn(known = new Set()) { const take = (message: Record) => { fake.received.push(message); if (message.type === "control_request") { + const request = message.request as Record; + const level = (request.settings as Record | undefined) + ?.effortLevel; + if ( + request.subtype === "apply_flag_settings" && + typeof level === "string" + ) + fake.effort = level; + if (request.subtype === "set_model") + fake.model = flag("--model") ?? "default"; + if ( + request.subtype === "set_model" && + typeof request.model === "string" + ) + fake.model = request.model; fake.emit({ type: "control_response", - response: { subtype: "success", request_id: message.request_id }, + response: { + subtype: "success", + request_id: message.request_id, + ...(request.subtype === "get_settings" + ? { + response: { + applied: { + effort: fake.effort, + model: resolved(fake.model), + }, + }, + } + : {}), + ...(request.subtype === "initialize" + ? { response: { models: MODELS } } + : {}), + }, }); return; } @@ -91,10 +154,17 @@ export function fakeSpawn(known = new Set()) { } const content = (message.message as { content: string }).content; fake.prompts.push(content); + fake.efforts.push(fake.effort); + fake.models.push(fake.model); if (!started) { started = true; known.add(session); - fake.emit({ type: "system", subtype: "init", session_id: session }); + fake.emit({ + type: "system", + subtype: "init", + session_id: session, + model: resolved(fake.model), + }); } open++; fake.emit({ diff --git a/src/bundled/claude-code/claude.test.ts b/src/bundled/claude-code/claude.test.ts index 62c73586f..d807135a5 100644 --- a/src/bundled/claude-code/claude.test.ts +++ b/src/bundled/claude-code/claude.test.ts @@ -82,6 +82,74 @@ it("answers both messages of a turn that took a second one mid-way", async () => await expect(second).resolves.toEqual({ ok: true }); }); +it("runs a turn at the effort asked for, and the next without one at the session's own", async () => { + const { spawn, processes } = fakeSpawn(); + const claude = await ClaudeProcess.start(spawn, { + ...launch, + sessionId: "s1", + }); + const fake = processes[0]; + if (!fake) throw new Error("no process"); + await claude.send("plain"); + expect(fake.received.map((message) => message.type)).toEqual([ + "control_request", + "user", + ]); + await claude.send("hard", { effort: "xhigh" }); + fake.hold = true; + const quick = claude.send("quick", { effort: "low" }); + // Folded into the running turn, a message without an effort keeps it. + const steer = claude.send("also"); + await flush(); + fake.finish(); + await Promise.all([quick, steer]); + fake.hold = false; + await claude.send("after"); + expect(fake.prompts).toEqual(["plain", "hard", "quick", "also", "after"]); + expect(fake.efforts).toEqual(["high", "xhigh", "low", "low", "high"]); + // The session's own level is read once, before the first change. + expect( + fake.received.filter( + (message) => + (message.request as { subtype?: string } | undefined)?.subtype === + "get_settings", + ), + ).toHaveLength(1); +}); + +it("runs a turn on the model asked for, and the next without one on the session's own", async () => { + const { spawn, processes } = fakeSpawn(); + const claude = await ClaudeProcess.start(spawn, { + ...launch, + model: "opus", + sessionId: "s1", + }); + const fake = processes[0]; + if (!fake) throw new Error("no process"); + await expect(claude.models()).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ value: "haiku" })]), + ); + await claude.send("plain"); + fake.hold = true; + const quick = claude.send("quick", { model: "haiku" }); + // Folded into the running turn, a message without a model keeps it. + const steer = claude.send("also"); + await flush(); + fake.finish(); + await Promise.all([quick, steer]); + fake.hold = false; + await claude.send("after"); + expect(fake.models).toEqual(["opus", "haiku", "haiku", "opus"]); + // The session reports its own model, not the picked one. + await expect(claude.readModel()).resolves.toBe("claude-opus-5-5"); + expect( + fake.received + .map((message) => message.request as { subtype?: string; model?: string }) + .filter((request) => request?.subtype === "set_model") + .map((request) => request.model), + ).toEqual(["haiku", "opus"]); +}); + it("settles on each result when Claude Code does not report idle", async () => { const { spawn, processes } = fakeSpawn(); const claude = await ClaudeProcess.start( diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index c163acc52..4e2330532 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -39,6 +39,18 @@ const FINAL_TOOL = `mcp__${TOOL_SERVER}__send`; type Message = Record & { type?: string; subtype?: string }; +/** A model Claude Code lists as one a session can switch to. */ +export type ClaudeModel = Readonly<{ + /** What `set_model` takes: an alias such as `opus`, or a model id. */ + value: string; + resolvedModel?: string; + displayName?: string; + supportsEffort?: boolean; + supportedEffortLevels?: readonly string[]; +}>; +/** What the next turn is asked to run with; absent is the session's own. */ +export type TurnPick = Readonly<{ model?: string; effort?: string }>; + /** How a burst of work ended: every message sent so far has been answered, * or the process is gone. */ export type Settled = Readonly<{ @@ -76,6 +88,8 @@ export class ClaudeProcess { private pending = 0; /** It has begun a turn, so its session loaded. */ private began = false; + /** The model the session reported when it began. */ + private reported: string | undefined; private waiters: ((settled: Settled) => void)[] = []; private buffer = ""; private stderr = ""; @@ -84,16 +98,32 @@ export class ClaudeProcess { /** It reports idle; an older `claude` that does not is settled per result. */ private reportsState = false; private requests = 0; + /** Control requests awaiting Claude Code's answer, by request id. */ + private calls = new Map void>(); + /** The models Claude Code lists, from its answer to `initialize`. */ + private listed: Promise = Promise.resolve([]); + /** The model the next turn runs on; undefined is the session's own. */ + private pickedModel: string | undefined; + /** The effort the next turn runs at; undefined is the session's own. */ + private effort: string | undefined; + /** The session's own effort, read before the first change to restore it. */ + private ownEffort: string | undefined; + private readingEffort: Promise | undefined; readonly exited: Promise; private constructor( private readonly process: HostProcess, resume: string | undefined, private tools: ToolServer | undefined, + /** The session's own model, as launched; undefined is Claude Code's default. */ + private readonly ownModel: string | undefined, ) { this.sessionId = resume; this.exited = process.exited.then((code) => { this.alive = false; + for (const resolve of this.calls.values()) + resolve({ subtype: "error", error: "Claude Code exited" }); + this.calls.clear(); const error = this.lastError ?? (this.stderr.trim().split("\n").at(-1) || @@ -155,6 +185,7 @@ export class ClaudeProcess { process, launch.resume ?? launch.sessionId, launch.tools, + launch.model, ); for (const data of earlyErrors) claude.readError(data); for (const data of early) claude.read(data); @@ -165,7 +196,7 @@ export class ClaudeProcess { * works for. Loads settings and tools now rather than on the first message. */ async initialize(launch: Pick) { if (launch.tools) this.tools = launch.tools; - await this.request({ + const { written, answer } = this.request({ subtype: "initialize", appendSystemPrompt: launch.systemPrompt, ...(launch.tools @@ -179,6 +210,15 @@ export class ClaudeProcess { } : {}), }); + await written; + this.listed = answer.then((response) => { + const models = (response.response as Message | undefined)?.models; + return Array.isArray(models) + ? models.filter( + (model): model is ClaudeModel => typeof model?.value === "string", + ) + : []; + }); } get running() { @@ -190,15 +230,43 @@ export class ClaudeProcess { get started() { return this.began; } + get model() { + return this.reported; + } + /** The models this session can switch to, without sending a turn. */ + models() { + return this.listed; + } + /** The effort a turn without a pick runs at, when Claude Code reports it. */ + async readEffort() { + await this.readOwnEffort(); + return this.ownEffort; + } + /** Settings resolve the default model without sending a paid user turn. */ + async readModel() { + // Settings report a picked model, which is not the session's own. + if (this.reported || this.pickedModel !== undefined) return this.reported; + const answer = await this.call({ subtype: "get_settings" }); + const applied = (answer.response as Message | undefined)?.applied as + | Message + | undefined; + if (typeof applied?.model === "string") this.reported = applied.model; + return this.reported; + } /** Sends one user message. Resolves when it, and anything sent after it, has - * been answered and the session is idle again. */ - send(text: string): Promise { + * been answered and the session is idle again. A new turn runs with the + * picked model and effort, or the session's own without them; a message + * folded into a running turn changes only what it asks for. */ + send(text: string, { model, effort }: TurnPick = {}): Promise { if (!this.alive) return Promise.resolve({ ok: false, error: "Claude Code is not running", }); + // The model first: effort applies to whichever model runs. + this.setModel(model !== undefined || !this.busy ? model : this.pickedModel); + this.setEffort(effort !== undefined || !this.busy ? effort : this.effort); this.pending++; const settled = new Promise((resolve) => this.waiters.push(resolve), @@ -224,12 +292,74 @@ export class ClaudeProcess { private write(message: Message) { return this.process.write(`${JSON.stringify(message)}\n`); } - private async request(request: Message) { - await this.write({ + /** Writes a control request: `written` settles with the write, `answer` + * with Claude Code's response. */ + private request(request: Message) { + const id = `buzz-${++this.requests}`; + const answer = new Promise((resolve) => + this.calls.set(id, resolve), + ); + const written = this.write({ type: "control_request", - request_id: `buzz-${++this.requests}`, + request_id: id, request, }); + return { written, answer }; + } + /** Sends a control request and resolves with Claude Code's response. */ + private call(request: Message): Promise { + const { written, answer } = this.request(request); + return written.then(() => answer); + } + /** Switches model without restarting, as the SDK's `setModel` does. Back to + * the session's own is the model it was launched with, or the default. A + * new model misses the prompt cache, which the owner accepts by picking. */ + private setModel(model: string | undefined) { + if (model === this.pickedModel) return; + this.pickedModel = model; + const next = model ?? this.ownModel; + void this.call({ + subtype: "set_model", + ...(next ? { model: next } : {}), + }).then((answer) => { + if (answer.subtype !== "success") + console.warn("Claude Code did not change its model", answer.error); + }); + } + /** Changes effort without restarting, which keeps the prompt cache: Claude + * Code sends effort per message. Clearing the flag setting would not restore + * the session's own level, so that is read before the first change and set + * back explicitly. Written ahead of the message without waiting, as Claude + * Code takes its input in order, so a turn never waits on it. */ + private setEffort(effort: string | undefined) { + if (effort === this.effort) return; + void this.readOwnEffort(); + const level = effort ?? this.ownEffort; + if (!level) { + console.warn("Claude Code's own effort is unknown; keeping the last one"); + return; + } + this.effort = effort; + void this.call({ + subtype: "apply_flag_settings", + settings: { effortLevel: level }, + }).then((answer) => { + if (answer.subtype !== "success") + console.warn("Claude Code did not change its effort", answer.error); + }); + } + /** Read once, ahead of the first change, so it is the session's own. */ + private readOwnEffort() { + this.readingEffort ??= this.call({ subtype: "get_settings" }).then( + (answer) => { + const applied = (answer.response as Message | undefined)?.applied as + | Message + | undefined; + if (typeof applied?.effort === "string") + this.ownEffort = applied.effort; + }, + ); + return this.readingEffort; } private settle(settled: Settled) { this.pending = 0; @@ -260,12 +390,23 @@ export class ClaudeProcess { private handle(message: Message) { if (typeof message.session_id === "string" && message.session_id) this.sessionId = message.session_id; - if (message.type === "system" && message.subtype === "init") + if (message.type === "system" && message.subtype === "init") { this.began = true; + // A picked model is not the session's own. + if (typeof message.model === "string" && this.pickedModel === undefined) + this.reported = message.model; + } if (message.type === "control_request") { void this.answer(message); return; } + if (message.type === "control_response") { + const response = (message.response ?? {}) as Message; + const id = String(response.request_id); + this.calls.get(id)?.(response); + this.calls.delete(id); + return; + } if ( message.type === "system" && message.subtype === "session_state_changed" diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index a31de4171..87a16c73e 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -51,6 +51,7 @@ export const apply: PluginModule["apply"] = (ctx) => { { id: "claude", title: "Claude", component: ClaudeTab }, { id: "settings", title: "Settings", component: SettingsTab }, ], + models: (agent) => runtime.models(agent.pubkey), run: (delivery) => runtime.run(delivery), timeoutMs: HANDOVER_MS, }); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 00e8eac8b..ab58a6eda 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -4,7 +4,7 @@ import type { Host } from "../../features/host/service"; import type { EventData, RelayEvent } from "../../features/relay/events"; import type { RelayData } from "../../features/relay/service"; import { fakeSpawn, flush } from "./claude-testing"; -import { ClaudeRuntime, DEFAULT_CONFIG } from "./runtime"; +import { ClaudeRuntime, DEFAULT_CONFIG, modelName } from "./runtime"; afterEach(() => { vi.restoreAllMocks(); @@ -118,6 +118,104 @@ it("hands a mention to its thread's session", async () => { expect(published).toEqual([]); }); +const EFFORTS = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, +]; +it("knows the agent's own model once one of its sessions has begun", async () => { + const mention = message("2", "@Claude can you look?", { + tags: [ + ["h", channel], + ["p", self], + ], + }); + const { runtime, deliver } = setup([mention]); + // Before then nothing says which model it runs, nor what else it could. + expect(await runtime.models(self)).toEqual({ + current: "", + models: [{ id: "", label: "Claude Code", efforts: EFFORTS }], + }); + await deliver({ type: "mention", event: mention }); + await flush(10); + expect((await runtime.models(self)).current).toBe("opus"); +}); +it("discovers models and effort without a turn or retaining a per-agent spare", async () => { + const { runtime, claudes } = setup(); + runtime.sync([{ pubkey: self, config: DEFAULT_CONFIG } as Agent]); + try { + expect(await runtime.models(self)).toEqual({ + current: "opus", + models: [ + { id: "opus", label: "Opus 5.5", efforts: EFFORTS, effort: "high" }, + { + id: "haiku", + label: "Haiku 5.5", + efforts: [EFFORTS[0], EFFORTS[2]], + effort: "high", + }, + { id: "legacy", label: "Legacy 1", efforts: [] }, + ], + }); + expect(claudes().filter((process) => !process.killed)).toHaveLength(1); + expect(claudes().every((process) => process.prompts.length === 0)).toBe( + true, + ); + } finally { + runtime.dispose(); + } +}); +it("still offers a configured model Claude Code does not list", async () => { + const { runtime } = setup(); + const config = { ...DEFAULT_CONFIG, model: "claude-sonnet-9-9" }; + runtime.sync([{ pubkey: self, config } as Agent]); + try { + const { current, models } = await runtime.models(self); + expect(current).toBe("claude-sonnet-9-9"); + expect(models.map((model) => model.label)).toEqual([ + "Sonnet 9.9", + "Opus 5.5", + "Haiku 5.5", + "Legacy 1", + ]); + } finally { + runtime.dispose(); + } +}); +it("runs a mention on the model and effort its delivery picked", async () => { + const mention = message("2", "@Claude think hard", { + tags: [ + ["h", channel], + ["p", self], + ], + }); + const { runtime, claudes, agent } = setup([mention]); + runtime.sync([{ pubkey: self, config: DEFAULT_CONFIG } as Agent]); + expect((await runtime.models(self)).current).toBe("opus"); + await runtime.run({ + trigger: { type: "mention", event: mention }, + channelId: channel, + agent, + config: DEFAULT_CONFIG, + model: "haiku", + effort: "low", + signal: new AbortController().signal, + } as Delivery); + await flush(10); + const claude = claudes().find((process) => process.prompts.length); + expect(claude?.models).toEqual(["haiku"]); + expect(claude?.efforts).toEqual(["low"]); + expect((await runtime.models(self)).current).toBe("opus"); + runtime.dispose(); +}); +it("reads model ids and aliases as names", () => { + expect(modelName("claude-opus-5-5")).toBe("Opus 5.5"); + expect(modelName("claude-sonnet-4-5-20250929")).toBe("Sonnet 4.5"); + expect(modelName("claude-opus-4[1m]")).toBe("Opus 4"); + expect(modelName("opus")).toBe("Opus"); + expect(modelName("my-proxy/model")).toBe("my-proxy/model"); +}); it("answers Claude's tool calls as the agent, in the thread it is working in", async () => { const root = message("1", "Build is red"); const mention = message("2", "@Claude can you look?", { diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index c0f2b271e..dd5a91757 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -10,6 +10,8 @@ import type { Agent, AgentHandle, Delivery, + Effort, + Models, } from "../../features/agents2/service"; import type { Host } from "../../features/host/service"; import type { EventData } from "../../features/relay/events"; @@ -27,6 +29,15 @@ import { } from "./prompt"; import { AgentSessions, localSessions, Spare } from "./sessions"; +/** What a session can switch to between turns. Claude Code runs `max` as + * `xhigh` in a stream-json session, so it is not offered. */ +const EFFORTS = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, +]; + export type Config = Readonly<{ /** A Claude model or alias; empty uses Claude Code's own default. */ model: string; @@ -128,6 +139,59 @@ export class ClaudeRuntime { this.notify(); } + /** The models the agent's next turn can run on, with their effort levels. + * Without a pick it uses its own: the configured one, or else the one its + * sessions report. `default` duplicates a listed model, so it is not offered. */ + async models(pubkey: string): Promise { + const entry = this.agents.get(pubkey); + const configured = entry?.config.model.trim(); + const { + models: listed, + model: reported, + effort, + } = (await entry?.sessions.metadata()) ?? { models: [] }; + // A turn without a pick keeps the session's effort on any model. + const resting = (efforts: readonly Effort[]) => + effort && efforts.some((level) => level.id === effort) ? { effort } : {}; + const own = configured || reported || ""; + const offered = listed.filter((model) => model.value !== "default"); + const models = offered.map((model) => { + const efforts = + model.supportsEffort === false + ? [] + : EFFORTS.filter( + (level) => + !model.supportedEffortLevels || + model.supportedEffortLevels.includes(level.id), + ); + return { + id: model.value, + label: modelName(model.resolvedModel ?? model.value), + efforts, + ...resting(efforts), + }; + }); + const current = + offered.find( + (model) => own === model.value || own === model.resolvedModel, + )?.value ?? own; + // An own model Claude Code does not list is still the one it runs. + return models.some((model) => model.id === current) + ? { current, models } + : { + current, + models: [ + { + id: current, + label: current ? modelName(current) : "Claude Code", + efforts: EFFORTS, + ...resting(EFFORTS), + }, + ...models, + ], + }; + } + /** Conversations each agent has running, for its status view. */ sessions(pubkey: string) { let view = this.views.get(pubkey); @@ -154,7 +218,14 @@ export class ClaudeRuntime { this.agents.clear(); } - async run({ trigger, agent, config: raw, channelId }: Delivery) { + async run({ + trigger, + agent, + config: raw, + channelId, + model, + effort, + }: Delivery) { const settings = config(raw); if ( trigger.type !== "timer" && @@ -211,6 +282,7 @@ export class ClaudeRuntime { const done = entry.sessions.deliver(key, text, event.created_at, { steer: steerPrompt(text), fresh: async () => (await prompt(true)).text, + pick: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, }); this.notify(); this.watch(done, async (error) => { @@ -451,3 +523,14 @@ export class ClaudeRuntime { } } } + +/** `claude-opus-5-5` reads as `Opus 5.5`, and an alias such as `opus` as + * `Opus`. Anything else is shown as it is. */ +export function modelName(model: string) { + const id = + /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:\[.*\])?$/.exec(model); + const family = id?.[1] ?? (/^[a-z]+$/.test(model) ? model : undefined); + if (!family) return model; + const name = family[0]?.toUpperCase() + family.slice(1); + return id ? `${name} ${id[2]}${id[3] ? `.${id[3]}` : ""}` : name; +} diff --git a/src/bundled/claude-code/sessions.test.ts b/src/bundled/claude-code/sessions.test.ts index 478c70500..191d77dda 100644 --- a/src/bundled/claude-code/sessions.test.ts +++ b/src/bundled/claude-code/sessions.test.ts @@ -307,6 +307,21 @@ it("starts over when a saved session no longer loads", async () => { expect(store.get("c/root")).toMatchObject({ id: "new-1", seen: 9 }); }); +it("runs a turn with the model and effort it was delivered with, starting over included", async () => { + const store = memoryStore(); + store.set("c/root", { id: "lost", seen: 4, at: 1 }); + const { pool, claudes } = sessions({ store }); + await pool.deliver("c/root", "hello", 9, { + fresh: async () => "hello again", + pick: { model: "haiku", effort: "low" }, + }); + await pool.deliver("c/root", "next", 10); + const fresh = claudes().find((process) => process.prompts.length); + expect(fresh?.prompts).toEqual(["hello again", "next"]); + expect(fresh?.models).toEqual(["haiku", "opus"]); + expect(fresh?.efforts).toEqual(["low", "high"]); +}); + it("runs any number of conversations at once", async () => { const { pool, live } = sessions({ hold: true }); for (let index = 0; index < 12; index++) @@ -404,3 +419,34 @@ it("keeps saved sessions per agent in storage", () => { data.set("buzz.claude-code.sessions.v1", "not json"); expect(one.get("c/root")).toBeUndefined(); }); + +it("shares a temporary metadata lookup, releases its process, and refreshes after settings change", async () => { + const { pool, claudes, live, setModel } = sessions(); + const [first, second] = await Promise.all([pool.metadata(), pool.metadata()]); + expect(first).toEqual(second); + expect(first.model).toBe("claude-opus-5-5"); + expect(claudes()).toHaveLength(1); + expect(live()).toHaveLength(0); + await pool.metadata(); + expect(claudes()).toHaveLength(1); + setModel("haiku"); + pool.reconfigure(); + expect((await pool.metadata()).model).toBe("claude-haiku-5-5"); + expect(live()).toHaveLength(0); + pool.dispose(); +}); + +it("does not start metadata discovery after its agent is disposed during launch", async () => { + let release!: () => void; + const launched = new Promise((resolve) => { + release = resolve; + }); + const { pool, claudes } = sessions({ launched }); + const result = pool.metadata(); + await flush(); + pool.dispose(); + const stopped = expect(result).rejects.toThrow("stopped"); + release(); + await stopped; + expect(claudes()).toHaveLength(0); +}); diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index 0b80ab674..b5a23ba37 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -6,9 +6,11 @@ import { ClaudeProcess, type ClaudeLaunch, + type ClaudeModel, type Settled, type Spawn, type ToolServer, + type TurnPick, } from "./claude"; /** A conversation's process stops after this long without a message. */ @@ -54,6 +56,17 @@ type Live = { export class AgentSessions { private readonly live = new Map(); private readonly opening = new Map>(); + private discovery: + | { + fingerprint: string; + result: Promise<{ + models: readonly ClaudeModel[]; + model: string | undefined; + effort: string | undefined; + }>; + process?: ClaudeProcess | undefined; + } + | undefined; private disposed = false; private readonly now: () => number; private readonly newId: () => string; @@ -75,9 +88,68 @@ export class AgentSessions { })); } + /** Read defaults without assigning the shared spare to this agent. */ + async metadata() { + const fingerprint = this.options.fingerprint(); + const read = async (process: ClaudeProcess) => { + const [models, model, effort] = await Promise.all([ + process.models(), + process.readModel(), + process.readEffort(), + ]); + return { models, model, effort }; + }; + for (const live of this.live.values()) + if (live.fingerprint === fingerprint && live.process.running) { + const metadata = await read(live.process); + const discovered = + this.discovery?.fingerprint === fingerprint + ? await this.discovery.result + : undefined; + return { ...metadata, model: metadata.model ?? discovered?.model }; + } + if (this.disposed) + return { models: [], model: undefined, effort: undefined }; + if (this.discovery?.fingerprint === fingerprint) + return this.discovery.result; + this.clearDiscovery(); + const discovery: NonNullable = { + fingerprint, + result: Promise.resolve().then(async () => { + const launch = await this.options.launch(); + if (this.disposed || this.discovery !== discovery) + throw new Error("Claude Code was stopped"); + const process = await ClaudeProcess.start(this.options.spawn, { + cwd: launch.cwd, + ...(launch.model ? { model: launch.model } : {}), + systemPrompt: "", + }); + discovery.process = process; + try { + if (this.disposed || this.discovery !== discovery) + throw new Error("Claude Code was stopped"); + return await read(process); + } finally { + await process.kill(); + discovery.process = undefined; + } + }), + }; + this.discovery = discovery; + void discovery.result.catch(() => { + if (this.discovery === discovery) this.discovery = undefined; + }); + return discovery.result; + } + private clearDiscovery() { + void this.discovery?.process?.kill(); + this.discovery = undefined; + } + /** Sends `text` into the conversation `key`, and resolves when the session * has answered it. `steer` replaces `text` when the session is mid-turn; - * `fresh` builds it again for a new session when the saved one is lost. */ + * `fresh` builds it again for a new session when the saved one is lost; + * `pick` is the model and effort the turn was asked to run with. */ async deliver( key: string, text: string, @@ -85,7 +157,12 @@ export class AgentSessions { { steer = text, fresh, - }: Readonly<{ steer?: string; fresh?: () => Promise }> = {}, + pick = {}, + }: Readonly<{ + steer?: string; + fresh?: () => Promise; + pick?: TurnPick; + }> = {}, ): Promise { if (this.disposed) return { ok: false, error: "Claude Code was stopped" }; // A message that arrives while its conversation's process starts uses it. @@ -103,7 +180,7 @@ export class AgentSessions { } if (live) { this.touch(key, live, seen); - return this.answer(key, live, live.process.busy ? steer : text); + return this.answer(key, live, live.process.busy ? steer : text, pick); } const saved = this.options.store.get(key); const starting = this.start(saved?.id).catch((error) => { @@ -119,13 +196,13 @@ export class AgentSessions { void live.process.kill(); return { ok: false, error: "Claude Code was stopped" }; } - const delivered = await this.answer(key, live, text); + const delivered = await this.answer(key, live, text, pick); // A saved session that no longer loads starts over rather than failing // every message in the conversation, shown the conversation so far. if (!delivered.ok && saved && !live.process.started) { this.options.store.delete(key); this.drop(key, live); - return this.deliver(key, fresh ? await fresh() : text, seen); + return this.deliver(key, fresh ? await fresh() : text, seen, { pick }); } return delivered; } @@ -133,6 +210,7 @@ export class AgentSessions { /** Stops idle processes started with old settings; busy ones stop when they * next fall idle. */ reconfigure() { + this.clearDiscovery(); const fingerprint = this.options.fingerprint(); for (const [key, live] of this.live) if (live.fingerprint !== fingerprint && !live.process.busy) @@ -141,6 +219,7 @@ export class AgentSessions { dispose() { this.disposed = true; + this.clearDiscovery(); for (const [key, live] of this.live) this.drop(key, live); } @@ -181,8 +260,8 @@ export class AgentSessions { if (this.live.get(key) === live) this.drop(key, live); }); } - private async answer(key: string, live: Live, text: string) { - const settled = await live.process.send(text); + private async answer(key: string, live: Live, text: string, pick: TurnPick) { + const settled = await live.process.send(text, pick); if (this.live.get(key) === live) { this.touch(key, live); if (live.fingerprint !== this.options.fingerprint() && !live.process.busy) diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 37195bef2..1c836296f 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -41,6 +41,7 @@ export const apply: PluginModule["apply"] = (ctx) => { { id: "codex", title: "Codex", component: CodexTab }, { id: "settings", title: "Settings", component: SettingsTab }, ], + models: (agent) => runtime.preview(agent), run: (delivery) => runtime.run(delivery), timeoutMs: 2 * 60_000, }); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index b2d6599e0..cbc1c7d38 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -76,8 +76,21 @@ function fixture( data: [ { model: "test-model", + displayName: "Test Model", isDefault: true, defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["low", "medium", "high"].map( + (reasoningEffort) => ({ reasoningEffort, description: "" }), + ), + }, + { + model: "deep-model", + displayName: "", + isDefault: false, + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["high", "xhigh"].map( + (reasoningEffort) => ({ reasoningEffort, description: "" }), + ), }, ], nextCursor: null, @@ -550,6 +563,126 @@ it("reopens the shared server after it exits", async () => { await f.runtime.run(f.delivery("again")); await vi.waitFor(() => expect(servers(f)).toHaveLength(2)); }); +it("discovers models and efforts before any turn, sharing one read-only lookup", async () => { + const f = fixture(); + const agent = { pubkey, config: { workspace: "/tmp/codex-test" } } as Agent; + const one = f.runtime.preview(agent); + const two = f.runtime.preview(agent); + f.runtime.sync([agent]); + expect(one).toBe(two); + await expect(one).resolves.toEqual({ + current: "test-model", + models: [ + { + id: "test-model", + label: "Test Model", + efforts: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + effort: "medium", + }, + { + id: "deep-model", + label: "deep-model", + efforts: [ + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, + ], + effort: "high", + }, + ], + }); + expect(f.spawn).toHaveBeenCalledTimes(1); + expect(f.spawn.mock.calls[0]?.[1]?.cwd).toBe("~/.buzz"); + expect(f.starts()).toEqual([]); + expect(f.sent.some((wire) => wire.method === "thread/start")).toBe(false); + expect(f.process.end).not.toHaveBeenCalled(); + expect(f.runtime.preview(agent)).toBe(one); + f.runtime.sync([]); + f.spawn.mockRejectedValueOnce(new Error("offline")); + await expect(f.runtime.preview(agent)).rejects.toThrow("offline"); +}); +it("shares model discovery with active turns and another agent", async () => { + const f = fixture(); + await f.runtime.run(f.delivery("work")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + const b = second(f); + await Promise.all([ + f.runtime.preview(f.delivery("unused").agent as unknown as Agent), + f.runtime.preview(b.agent as unknown as Agent), + ]); + expect(servers(f)).toHaveLength(1); + expect(f.process.end).not.toHaveBeenCalled(); + f.complete(); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); +}); +it("still offers the listed models when the configured one is not listed", async () => { + const f = fixture(); + const agent = { + pubkey, + config: { workspace: "/tmp/codex-test", model: "newer-model" }, + } as Agent; + const { current, models } = await f.runtime.preview(agent); + expect(current).toBe("newer-model"); + expect(models.map((model) => [model.id, model.efforts.length])).toEqual([ + ["newer-model", 0], + ["test-model", 3], + ["deep-model", 2], + ]); +}); +it("previews each model at the configured effort when it has it", async () => { + const f = fixture(); + const agent = { + pubkey, + config: { workspace: "/tmp/codex-test", effort: "low" }, + } as Agent; + const { models } = await f.runtime.preview(agent); + expect(models.map((model) => model.effort)).toEqual(["low", "high"]); +}); +it("starts a turn at the effort its owner picked when the model supports it", async () => { + const f = fixture(); + await f.runtime.run({ ...f.delivery("@Codex hard"), effort: "high" }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.runtime.run({ + ...f.delivery("@Codex other", "e".repeat(64)), + effort: "max", + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + expect( + f.starts().map((wire) => (wire.params as { effort?: string }).effort), + ).toEqual(["high", "low"]); +}); +it("starts a turn on the model its owner picked, at an effort that model supports", async () => { + const f = fixture(); + const run = async ( + thread: string, + pick: { model?: string; effort?: string }, + ) => { + const before = f.starts().length; + await f.runtime.run({ ...f.delivery("@Codex", thread), ...pick }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(before + 1)); + }; + await run("a".repeat(64), { model: "deep-model", effort: "xhigh" }); + // The configured `low` is not one of its levels, so it runs at its default. + await run("b".repeat(64), { model: "deep-model" }); + // A model Codex no longer lists falls back to the agent's own. + await run("c".repeat(64), { model: "gone-model" }); + expect( + f.starts().map((wire) => { + const { model, effort } = wire.params as { + model: string; + effort: string; + }; + return { model, effort }; + }), + ).toEqual([ + { model: "deep-model", effort: "xhigh" }, + { model: "deep-model", effort: "high" }, + { model: "test-model", effort: "low" }, + ]); +}); it("serializes owner follow-ups and routes tool replies to the latest accepted request", async () => { const f = fixture(); await f.runtime.run(f.delivery("first")); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 7f0f9cef7..47559abbf 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -2,6 +2,7 @@ import type { Agent, AgentHandle, Delivery, + Models, } from "../../features/agents2/service"; import type { RelayData } from "../../features/relay/service"; import type { EventData } from "../../features/relay/events"; @@ -11,6 +12,7 @@ import { config, defaults, absoluteWorkspace, + effortName, validWorkspace, type Config, } from "./config"; @@ -40,6 +42,9 @@ type Request = { /** Latest request's thread, independent of session scope; absent in DMs. */ toolRoot?: string; interest: string; + /** The model and effort the owner picked for this turn. */ + model?: string; + effort?: string; }; type Saved = { threadId: string; workspace: string }; type Active = { @@ -94,6 +99,8 @@ export class CodexRuntime { private stopping = new Map>(); private views = new Map(); private listeners = new Set<() => void>(); + /** Read-only model discovery, kept per agent until its settings change. */ + private previews = new Map }>(); constructor( private readonly spawn: Spawn, /** The owner's connection to each agent's own community. */ @@ -108,6 +115,76 @@ export class CodexRuntime { }; sessions = (pubkey: string): readonly SessionView[] => this.views.get(pubkey) ?? EMPTY; + preview(agent: Agent) { + const settings = config(agent.config); + const key = JSON.stringify([ + settings.workspace, + settings.model, + settings.effort, + ]); + const existing = this.previews.get(agent.pubkey); + if (existing?.key === key) return existing.ready; + const server = this.open(); + const rpc = server.rpc; + const ready = (async () => { + const workspace = await this.workspace(settings.workspace); + await server.opening; + const normalized = await rpc.request<{ + config: { model?: string | null }; + }>("config/read", { cwd: workspace }); + const models = await listModels(rpc); + const model = + settings.model || + normalized.config.model || + models.find((row) => row.isDefault)?.model; + const offered = models.map((row) => { + const efforts = row.supportedReasoningEfforts.map( + ({ reasoningEffort: id }) => ({ id, label: effortName(id) }), + ); + // As a run picks it: the configured effort if the model has it. + const effort = efforts.some((level) => level.id === settings.effort) + ? settings.effort + : row.defaultReasoningEffort; + return { + id: row.model, + label: row.displayName || row.model, + efforts, + ...(effort ? { effort } : {}), + }; + }); + // A configured model this Codex does not list still leaves the + // listed ones to pick, so the picker stays. + return !model || models.some((row) => row.model === model) + ? { current: model ?? "", models: offered } + : { + current: model, + models: [{ id: model, label: model, efforts: [] }, ...offered], + }; + })(); + const entry = { key, ready }; + this.previews.set(agent.pubkey, entry); + void ready.catch(() => { + if (this.previews.get(agent.pubkey) === entry) + this.previews.delete(agent.pubkey); + }); + return ready; + } + + private async workspace(path: string) { + if (absoluteWorkspace(path)) return path; + let directory = ""; + const process = await this.spawn("workspace", { + cwd: path, + onStdout: (data) => { + directory += data; + }, + }); + const code = await process.exited; + const workspace = directory.trim(); + if (code !== 0 || !absoluteWorkspace(workspace)) + throw new Error("Could not resolve the default Codex workspace."); + return workspace; + } private status(pubkey: string, key: string, status: string, detail = "") { // A stopped agent's draining turns don't bring its sessions back. if (!this.entries.has(pubkey)) return; @@ -120,6 +197,10 @@ export class CodexRuntime { } sync(agents: readonly Agent[]) { const present = new Set(agents.map((agent) => agent.pubkey)); + for (const pubkey of this.previews.keys()) + if (!present.has(pubkey)) { + this.previews.delete(pubkey); + } for (const [pubkey, entry] of this.entries) if (!present.has(pubkey)) { entry.abort.abort(); @@ -127,7 +208,7 @@ export class CodexRuntime { this.views.delete(pubkey); if (this.server) this.stop(this.server, pubkey, entry); } - if (!this.entries.size) this.close(); + if (!present.size) this.close(); for (const listener of this.listeners) listener(); } /** Other agents share the server, so a stopped agent's turns interrupt @@ -155,6 +236,7 @@ export class CodexRuntime { }); } dispose() { + this.previews.clear(); for (const entry of this.entries.values()) entry.abort.abort(); this.entries.clear(); this.views.clear(); @@ -262,6 +344,8 @@ export class CodexRuntime { agent, settings, ...(toolRoot ? { toolRoot } : {}), + ...(delivery.model ? { model: delivery.model } : {}), + ...(delivery.effort ? { effort: delivery.effort } : {}), conversation: { channelId, name: channel?.name ?? channelId, @@ -573,21 +657,8 @@ export class CodexRuntime { signal.throwIfAborted(); // The process host expands ~/.buzz and creates it; Codex's RPC needs // an absolute path rather than shell-style home notation. - let workspace = settings.workspace; - if (!absoluteWorkspace(workspace)) { - let directory = ""; - const process = await this.spawn("workspace", { - cwd: workspace, - onStdout: (data) => { - directory += data; - }, - }); - const code = await process.exited; - workspace = directory.trim(); - if (code !== 0 || !absoluteWorkspace(workspace)) - throw new Error("Could not resolve the default Codex workspace."); - signal.throwIfAborted(); - } + const workspace = await this.workspace(settings.workspace); + signal.throwIfAborted(); active.workspace = workspace; const existing = entry.saved[key]; const normalized = await rpc.request<{ @@ -597,16 +668,26 @@ export class CodexRuntime { }; }>("config/read", { cwd: workspace }); const models = await listModels(rpc); - const model = + const own = settings.model || normalized.config.model || models.find((row) => row.isDefault)?.model; - const selected = models.find((row) => row.model === model); + // Model and effort are per turn, so a pick changes nothing after it. + const picked = models.find((row) => row.model === request.model); + const selected = picked ?? models.find((row) => row.model === own); if (!selected) throw new Error( "The selected Codex model is unavailable. Choose an available model in Settings.", ); - const effort = settings.effort || selected.defaultReasoningEffort; + const model = selected.model; + const supported = selected.supportedReasoningEfforts.map( + (level) => level.reasoningEffort, + ); + // A picked model may not support the configured effort. + const effort = + [request.effort, settings.effort].find( + (level) => level && supported.includes(level), + ) ?? selected.defaultReasoningEffort; const mcp = Object.fromEntries( Object.entries(normalized.config.mcp_servers ?? {}).map( ([name, server]) => [ diff --git a/src/features/agents2/react.test.tsx b/src/features/agents2/react.test.tsx index fc14c1a28..7dbdc721a 100644 --- a/src/features/agents2/react.test.tsx +++ b/src/features/agents2/react.test.tsx @@ -1,9 +1,18 @@ // @vitest-environment jsdom -import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { act, waitFor, renderHook } from "@testing-library/react"; import { expect, it } from "vitest"; -import { useAgent2 } from "./react"; +import { useAgent2, useMentionModels, Agents2Context } from "./react"; import type { Agent, Agents2 } from "./service"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + it("still finds a saved agent whose type plugin is not registered", () => { const agent = { pubkey: "a".repeat(64), type: "gone/echo" } as Agent; const agents2 = { @@ -16,3 +25,107 @@ it("still finds a saved agent whose type plugin is not registered", () => { const other = renderHook(() => useAgent2(agents2, "b".repeat(64))); expect(other.result.current).toBeUndefined(); }); + +const high = { id: "high", label: "High" }; +const models = (current: string) => ({ + current, + models: [{ id: current, label: current, efforts: [high] }], +}); + +it("loads models and their effort levels asynchronously before an agent runs", async () => { + const pending = deferred>(); + const agent = { pubkey: "agent", type: "codex", name: "Codex" } as Agent; + const snapshot = { status: "ready", agents: [agent] }; + const types = [{ key: "codex", models: () => pending.promise }]; + const service = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useMentionModels(["agent"]), { + wrapper, + }); + expect(result.current).toEqual([ + { pubkey: "agent", name: "Codex", current: "", models: [], loading: true }, + ]); + await act(async () => { + pending.resolve(models("GPT test")); + }); + expect(result.current).toEqual([ + { pubkey: "agent", name: "Codex", ...models("GPT test") }, + ]); +}); + +it("offers nothing for a single model without effort levels", async () => { + const agents = ["fixed", "tunable"].map( + (pubkey) => ({ pubkey, type: pubkey, name: pubkey }) as Agent, + ); + const snapshot = { status: "ready", agents }; + const types = [ + { + key: "fixed", + models: async () => ({ + current: "only", + models: [{ id: "only", label: "Only", efforts: [] }], + }), + }, + { key: "tunable", models: async () => models("Tunable") }, + ]; + const service = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useMentionModels(["fixed", "tunable"]), { + wrapper, + }); + // Both lookups settle together, so the tunable one is the barrier. + await waitFor(() => + expect(result.current.map((choice) => choice.pubkey)).toEqual(["tunable"]), + ); +}); + +it("discards pending metadata after the mentioned agent changes", async () => { + const pending = deferred>(); + const agents = ["old", "new"].map( + (pubkey) => ({ pubkey, type: "type", name: pubkey }) as Agent, + ); + const snapshot = { status: "ready", agents }; + const types = [ + { + key: "type", + models: async (agent: Agent) => + agent.pubkey === "old" ? pending.promise : models("New model"), + }, + ]; + const service = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result, rerender } = renderHook( + ({ pubkey }) => useMentionModels([pubkey]), + { initialProps: { pubkey: "old" }, wrapper }, + ); + rerender({ pubkey: "new" }); + await waitFor(() => expect(result.current[0]?.current).toBe("New model")); + await act(async () => { + pending.resolve(models("Old model")); + }); + expect(result.current.map((choice) => choice.pubkey)).toEqual(["new"]); +}); diff --git a/src/features/agents2/react.ts b/src/features/agents2/react.ts index 511c86a3f..ed031ad74 100644 --- a/src/features/agents2/react.ts +++ b/src/features/agents2/react.ts @@ -1,7 +1,14 @@ -import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import type { ChannelQueries } from "../relay/contracts"; import type { ChannelChoice } from "./attention"; -import type { Agents2 } from "./service"; +import type { Agents2, Models } from "./service"; const noSubscribe = () => () => {}; const none = () => undefined; @@ -40,6 +47,85 @@ export function useAgent2(agents2: Agents2 | undefined, pubkey: string) { return agent ? { agent, type } : undefined; } +/** The app's Agents2, for surfaces such as the composer that plugins do not own. */ +export const Agents2Context = createContext(undefined); + +export type MentionModelChoice = Models & + Readonly<{ pubkey: string; name: string; loading?: boolean }>; +/** The mentioned agents, of the viewer's own, whose type offers a choice of + * model or effort. */ +export function useMentionModels( + pubkeys: readonly string[], +): readonly MentionModelChoice[] { + const agents2 = useContext(Agents2Context); + const subscribe = agents2?.subscribe ?? noSubscribe; + const snapshot = useSyncExternalStore( + subscribe, + agents2 ? agents2.snapshot : none, + none, + ); + const types = useSyncExternalStore( + subscribe, + agents2 ? agents2.types : none, + none, + ); + const key = [...new Set(pubkeys)].join(":"); + const [resolved, setResolved] = useState<{ + snapshot: typeof snapshot; + types: typeof types; + key: string; + choices: readonly MentionModelChoice[]; + }>(); + useEffect(() => { + let current = true; + void Promise.all( + key.split(":").map(async (pubkey): Promise => { + const agent = snapshot?.agents.find((item) => item.pubkey === pubkey); + const type = agent && types?.find((item) => item.key === agent.type); + if (!agent || !type?.models) return []; + try { + const { current, models } = await type.models(agent); + return models.length > 1 || models.some((m) => m.efforts.length) + ? [{ pubkey, name: agent.name, current, models }] + : []; + } catch (error) { + console.warn( + `Agent type ${type.key} could not list its models`, + error, + ); + return []; + } + }), + ).then((choices) => { + if (current) + setResolved({ snapshot, types, key, choices: choices.flat() }); + }); + return () => { + current = false; + }; + }, [snapshot, types, key]); + // Never offer a previous agent/config's choices while the new lookup settles. + return resolved?.snapshot === snapshot && + resolved?.types === types && + resolved?.key === key + ? resolved.choices + : key.split(":").flatMap((pubkey) => { + const agent = snapshot?.agents.find((item) => item.pubkey === pubkey); + const type = agent && types?.find((item) => item.key === agent.type); + return agent && type?.models + ? [ + { + pubkey, + name: agent.name, + current: "", + models: [], + loading: true, + }, + ] + : []; + }); +} + const noList = () => undefined; /** Channels a watch can be scoped to, by name, from the viewer's joined roster. */ export function useChannelChoices( diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 6a48bf26e..93f2111f3 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -246,6 +246,20 @@ async function setup({ }, }, }), + models: async () => ({ + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + { id: "deep", label: "Deep", efforts: [{ id: "max", label: "Max" }] }, + ], + }), run, }; plugin.agents2.register(type); @@ -344,6 +358,37 @@ it("treats a reply to something the agent published as addressed", async () => { expect(run.mock.calls[1]?.[0].trigger.type).toBe("mention"); }); +it("passes the model and effort its owner asked for, and no other", async () => { + const { service, run, emit } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const asked = (id: string, pubkey: string, ...tags: string[][]) => + event(id, { pubkey, tags: [["h", channel], ["p", bot], ...tags] }); + emit({ + events: [ + asked("owner", viewer, ["effort", bot, "high"]), + asked("stranger", other, ["effort", bot, "high"], ["model", bot, "deep"]), + asked("unlisted", viewer, ["effort", bot, "max"]), + asked("elsewhere", viewer, ["effort", sibling, "high"]), + asked("model", viewer, ["model", bot, "deep"], ["effort", bot, "max"]), + asked("mismatch", viewer, ["model", bot, "deep"], ["effort", bot, "low"]), + asked("unknown", viewer, ["model", bot, "gone"], ["effort", bot, "low"]), + ], + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(7)); + expect( + run.mock.calls.map(([{ model, effort }]) => ({ model, effort })), + ).toEqual([ + { model: undefined, effort: "high" }, + { model: undefined, effort: undefined }, + { model: undefined, effort: undefined }, + { model: undefined, effort: undefined }, + // An effort counts only for the model the run uses. + { model: "deep", effort: "max" }, + { model: "deep", effort: undefined }, + { model: undefined, effort: "low" }, + ]); +}); + it("keeps the in-flight run and the queue across edits; each job reads the agent as it starts", async () => { const { service, run, emit, native } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index 9c9fe592c..7c5fc46bc 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -14,6 +14,7 @@ import { removeAgentFromChannels } from "../agents/relay-removal"; import { communityDestination, relayOrigin } from "../communities/destination"; import type { Communities } from "../communities/service"; import type { RelayEvent } from "../relay/events"; +import { EFFORT_TAG, MODEL_TAG } from "../relay/messages"; import type { LiveBatch } from "../relay/incoming"; import { relayPartition } from "../relay/partition"; import type { RelayData } from "../relay/service"; @@ -129,10 +130,27 @@ export type Delivery = Readonly<{ agent: AgentHandle; /** The agent's config when this run started. */ config: Config; + /** The model the owner asked for in the triggering message: one of the + * type's `models`. Absent means the agent's own model. */ + model?: string; + /** The effort level the owner asked for in the triggering message: one of + * that model's `efforts`. Absent means the agent's own default. */ + effort?: string; /** Aborts on the run's deadline, or when the agent is removed or its type is * replaced. Advisory: the handle keeps working, so finishing up is harmless. */ signal: AbortSignal; }>; +export type Effort = Readonly<{ id: string; label: string }>; +/** A model with its effort levels, lowest first. */ +export type Model = Readonly<{ + id: string; + label: string; + efforts: readonly Effort[]; + /** The effort a run on it uses without a pick, when known. */ + effort?: string; +}>; +/** The models an owner can pick from, and the id of the one used without a pick. */ +export type Models = Readonly<{ current: string; models: readonly Model[] }>; export type AgentType = { id: string; title: string; @@ -148,6 +166,10 @@ export type AgentType = { Peek?: ComponentType>; /** The type's settings tabs, in order. The host adds Attention after them. */ tabs?: readonly AgentTab[]; + /** Models and effort levels the owner can pick for the next run while + * mentioning the agent. Discovery may be async but must not start a turn. + * The picks reach that run as `Delivery.model` and `Delivery.effort`. */ + models?(agent: Agent): Models | Promise; run?(delivery: Delivery): void | Promise; /** Per-run deadline in milliseconds, up to 30 minutes; defaults to 60 seconds. */ timeoutMs?: number; @@ -216,6 +238,39 @@ const bounded = (set: Set, id: string) => { set.add(id); if (set.size > SEEN_LIMIT) set.delete(set.values().next().value as string); }; +/** The model and effort the triggering message asks of this agent. Only the + * owner picks them, so nobody else in a channel can run the agent at their + * level, and only ones the type lists: effort from the model the run uses. */ +async function pickFor( + trigger: Trigger, + agent: Agent, + type: RegisteredAgentType, +): Promise<{ model?: string; effort?: string }> { + if (trigger.type === "timer" || trigger.event.pubkey !== agent.owner) + return {}; + const asked = (name: string) => + trigger.event.tags.find( + (tag) => tag[0] === name && tag[1] === agent.pubkey, + )?.[2]; + const model = asked(MODEL_TAG); + const effort = asked(EFFORT_TAG); + if (!model && !effort) return {}; + try { + const offered = await type.models?.(agent); + const picked = offered?.models.find((item) => item.id === model); + const runs = + picked ?? offered?.models.find((item) => item.id === offered.current); + return { + ...(picked ? { model: picked.id } : {}), + ...(effort && runs?.efforts.some((level) => level.id === effort) + ? { effort } + : {}), + }; + } catch (error) { + console.warn(`Agent type ${type.key} could not list its models`, error); + return {}; + } +} /** What Agents2 needs of the communities: the viewer, the selected one, and * a connection to any joined one without selecting it. */ @@ -1010,17 +1065,20 @@ export class Agents2Service extends Service implements Agents2 { }); try { await Promise.race([ - Promise.resolve().then(() => - run( + Promise.resolve().then(async () => { + const pick = await pickFor(job.trigger, agent, type); + signal.throwIfAborted(); + return run( Object.freeze({ trigger: job.trigger, ...(job.channelId ? { channelId: job.channelId } : {}), agent: handle, config: agent.config, + ...pick, signal, }), - ), - ), + ); + }), new Promise((_, reject) => { if (signal.aborted) reject(signal.reason); signal.addEventListener("abort", () => reject(signal.reason), { diff --git a/src/features/messages/ComposerModels.module.css b/src/features/messages/ComposerModels.module.css new file mode 100644 index 000000000..cb6ee099b --- /dev/null +++ b/src/features/messages/ComposerModels.module.css @@ -0,0 +1,196 @@ +/* The controls are the popup: no surface around them. */ +.popup { + display: grid; + gap: var(--space-3); + width: 20rem; + max-width: var(--available-width); + outline: none; +} + +.agent { + display: grid; + gap: var(--space-2); +} + +.model { + display: grid; + min-width: 0; + max-width: 100%; +} + +.slider { + --track: calc(var(--space-8) + var(--space-2)); + --thumb-height: var(--space-8); + --thumb-width: var(--space-8); + --shoulder: var(--space-2); + --tab-inset: calc(var(--track) / 2); + display: grid; + width: 100%; +} + +.control { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: var(--track); + cursor: pointer; + touch-action: none; + user-select: none; +} + +.slider[data-disabled] .control { + cursor: not-allowed; +} + +.track { + position: relative; + width: 100%; + height: 100%; + border: 1px solid var(--border-standard); + border-radius: var(--radius-pill); + background: var(--surface-popover); + box-shadow: var(--shadow-sm); +} + +.indicator { + inset-block-start: 50%; + max-height: var(--space-6); + border-radius: var(--radius-pill); + background: var(--affordance-subtle); + margin-inline-start: var(--space-1); + transform: translateY(-50%); + transition: width var(--duration-settle) var(--easing-settle); +} + +/* Without a pick it still shows the level the run uses, if known. */ +.slider[data-unknown] .indicator { + visibility: hidden; +} + +.stop { + position: absolute; + inset-block-start: 50%; + inset-inline-start: calc( + var(--thumb-width) / + 2 + + var(--stop) * + (100% - var(--thumb-width)) + ); + width: var(--space-half); + height: var(--space-1); + border-radius: var(--radius-pill); + background: var(--text-subtle); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.thumb { + width: var(--thumb-width); + height: var(--thumb-height); + border: var(--space-1) solid transparent; + border-radius: var(--radius-pill); + background: var(--affordance-prominent); + background-clip: padding-box; + transition: inset-inline-start var(--duration-settle) var(--easing-settle); +} + +.slider[data-unknown] .thumb { + background-color: var(--affordance-subtle); +} + +html[data-keyboard-navigation] .thumb:focus-within { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +.slider[data-disabled] .thumb { + background-color: var(--affordance-disabled); +} + +/* A single continuous rim: the tab covers the track's top stroke, while + * concave shoulders turn its sides into that stroke without a square seam. */ +.tab { + position: relative; + z-index: 1; + justify-self: end; + margin-inline: var(--tab-inset); + translate: 0 1px; + display: flex; + align-items: center; + gap: var(--space-2); + max-width: calc(100% - 2 * var(--tab-inset)); + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border-standard); + border-block-end: 0; + border-start-start-radius: var(--radius-row); + border-start-end-radius: var(--radius-row); + background: var(--surface-popover); + color: var(--text-standard); + white-space: nowrap; + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--text-caption--font-weight); +} + +.tab::before, +.tab::after { + content: ""; + position: absolute; + inset-block-end: 0; + width: var(--shoulder); + height: var(--shoulder); + background: radial-gradient( + circle at top left, + transparent calc(var(--shoulder) - 1px), + var(--border-standard) calc(var(--shoulder) - 1px), + var(--border-standard) var(--shoulder), + var(--surface-popover) var(--shoulder) + ); +} + +.tab::before { + inset-inline-end: 100%; +} + +.tab::after { + inset-inline-start: 100%; + transform: scaleX(-1); +} + +.about { + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-subtle); + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--text-caption--font-weight); +} + +.slider[data-dragging] .thumb, +.slider[data-dragging] .indicator, +html[data-keyboard-navigation] .slider * { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .slider * { + transition: none; + } +} + +.loading { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border-standard); + border-radius: var(--radius-row); + background: var(--surface-popover); + color: var(--text-subtle); + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--text-caption--font-weight); +} diff --git a/src/features/messages/ComposerModels.tsx b/src/features/messages/ComposerModels.tsx new file mode 100644 index 000000000..6b80dcbe7 --- /dev/null +++ b/src/features/messages/ComposerModels.tsx @@ -0,0 +1,282 @@ +import { Popover } from "@base-ui/react/popover"; +import { Slider } from "@base-ui/react/slider"; +import { + useRef, + useState, + type CSSProperties, + type ReactNode, + type Ref, +} from "react"; +import { useMentionModels } from "../agents2/react"; +import type { MentionModelChoice } from "../agents2/react"; +import type { Effort } from "../agents2/service"; +import type { MentionPicks, RunPick } from "../relay/messages"; +import { + CaretDownIcon, + GaugeIcon, +} from "../../shared/design-system/icons/index"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { Button } from "../../shared/design-system/ui/Button"; +import { + MenuRoot, + MenuTrigger, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, +} from "../../shared/design-system/ui/Menu"; +import styles from "./ComposerModels.module.css"; + +/** Model and effort for the next run of each mentioned agent of the viewer's + * that offers a choice. Unpicked agents run with their own. */ +export function ComposerModels({ + pubkeys, + value, + disabled, + onChange, +}: { + pubkeys: readonly string[]; + value: MentionPicks; + disabled: boolean; + onChange(value: MentionPicks): void; +}) { + const [open, setOpen] = useState(false); + const choices = useMentionModels(pubkeys); + const thumb = useRef(null); + const trigger = useRef(null); + if (!choices.length) return null; + const picked = choices.flatMap((choice) => { + const { model, effort } = value[choice.pubkey] ?? {}; + const runs = runModel(choice, model); + const text = [ + model !== undefined && runs.label, + runs.efforts.find((item) => item.id === effort)?.label, + ] + .filter(Boolean) + .join(" · "); + return text ? [choices.length > 1 ? `${choice.name}: ${text}` : text] : []; + }); + const pick = (pubkey: string, next: RunPick) => { + const { [pubkey]: _, ...rest } = value; + onChange(next.model || next.effort ? { ...rest, [pubkey]: next } : rest); + }; + return ( + + + ); +} + +/** The model a run uses: the picked one, or else the agent's own. */ +const runModel = (choice: MentionModelChoice, picked: string | undefined) => + choice.models.find((item) => item.id === picked) ?? + choice.models.find((item) => item.id === choice.current) ?? + choice.models[0] ?? { id: "", label: "", efforts: [] }; + +function AgentChoice({ + choice, + named, + value, + disabled, + pick, + thumb, +}: { + choice: MentionModelChoice; + named: boolean; + value: RunPick; + disabled: boolean; + pick(next: RunPick): void; + thumb?: Ref; +}) { + if (choice.loading) + return ( +
+ {named && {choice.name}} + Loading model and effort… +
+ ); + const runs = runModel(choice, value.model); + const several = choice.models.length > 1; + // Picking the agent's own model is no pick; an effort the new model lacks + // falls back to its default. + const chooseModel = (id: string) => { + const next = choice.models.find((item) => item.id === id); + const effort = next?.efforts.some((item) => item.id === value.effort) + ? value.effort + : undefined; + pick({ + ...(id !== choice.current ? { model: id } : {}), + ...(effort ? { effort } : {}), + }); + }; + const model = several ? ( +
+ + + {runs.label} + +
+ ) : ( + {runs.label} + ); + return ( +
+ {named && {choice.name}} + {runs.efforts.length > 0 ? ( + + pick({ + ...(value.model ? { model: value.model } : {}), + ...(effort ? { effort } : {}), + }) + } + {...(thumb ? { thumb } : {})} + /> + ) : ( + model + )} +
+ ); +} + +function EffortSlider({ + name, + model, + efforts, + resting, + value, + disabled, + pick, + thumb, +}: { + name: string; + model: ReactNode; + efforts: readonly Effort[]; + /** The effort the run uses without a pick, when known. */ + resting: string | undefined; + value: string | undefined; + disabled: boolean; + pick(effort: string | undefined): void; + thumb?: Ref; +}) { + const index = efforts.findIndex((effort) => effort.id === value); + const set = index >= 0; + const own = efforts.findIndex((effort) => effort.id === resting); + const at = set ? index : Math.max(own, 0); + const last = Math.max(efforts.length - 1, 1); + const known = set || own >= 0; + const level = known ? (efforts[at]?.label ?? "") : "Default"; + // A tap on the thumb's resting stop changes no value, so commits pick too. + // The run's own effort is no pick. + const choose = (next: number) => { + const effort = efforts[next]?.id; + pick(effort === resting ? undefined : effort); + }; + return ( + + {/* Keep the model outside the drag target, on the track's attached tab. */} +
+ {model} + +
+ + + + {efforts.map((effort, stop) => ( + + ))} + level} + /> + + +
+ ); +} diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 3b682edc5..03836128a 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -44,6 +44,8 @@ import { UploadError, UPLOAD_FAILURES } from "../relay/attachments"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import type { OutgoingEvent } from "../relay/outbox"; import { ConversationPresentation } from "../conversation/ConversationPresentation"; +import { Agents2Context } from "../agents2/react"; +import type { Agents2 } from "../agents2/service"; import { MessageComposer, type MessageComposerProps } from "./MessageComposer"; import { createRelaySession, type RelaySession } from "../relay/session"; import { keypair, metadata, roster, signed } from "../relay/testing"; @@ -106,6 +108,7 @@ function mount( options: Partial = {}, control?: AgentControl, viewer?: string, + agents2?: Agents2, ) { let commands: ComposerToolProps; const completionRequests: ComposerCompletionProps["publish"][] = []; @@ -331,11 +334,13 @@ function mount( bindChoices(); let presented = true; const tree = () => ( - - - + + + + + ); const view = render(tree(), { reactStrictMode: true, @@ -2045,6 +2050,236 @@ it("retries publish-unknown attachment sends with the same signed event and no r expect(h.uploadCalls).toHaveLength(1); }); +it("opens the model picker while discovery is pending, then fills its controls", async () => { + const models = { + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ], + }; + const pending = deferred(); + const snapshot = { + status: "ready", + agents: [{ pubkey: first.pubkey, name: "Honey", type: "test/agent" }], + }; + const types = [{ key: "test/agent", models: () => pending.promise }]; + const agents2 = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const h = mount({}, undefined, undefined, agents2); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + await h.user.click( + screen.getByRole("button", { name: "Model and effort for the next run" }), + ); + expect( + screen.getByText("Loading model and effort…").closest("[role=status]"), + ).toHaveAttribute("aria-busy", "true"); + await act(async () => { + pending.resolve(models); + }); + expect( + await screen.findByLabelText("Effort for Honey's next run"), + ).toBeInTheDocument(); + expect( + screen.queryByText("Loading model and effort…"), + ).not.toBeInTheDocument(); +}); + +it("asks a mentioned agent of the viewer's for a model and effort on the next run only", async () => { + // Snapshots are stable, as the service's are. + const snapshot = { + status: "ready", + agents: [{ pubkey: first.pubkey, name: "Honey", type: "test/agent" }], + }; + const models = { + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + { + id: "deep", + label: "Deep", + efforts: [ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + }, + ], + }; + const types = [{ key: "test/agent", models: () => models }]; + const agents2 = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const h = mount({}, undefined, undefined, agents2); + const picker = () => + screen.queryByRole("button", { + name: "Model and effort for the next run", + }); + const open = async () => { + // Choices load asynchronously each time the agent is mentioned again. + await h.user.click( + await screen.findByRole("button", { + name: "Model and effort for the next run", + }), + ); + // The edge-aligned thumb stays hidden until it is measured, which jsdom + // cannot do, so the slider is not in the accessibility tree here. + return screen.findByLabelText("Effort for Honey's next run"); + }; + const model = () => + screen.getByRole("button", { name: "Model for Honey's next run" }); + // Writes the message and mentions an agent before any pick is made. + const write = async (name: string, text: string) => { + await h.user.type(h.input(), text); + await h.user.click(screen.getByRole("button", { name })); + }; + const send = async () => { + await h.user.keyboard("{Escape}"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + }; + expect(picker()).toBeNull(); + await h.user.click(screen.getByRole("button", { name: "Second Honey" })); + // Someone else's agent, or a person, offers no choice. + expect(picker()).toBeNull(); + await write("First Honey", "think hard"); + const slider = await open(); + expect(model()).toHaveTextContent("Fast"); + expect(slider).toHaveAttribute("aria-valuetext", "Default"); + expect(slider.closest("[data-unknown]")).not.toBeNull(); + slider.focus(); + await h.user.keyboard("{ArrowRight}"); + expect(slider).toHaveAttribute("aria-valuetext", "High"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("think hard"), + [second.pubkey, first.pubkey], + [], + undefined, + [], + { [first.pubkey]: { effort: "high" } }, + ); + await write("First Honey", "think deeper"); + const again = await open(); + expect(again).toHaveAttribute("aria-valuetext", "Default"); + // A new model keeps an effort it also offers. + again.focus(); + await h.user.keyboard("{ArrowRight}"); + await h.user.click(model()); + await h.user.click( + await screen.findByRole("menuitemradio", { name: "Deep" }), + ); + expect(model()).toHaveTextContent("Deep"); + const deep = await screen.findByLabelText("Effort for Honey's next run"); + expect(deep).toHaveAttribute("aria-valuetext", "High"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("think deeper"), + [first.pubkey], + [], + undefined, + [], + { [first.pubkey]: { model: "deep", effort: "high" } }, + ); + await write("First Honey", "as usual"); + await open(); + await h.user.click(model()); + await h.user.click( + await screen.findByRole("menuitemradio", { name: "Deep" }), + ); + // Back to the agent's own model is no pick at all. + await h.user.click(model()); + await h.user.click( + await screen.findByRole("menuitemradio", { name: "Fast" }), + ); + expect(model()).toHaveTextContent("Fast"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("as usual"), + [first.pubkey], + [], + ); +}); + +it("opens the effort picker at the effort the agent's next run uses", async () => { + const snapshot = { + status: "ready", + agents: [{ pubkey: first.pubkey, name: "Honey", type: "test/agent" }], + }; + const models = { + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + effort: "medium", + }, + ], + }; + const types = [{ key: "test/agent", models: () => models }]; + const agents2 = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const h = mount({}, undefined, undefined, agents2); + const open = async () => { + await h.user.click( + await screen.findByRole("button", { + name: "Model and effort for the next run", + }), + ); + return screen.findByLabelText("Effort for Honey's next run"); + }; + const send = async () => { + await h.user.keyboard("{Escape}"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + }; + await h.user.type(h.input(), "think hard"); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + const slider = await open(); + expect(slider).toHaveAttribute("aria-valuetext", "Medium"); + // A known level is drawn like a picked one, not muted. + expect(slider.closest("[data-unknown]")).toBeNull(); + slider.focus(); + await h.user.keyboard("{ArrowRight}"); + expect(slider).toHaveAttribute("aria-valuetext", "High"); + // Back to the agent's own effort is no pick at all. + await h.user.keyboard("{ArrowLeft}"); + expect(slider).toHaveAttribute("aria-valuetext", "Medium"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("think hard"), + [first.pubkey], + [], + ); +}); + it("sends channel messages and thread replies through real form and keyboard events", async () => { const h = mount(); await h.user.type(h.input(), "channel draft"); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 6224ca34d..03aef80b5 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -81,6 +81,8 @@ import { ComposerAccessories } from "../conversation/ComposerAccessories"; import { ComposerTools } from "../conversation/ComposerTools"; import { ComposerLinkDialog } from "./ComposerLinkDialog"; import { ComposerFormattingTools } from "./ComposerFormattingTools"; +import { ComposerModels } from "./ComposerModels"; +import type { MentionPicks } from "../relay/messages"; import type { ConversationExtensions, CompletionEdit, @@ -378,6 +380,8 @@ function Composer({ ); const draft = value.text; const valueRef = useRef(value); + // Model and effort picked for mentioned agents' next run; cleared by each send. + const [picks, setPicks] = useState({}); const caret = useRef(undefined); const input = useRef(null); const focusOnMount = useRef( @@ -1038,6 +1042,7 @@ function Composer({ sendAttempt.current = attempt; const captured = valueRef.current; const capturedAttachments = attachments.store.snapshot(); + const capturedPicks = picks; try { if (submission?.receiptOnly) { submission.submit(captured); @@ -1131,6 +1136,13 @@ function Composer({ ) : [], ); + // Only agents still notified by this message are asked for a model or effort. + const sentPicks: MentionPicks = Object.fromEntries( + Object.entries(capturedPicks).filter(([key]) => + recipients.includes(key), + ), + ); + const tuned = Object.keys(sentPicks).length > 0; // Destination and content are fixed here; a background send never retargets. const publish = (uploaded: readonly UploadedAttachment[]) => { requireCompletedSessionStart(); @@ -1141,10 +1153,13 @@ function Composer({ content, recipients, uploaded, - ...(replyParentId || references.length ? [replyParentId] : []), - ...(references.length ? [references] : []), + ...(replyParentId || references.length || tuned + ? [replyParentId] + : []), + ...(references.length || tuned ? [references] : []), + ...(tuned ? [sentPicks] : []), ) - : references.length + : references.length || tuned ? session.messages.send( channelId, content, @@ -1152,6 +1167,7 @@ function Composer({ uploaded, undefined, references, + ...(tuned ? [sentPicks] : []), ) : session.messages.send(channelId, content, recipients, uploaded); }; @@ -1237,6 +1253,7 @@ function Composer({ const changed = JSON.stringify(next) !== JSON.stringify(valueRef.current); valueRef.current = next; updateDraft(next); + setPicks({}); // An unchanged prefill may not render. Do not leave a caret command for // the next keystroke to consume after inserting its first character. caret.current = changed ? next.text.length : undefined; @@ -1634,6 +1651,17 @@ function Composer({ )} {active && !editing.target && trailingTool} + {active && + !editing.target && + !submission && + !!value.recipients.length && ( + item.pubkey)} + value={picks} + disabled={editingDisabled} + onChange={setPicks} + /> + )} { + const h = setup(); + await h.members([viewer.pubkey, honey.pubkey]); + const root = "f".repeat(64); + const post = ( + picks: Record, + ) => + reply + ? h.session.messages.reply( + "c", + root, + "@Honey", + [honey.pubkey], + [], + undefined, + [], + picks, + ) + : h.session.messages.send( + "c", + "@Honey", + [honey.pubkey], + [], + undefined, + [], + picks, + ); + post({ + [honey.pubkey]: { model: "claude-fable-5-1[1m]", effort: "xhigh" }, + }); + await flush(); + const tags = h.publish.mock.calls[0]?.[0]?.tags; + expect(tags).toContainEqual([ + "model", + honey.pubkey, + "claude-fable-5-1[1m]", + ]); + expect(tags).toContainEqual(["effort", honey.pubkey, "xhigh"]); + expect(() => post({ [namesake.pubkey]: { effort: "high" } })).toThrow( + /mentioned agent/, + ); + expect(() => post({ [honey.pubkey]: { effort: "High Effort" } })).toThrow( + /mentioned agent/, + ); + expect(() => post({ [honey.pubkey]: { model: "two words" } })).toThrow( + /mentioned agent/, + ); + }, +); + it.each(mentionConformance.tags.write)( "conforms to the portable mention tag writing contract: $name", async (fixture) => { diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index c414c33e1..a405244b5 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -24,6 +24,13 @@ export const REPORT_TYPES = [ "other", ] as const; export type ReportType = (typeof REPORT_TYPES)[number]; +/** `["effort", agentPubkey, level]` and `["model", agentPubkey, model]`: what + * a mentioned agent's owner asks of its next run. Only the owner is honoured. */ +export const EFFORT_TAG = "effort"; +export const MODEL_TAG = "model"; +export type RunPick = Readonly<{ model?: string; effort?: string }>; +/** The model and effort picked for each mentioned agent, by pubkey. */ +export type MentionPicks = Readonly>; /** Domain convenience only. Delivery and read reconciliation remain session-owned. */ export function createMessages( @@ -71,6 +78,19 @@ export function createMessages( throw new Error("Choose at most 32 valid mention references"); return [...new Set(pubkeys)].map((key) => ["mention", key]); }; + const pickTags = (mentions: readonly string[], picks: MentionPicks) => + Object.entries(picks).flatMap(([key, { model, effort }]) => { + if ( + !mentions.includes(key) || + (model !== undefined && !/^[\w.:[\]-]{1,64}$/.test(model)) || + (effort !== undefined && !/^[a-z0-9-]{1,32}$/.test(effort)) + ) + throw new Error("A model or effort applies only to a mentioned agent"); + return [ + ...(model === undefined ? [] : [[MODEL_TAG, key, model]]), + ...(effort === undefined ? [] : [[EFFORT_TAG, key, effort]]), + ]; + }); return Object.freeze({ send( channelId: string, @@ -79,6 +99,7 @@ export function createMessages( attachments: readonly UploadedAttachment[] = [], recovery?: OutboxRecovery, references: readonly string[] = [], + picks: MentionPicks = {}, ) { if (!channelId) throw new Error("A channel is required"); const message = attachmentMessage(content, attachments, relayOrigin); @@ -90,6 +111,7 @@ export function createMessages( ["h", channelId], ...mentionTags(channelId, mentions), ...referenceTags(references), + ...pickTags(mentions, picks), ...emojiTags(content), ...message.tags, ], @@ -184,6 +206,7 @@ export function createMessages( attachments: readonly UploadedAttachment[] = [], parentId: string = rootId, references: readonly string[] = [], + picks: MentionPicks = {}, ) { if (!channelId) throw new Error("A channel is required"); if (!/^[0-9a-f]{64}$/.test(rootId)) @@ -200,6 +223,7 @@ export function createMessages( ["e", parentId, "", "reply"], ...mentionTags(channelId, mentions), ...referenceTags(references), + ...pickTags(mentions, picks), ...emojiTags(content), ...message.tags, ], diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 6e541540b..18f64f836 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -627,7 +627,10 @@ use the same 10px corners as icon actions, including hover and pressed fills. Small buttons use 16px side padding and 16px icons; medium and large use 24px side padding and 24px icons. Standard Button labels use the complete `text-label-sm` role with an 8px icon gap; the -extra-small capsule uses `text-caption`. +extra-small capsule uses `text-caption`. For a caption-sized inline choice such as +the composer's model name, `size="inline"` keeps a 24px minimum target with 4px +horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"` +for an unframed label; shared hover, disabled, and keyboard focus still apply. IconButton defaults to a 10px rounded rectangle (`--radius-icon-button`) and uses the same sm/md/lg sizes. Existing names diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts index 6fb716493..8b5c35174 100644 --- a/src/shared/design-system/icons/index.ts +++ b/src/shared/design-system/icons/index.ts @@ -442,3 +442,6 @@ import TablerCircleIcon from "@tabler/icons-react/dist/esm/icons/IconCircle.mjs" export const CircleIcon = defineIcon("tabler", TablerCircleIcon); import TablerCircleHalfIcon from "@tabler/icons-react/dist/esm/icons/IconCircleHalf.mjs"; export const CircleHalfIcon = defineIcon("tabler", TablerCircleHalfIcon); + +import TablerGaugeIcon from "@tabler/icons-react/dist/esm/icons/IconGauge.mjs"; +export const GaugeIcon = defineIcon("tabler", TablerGaugeIcon); diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 9615d1240..c8868effd 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -62,6 +62,20 @@ width: var(--space-4); height: var(--space-4); } + /* Inline caption choices, such as the composer's model label. */ + .buzz-button[data-size="inline"] { + @apply text-caption; + min-height: var(--space-6); + padding: 0 var(--space-1); + } + .buzz-button[data-size="inline"] .buzz-button-label { + gap: var(--space-1); + } + .buzz-button[data-size="inline"] .buzz-button-label > svg, + .buzz-button[data-size="inline"] .buzz-button-spinner { + width: var(--space-3); + height: var(--space-3); + } .buzz-button[data-size="sm"] { @apply text-label-sm; min-height: var(--size-control-sm); diff --git a/src/shared/design-system/ui/Button.tsx b/src/shared/design-system/ui/Button.tsx index 609e62030..d66d5fea0 100644 --- a/src/shared/design-system/ui/Button.tsx +++ b/src/shared/design-system/ui/Button.tsx @@ -13,7 +13,7 @@ type ButtonVariant = | "link" | "primary" | "quiet"; -type ButtonSize = "xs" | "sm" | "md" | "lg" | "compact" | "default"; +type ButtonSize = "inline" | "xs" | "sm" | "md" | "lg" | "compact" | "default"; export type ButtonProps = Omit< ComponentProps, diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts index 70007b467..39d757155 100644 --- a/src/shared/design-system/ui/registry.ts +++ b/src/shared/design-system/ui/registry.ts @@ -414,7 +414,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [ "outline", "inverted", "link", - "size: xs | sm | md | lg", + "size: inline | xs | sm | md | lg", "shape: capsule (default) | control (12px)", "loading", ], diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx index 98eb372dc..dc903a34f 100644 --- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx +++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx @@ -3,6 +3,7 @@ import { SectionHeading } from "./primitives"; import { useState } from "react"; import { ArrowRightIcon, + CaretDownIcon, PlusIcon, DotsThreeIcon, GearIcon, @@ -140,6 +141,16 @@ export function ButtonSpecimen() { + Model
Date: Sun, 11 Oct 2026 17:08:02 -0700 Subject: [PATCH 2/2] Bind the popup CSS contract test to completions Composer model controls also use a popup class. Select the completion-specific data-mention variant instead of whichever CSS module loads first. Preserves all token and geometry assertions; reproduced before and passed in Chromium and WebKit after. Co-authored-by: codex00 <6922125dbee102fcf02d2b32441fd56927c1298ac16119167f82f72529d13676@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen Signed-off-by: Matt Toohey --- tests/browser/design-system.spec.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/browser/design-system.spec.mjs b/tests/browser/design-system.spec.mjs index 971150bf9..3e4b6ddb7 100644 --- a/tests/browser/design-system.spec.mjs +++ b/tests/browser/design-system.spec.mjs @@ -85,13 +85,13 @@ test("shared tokens reach app controls without history or chip overrides", async positioner.append(surface); probes.append(positioner); } else { + // The completion surface owns the data-mention variant. Other features + // also name classes popup, so stylesheet order is not an identity. const selector = selectors.find((s) => - new RegExp(`\\._${name}_`).test(s), + /\._popup_[\w]+\[data-mention\]/.test(s), ); - if (!selector) throw new Error(`Missing popup style: ${name}`); - surface.className = selector.match( - new RegExp(`\\.(_${name}_[\\w]+)`), - )[1]; + if (!selector) throw new Error("Missing completion popup style"); + surface.className = selector.match(/\.(_popup_[\w]+)/)[1]; probes.append(surface); } }