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
213 lines
7.4 KiB
TypeScript
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")
|
|
}),
|
|
)
|