opencode/packages/core/test/session-title.test.ts
Dax Raad 5710064ae1 feat(core): generate session titles from first prompt
Add SessionTitle service that generates a session title from the
session's sole user message via the customizable "title" agent, then
renames the session. Runs once per session, gated on durable history
having exactly one user message (no default-title string comparison).

Wire it into SessionRunner as a background fork after the first turn's
prompt promotion makes the user message visible; forking before
promotion caused the first-message lookup to see zero rows and silently
no-op.

Closes #34364
2026-07-01 11:07:44 -04:00

213 lines
7.4 KiB
TypeScript

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 { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTitle } from "@opencode-ai/core/session/title"
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 { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
const model = Model.make({
id: "title-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
},
generate: () => Effect.die("unused"),
})
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
agents,
SessionTitle.layer.pipe(
Layer.provide(client),
Layer.provide(models),
Layer.provide(agents),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
),
),
)
const insertSession = (id: SessionV2.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id,
project_id: Project.ID.global,
slug: id,
directory: "/project",
title: "New session - fake",
version: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
const prompt = (sessionID: SessionV2.ID, text: string) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }),
delivery: "steer",
})
})
it.effect("generates a title from the sole user message and renames the 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."
})
})
const sessionID = SessionV2.ID.make("ses_title_generate")
yield* insertSession(sessionID)
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(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)
expect(renamed?.title).toBe("Generated 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."
})
})
const sessionID = SessionV2.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
yield* prompt(sessionID, "Second message")
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(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
}),
)
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."
})
})
const sessionID = SessionV2.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
parent_id: SessionV2.ID.make("ses_title_parent"),
slug: sessionID,
directory: "/project",
title: "Child session - fake",
version: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* prompt(sessionID, "Do this subtask")
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(requests).toHaveLength(0)
}),
)
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
const sessionID = SessionV2.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
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(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
}),
)