feat(channels): model selection from WhatsApp/Telegram

New bridge commands: "model" lists the available models (same catalog as
the desktop picker), "model N" / "model <name>" sets a per-sender
override passed as agent.overrides.model on every turn, and
"model default" resets to the app default. In-memory scope: resets on
app restart, never sticks a session to a bad model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-07-03 20:23:04 +05:30
parent c80a09daba
commit 930e377f34
3 changed files with 191 additions and 5 deletions

View file

@ -4,7 +4,7 @@ import type { TurnStreamEvent } from "@x/shared/dist/turns.js";
import { EmitterSessionBus } from "../sessions/bus.js";
import { TurnInputError } from "../turns/api.js";
import type { ISessions } from "../sessions/api.js";
import { ChannelBridge } from "./bridge.js";
import { ChannelBridge, type ModelChoice } from "./bridge.js";
const SENDER = "test:1";
@ -61,9 +61,16 @@ interface Harness {
getTurn: ReturnType<typeof vi.fn>;
listSessions: ReturnType<typeof vi.fn>;
};
listModels: ReturnType<typeof vi.fn>;
publish: (turnId: string, event: TurnStreamEvent) => void;
}
const MODELS: ModelChoice[] = [
{ provider: "anthropic", model: "claude-fable-5", label: "Fable 5 — Anthropic" },
{ provider: "anthropic", model: "claude-sonnet-5", label: "Sonnet 5 — Anthropic" },
{ provider: "openai", model: "gpt-5", label: "GPT-5 — OpenAI" },
];
function harness(entries: SessionIndexEntry[] = []): Harness {
const bus = new EmitterSessionBus();
const publish = (turnId: string, event: TurnStreamEvent) =>
@ -76,15 +83,17 @@ function harness(entries: SessionIndexEntry[] = []): Harness {
getTurn: vi.fn(async () => ({ turnId: "t1", events: [] })),
listSessions: vi.fn(() => entries),
};
const listModels = vi.fn(async () => MODELS);
const bridge = new ChannelBridge({
sessions: sessions as unknown as ISessions,
sessionBus: bus,
listModels,
});
const replies: string[] = [];
const reply = async (text: string) => {
replies.push(text);
};
return { bridge, bus, replies, reply, sessions, publish };
return { bridge, bus, replies, reply, sessions, listModels, publish };
}
// Settle the turn as soon as sendMessage is called: the watcher subscribes
@ -133,6 +142,67 @@ describe("ChannelBridge commands", () => {
});
});
describe("ChannelBridge model selection", () => {
it("lists models and applies a by-index selection to later turns", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "model", h.reply);
expect(h.replies[0]).toContain("1. Fable 5 — Anthropic");
expect(h.replies[0]).toContain("app default");
await h.bridge.handleInbound(SENDER, "model 3", h.reply);
expect(h.replies[1]).toContain("GPT-5");
settleOnSend(h, completedEvent("t1", "done"));
await h.bridge.handleInbound(SENDER, "hello", h.reply);
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
"s1",
expect.anything(),
expect.objectContaining({
agent: {
agentId: "copilot",
overrides: { model: { provider: "openai", model: "gpt-5" } },
},
}),
);
});
it("selects by unique name match and resets on 'model default'", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "model fable", h.reply);
expect(h.replies[0]).toContain("Fable 5");
settleOnSend(h, completedEvent("t1", "done"));
await h.bridge.handleInbound(SENDER, "hi", h.reply);
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
"s1",
expect.anything(),
expect.objectContaining({
agent: expect.objectContaining({
overrides: { model: { provider: "anthropic", model: "claude-fable-5" } },
}),
}),
);
await h.bridge.handleInbound(SENDER, "model default", h.reply);
settleOnSend(h, completedEvent("t2", "done"), "t2");
await h.bridge.handleInbound(SENDER, "hi again", h.reply);
expect(h.sessions.sendMessage).toHaveBeenLastCalledWith(
"s1",
expect.anything(),
expect.objectContaining({ agent: { agentId: "copilot" } }),
);
});
it("asks for disambiguation on an ambiguous name and rejects unknown ones", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "model claude", h.reply);
expect(h.replies[0]).toContain("matches 2 models");
await h.bridge.handleInbound(SENDER, "model llama", h.reply);
expect(h.replies[1]).toContain('No model matching "llama"');
});
});
describe("ChannelBridge message flow", () => {
it("creates a session, acks, and delivers the completed text", async () => {
const h = harness();

View file

@ -27,18 +27,31 @@ const HELP_TEXT = [
"• list — recent chats",
"• resume N — continue chat N from the list",
"• new [message] — start a fresh chat",
"• model [N or name] — pick the model (\"model default\" resets)",
"• status — current chat and what it's doing",
"• stop — cancel the running task",
"",
"Anything else is sent to the current chat.",
].join("\n");
const MODEL_LIST_LIMIT = 20;
export type ReplyFn = (text: string) => Promise<void>;
export interface ModelChoice {
provider: string;
model: string;
label: string;
}
interface SenderState {
activeSessionId: string | null;
// sessionIds as last shown by `list` (1-based indexing for `resume N`).
lastList: string[];
// Choices as last shown by `model` (1-based indexing for `model N`).
lastModels: ModelChoice[];
// Per-sender override passed on every turn; null = app default model.
model: { provider: string; model: string } | null;
pendingAsk: { turnId: string; toolCallId: string } | null;
busy: boolean;
}
@ -124,6 +137,7 @@ export class ChannelBridge {
private readonly deps: {
sessions: ISessions;
sessionBus: EmitterSessionBus;
listModels: () => Promise<ModelChoice[]>;
},
) {}
@ -147,6 +161,15 @@ export class ChannelBridge {
await reply(this.resumeSession(state, Number(resume[1])));
return;
}
if (lower === "model" || lower === "models") {
await reply(await this.renderModelList(state));
return;
}
const model = /^model\s+(.+)$/i.exec(trimmed);
if (model) {
await reply(await this.selectModel(state, model[1].trim()));
return;
}
if (lower === "status") {
await reply(this.renderStatus(state));
return;
@ -178,12 +201,81 @@ export class ChannelBridge {
private senderState(senderKey: string): SenderState {
let state = this.senders.get(senderKey);
if (!state) {
state = { activeSessionId: null, lastList: [], pendingAsk: null, busy: false };
state = {
activeSessionId: null,
lastList: [],
lastModels: [],
model: null,
pendingAsk: null,
busy: false,
};
this.senders.set(senderKey, state);
}
return state;
}
private isCurrentModel(state: SenderState, choice: ModelChoice): boolean {
return (
state.model?.provider === choice.provider && state.model?.model === choice.model
);
}
private async renderModelList(state: SenderState): Promise<string> {
const choices = await this.deps.listModels();
if (choices.length === 0) {
return "No models available — configure one in Rowboat → Settings → Models.";
}
state.lastModels = choices;
const shown = choices.slice(0, MODEL_LIST_LIMIT);
const lines = shown.map((c, i) => {
const current = this.isCurrentModel(state, c) ? " ← current" : "";
return `${i + 1}. ${c.label}${current}`;
});
if (choices.length > shown.length) {
lines.push(`… and ${choices.length - shown.length} more — pick by name.`);
}
return [
`Models${state.model ? "" : " (using app default)"}:`,
...lines,
"",
`Reply "model N" or "model <name>" to switch, "model default" to reset.`,
].join("\n");
}
private async selectModel(state: SenderState, arg: string): Promise<string> {
const lower = arg.toLowerCase();
if (lower === "default" || lower === "reset") {
state.model = null;
return "✅ Using the app default model.";
}
if (state.lastModels.length === 0) {
state.lastModels = await this.deps.listModels();
}
let choice: ModelChoice | undefined;
if (/^\d+$/.test(lower)) {
choice = state.lastModels[Number(lower) - 1];
if (!choice) {
return `No model #${lower} — send "model" to see the list.`;
}
} else {
const matches = state.lastModels.filter(
(c) =>
c.label.toLowerCase().includes(lower) ||
c.model.toLowerCase().includes(lower),
);
if (matches.length === 0) {
return `No model matching "${arg}" — send "model" to see the list.`;
}
if (matches.length > 1) {
const preview = matches.slice(0, 5).map((c) => `${c.label}`);
return [`"${arg}" matches ${matches.length} models:`, ...preview, "", "Be more specific."].join("\n");
}
choice = matches[0];
}
state.model = { provider: choice.provider, model: choice.model };
return `✅ Model set to ${choice.label} for your chats from here.`;
}
private sessionEntry(sessionId: string): SessionIndexEntry | undefined {
return this.deps.sessions.listSessions().find((e) => e.sessionId === sessionId);
}
@ -302,7 +394,13 @@ export class ChannelBridge {
const sent = await this.deps.sessions.sendMessage(
state.activeSessionId,
{ role: "user", content: text },
{ agent: { agentId: AGENT_ID }, autoPermission: true },
{
agent: {
agentId: AGENT_ID,
...(state.model ? { overrides: { model: state.model } } : {}),
},
autoPermission: true,
},
);
const settled = await watcher.waitFor(sent.turnId, TURN_TIMEOUT_MS);
await this.deliverSettled(state, sent.turnId, settled, reply);

View file

@ -6,7 +6,10 @@ import container from "../di/container.js";
import { WorkDir } from "../config/config.js";
import type { ISessions } from "../sessions/api.js";
import type { EmitterSessionBus } from "../sessions/bus.js";
import { ChannelBridge } from "./bridge.js";
import { isSignedIn } from "../account/account.js";
import { listGatewayModels } from "../models/gateway.js";
import { listOnboardingModels } from "../models/models-dev.js";
import { ChannelBridge, type ModelChoice } from "./bridge.js";
import type { IChannelsConfigRepo } from "./repo.js";
import { TelegramTransport } from "./transports/telegram.js";
// Type-only: the real module (which pulls the ~9MB baileys dependency) is
@ -77,11 +80,26 @@ export function subscribeChannelsStatus(listener: (status: Status) => void): ()
return () => statusListeners.delete(listener);
}
// Same catalog the desktop model picker uses (models:list IPC).
async function listBridgeModels(): Promise<ModelChoice[]> {
const catalog = (await isSignedIn())
? await listGatewayModels()
: await listOnboardingModels();
return catalog.providers.flatMap((provider) =>
provider.models.map((m) => ({
provider: provider.id,
model: m.id,
label: `${m.name ?? m.id}${provider.name}`,
})),
);
}
function ensureBridge(): ChannelBridge {
if (!bridge) {
bridge = new ChannelBridge({
sessions: container.resolve<ISessions>("sessions"),
sessionBus: container.resolve<EmitterSessionBus>("sessionBus"),
listModels: listBridgeModels,
});
}
return bridge;