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) && (
+
+ Retry navigation
+
+ )}
+
select("settings")}>
+ Open Settings
+
+
+ ) : 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) && (
-
- Retry navigation
-
- )}
-
select("settings")}>
- Open Settings
-
-
- ) : 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 controls are the whole popup: it has no surface of its own. */}
+ trigger.current?.closest("form") ?? trigger.current}
+ collisionPadding={8}
+ >
+
+ {choices.map((choice, index) => (
+ 1}
+ value={value[choice.pubkey] ?? {}}
+ disabled={disabled}
+ pick={(next) => pick(choice.pubkey, next)}
+ {...(index === 0 ? { thumb } : {})}
+ />
+ ))}
+
+
+
+
+ );
+}
+
+/** 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}
+
+
+ }
+ />
+
+
+ {choice.models.map((item) => (
+
+ {item.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}
+ {level}
+
+
+
+
+ {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() {
Continue
+ Model `}
+ >
+
+ Model
+
+
+ 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);
}
}