From 3c71e4b4c00d45f946a7f97626af76a46f0d0a0e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 15:55:06 -0500 Subject: [PATCH] fix(core): use catalog small model for session titles Prefer Catalog.model.small over the session model when the title agent has no explicit model, falling back to the session model if small selection is unavailable. --- packages/core/src/session/title.ts | 29 +++-- packages/core/test/session-title.test.ts | 153 +++++++++++++++++++---- 2 files changed, 154 insertions(+), 28 deletions(-) diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 8e7b5857b7..41ec8afea0 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -3,10 +3,12 @@ export * as SessionTitle from "./title" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" +import { ModelV2 } from "../model" import { SessionEvent } from "./event" import { SessionHistory } from "./history" import { SessionRunnerModel } from "./runner/model" @@ -21,6 +23,7 @@ type Dependencies = { } readonly agents: AgentV2.Interface readonly models: SessionRunnerModel.Interface + readonly catalog: Catalog.Interface } export interface Interface { @@ -33,6 +36,21 @@ export class Service extends Context.Service()("@opencode/v2 const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`) const make = (dependencies: Dependencies) => { + const resolveModel = (session: SessionSchema.Info, agent: AgentV2.Info) => { + if (agent.model) return dependencies.models.resolve({ ...session, model: agent.model }) + return Effect.gen(function* () { + const providerID = session.model?.providerID ?? (yield* dependencies.catalog.model.default())?.providerID + const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined + if (!small) return yield* dependencies.models.resolve(session) + return yield* dependencies.models + .resolve({ + ...session, + model: ModelV2.Ref.make({ id: small.id, providerID: small.providerID }), + }) + .pipe(Effect.catch(() => dependencies.models.resolve(session))) + }) + } + const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* ( db: Database.Interface["db"], session: SessionSchema.Info, @@ -42,11 +60,7 @@ const make = (dependencies: Dependencies) => { if (!firstUser) return const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) if (!agent) return - const resolved = yield* ( - agent.model - ? dependencies.models.resolve({ ...session, model: agent.model }) - : dependencies.models.resolve(session) - ).pipe(Effect.catch(() => Effect.succeed(undefined))) + const resolved = yield* resolveModel(session, agent).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] let failed = false @@ -90,8 +104,9 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const models = yield* SessionRunnerModel.Service + const catalog = yield* Catalog.Service const database = yield* Database.Service - const title = make({ events, llm, agents, models }) + const title = make({ events, llm, agents, models, catalog }) return Service.of({ generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), }) @@ -101,5 +116,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node], + deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Catalog.node, Database.node], }) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index aeb0963660..76f52e60ea 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -2,11 +2,14 @@ import { expect } from "bun:test" import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" import { OpenAIChat } from "@opencode-ai/llm/protocols" import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { llmClient } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -18,10 +21,12 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" -import { DateTime, Effect, Layer, Stream } from "effect" +import { Money } from "@opencode-ai/schema/money" +import { Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" let requests: LLMRequest[] = [] +let resolvedModels: Array<{ id: string; provider: string } | undefined> = [] const model = Model.make({ id: "title-model", provider: "test", @@ -36,7 +41,17 @@ const client = Layer.mock(LLMClient.Service)({ generate: () => Effect.die("unused"), }) const models = Layer.mock(SessionRunnerModel.Service)({ - resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), + resolve: (session) => { + resolvedModels.push(session.model ? { id: session.model.id, provider: session.model.providerID } : undefined) + const selected = session.model + ? Model.make({ + id: session.model.id, + provider: session.model.providerID, + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), + }) + : model + return Effect.succeed(SessionRunnerModel.resolved(selected)) + }, }) const it = testEffect( AppNodeBuilder.build( @@ -46,6 +61,7 @@ const it = testEffect( SessionProjector.node, SessionStore.node, AgentV2.node, + Catalog.node, SessionTitle.node, ]), [ @@ -55,7 +71,10 @@ const it = testEffect( ), ) -const insertSession = (id: SessionV2.ID) => +const insertSession = ( + id: SessionV2.ID, + model?: { id: string; providerID: string }, +) => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -73,6 +92,14 @@ const insertSession = (id: SessionV2.ID) => directory: "/project", title: "New session - fake", version: "test", + ...(model + ? { + model: { + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(model.providerID), + }, + } + : {}), }) .onConflictDoNothing() .run() @@ -94,17 +121,68 @@ const prompt = (sessionID: SessionV2.ID, text: string) => }) }) -it.effect("generates a title from the sole user message and renames the session", () => +const enableTitleAgent = (model?: { id: string; providerID: string }) => Effect.gen(function* () { - requests = [] const agentService = yield* AgentV2.Service yield* agentService.transform((editor) => { editor.update(AgentV2.ID.make("title"), (agent) => { agent.mode = "primary" agent.hidden = true agent.system = "You are a title generator." + if (model) { + agent.model = { + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(model.providerID), + } + } }) }) + }) + +const seedSmallModel = () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("main"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(50), + output: Money.USDPerMillionTokens.make(50), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(1), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] + model.time.released = Date.now() + }) + }) + }) + +it.effect("generates a title from the sole user message and renames the session", () => + Effect.gen(function* () { + requests = [] + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_generate") yield* insertSession(sessionID) yield* prompt(sessionID, "Help me debug the failing build") @@ -123,17 +201,55 @@ it.effect("generates a title from the sole user message and renames the session" }), ) +it.effect("prefers the catalog small model over the session model", () => + Effect.gen(function* () { + requests = [] + resolvedModels = [] + yield* enableTitleAgent() + yield* seedSmallModel() + const sessionID = SessionV2.ID.make("ses_title_small_model") + yield* insertSession(sessionID, { id: "main", providerID: "test" }) + yield* prompt(sessionID, "Help me debug the failing build") + + const store = yield* SessionStore.Service + const session = yield* store + .get(sessionID) + .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing")))) + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(session) + + expect(resolvedModels).toEqual([{ id: "mini", provider: "test" }]) + expect(String(requests[0]?.model.id)).toBe("mini") + }), +) + +it.effect("prefers the title agent model over the catalog small model", () => + Effect.gen(function* () { + requests = [] + resolvedModels = [] + yield* enableTitleAgent({ id: "agent-title", providerID: "test" }) + yield* seedSmallModel() + const sessionID = SessionV2.ID.make("ses_title_agent_model") + yield* insertSession(sessionID, { id: "main", providerID: "test" }) + yield* prompt(sessionID, "Help me debug the failing build") + + const store = yield* SessionStore.Service + const session = yield* store + .get(sessionID) + .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing")))) + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(session) + + expect(resolvedModels).toEqual([{ id: "agent-title", provider: "test" }]) + expect(String(requests[0]?.model.id)).toBe("agent-title") + }), +) + it.effect("does not generate once a second user message exists", () => Effect.gen(function* () { requests = [] - const agentService = yield* AgentV2.Service - yield* agentService.transform((editor) => { - editor.update(AgentV2.ID.make("title"), (agent) => { - agent.mode = "primary" - agent.hidden = true - agent.system = "You are a title generator." - }) - }) + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_second_message") yield* insertSession(sessionID) yield* prompt(sessionID, "First message") @@ -155,14 +271,8 @@ it.effect("does not generate once a second user message exists", () => it.effect("does not generate for a child session", () => Effect.gen(function* () { requests = [] - const agentService = yield* AgentV2.Service - yield* agentService.transform((editor) => { - editor.update(AgentV2.ID.make("title"), (agent) => { - agent.mode = "primary" - agent.hidden = true - agent.system = "You are a title generator." - }) - }) + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_child") const { db } = yield* Database.Service yield* db @@ -201,6 +311,7 @@ it.effect("does not generate for a child session", () => it.effect("does not generate when the title agent is removed", () => Effect.gen(function* () { requests = [] + resolvedModels = [] const sessionID = SessionV2.ID.make("ses_title_no_agent") yield* insertSession(sessionID) yield* prompt(sessionID, "Help me debug the failing build")