|
|
@@ -0,0 +1,213 @@
|
|
|
+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")
|
|
|
+ }),
|
|
|
+)
|