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")