diff --git a/apps/x/packages/core/src/channels/bridge.test.ts b/apps/x/packages/core/src/channels/bridge.test.ts index 97c71ddf..2c78f90a 100644 --- a/apps/x/packages/core/src/channels/bridge.test.ts +++ b/apps/x/packages/core/src/channels/bridge.test.ts @@ -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; listSessions: ReturnType; }; + listModels: ReturnType; 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(); diff --git a/apps/x/packages/core/src/channels/bridge.ts b/apps/x/packages/core/src/channels/bridge.ts index 41643676..e53be4d8 100644 --- a/apps/x/packages/core/src/channels/bridge.ts +++ b/apps/x/packages/core/src/channels/bridge.ts @@ -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; +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; }, ) {} @@ -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 { + 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 " to switch, "model default" to reset.`, + ].join("\n"); + } + + private async selectModel(state: SenderState, arg: string): Promise { + 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); diff --git a/apps/x/packages/core/src/channels/service.ts b/apps/x/packages/core/src/channels/service.ts index 164d8efa..097d415a 100644 --- a/apps/x/packages/core/src/channels/service.ts +++ b/apps/x/packages/core/src/channels/service.ts @@ -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 { + 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("sessions"), sessionBus: container.resolve("sessionBus"), + listModels: listBridgeModels, }); } return bridge;