session-title.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { expect } from "bun:test"
  2. import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
  3. import { OpenAIChat } from "@opencode-ai/llm/protocols"
  4. import { AgentV2 } from "@opencode-ai/core/agent"
  5. import { Database } from "@opencode-ai/core/database/database"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
  8. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  9. import { EventV2 } from "@opencode-ai/core/event"
  10. import { SessionEvent } from "@opencode-ai/core/session/event"
  11. import { SessionMessage } from "@opencode-ai/core/session/message"
  12. import { Prompt } from "@opencode-ai/schema/prompt"
  13. import { SessionProjector } from "@opencode-ai/core/session/projector"
  14. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  15. import { SessionTable } from "@opencode-ai/core/session/sql"
  16. import { SessionStore } from "@opencode-ai/core/session/store"
  17. import { SessionTitle } from "@opencode-ai/core/session/title"
  18. import { SessionV2 } from "@opencode-ai/core/session"
  19. import { Project } from "@opencode-ai/core/project"
  20. import { ProjectTable } from "@opencode-ai/core/project/sql"
  21. import { AbsolutePath } from "@opencode-ai/core/schema"
  22. import { DateTime, Effect, Layer, Stream } from "effect"
  23. import { testEffect } from "./lib/effect"
  24. let requests: LLMRequest[] = []
  25. const model = Model.make({
  26. id: "title-model",
  27. provider: "test",
  28. route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
  29. })
  30. const client = Layer.mock(LLMClient.Service)({
  31. prepare: () => Effect.die("unused"),
  32. stream: (request: LLMRequest) => {
  33. requests.push(request)
  34. return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
  35. },
  36. generate: () => Effect.die("unused"),
  37. })
  38. const models = Layer.mock(SessionRunnerModel.Service)({
  39. resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
  40. })
  41. const it = testEffect(
  42. AppNodeBuilder.build(
  43. LayerNode.group([
  44. Database.node,
  45. EventV2.node,
  46. SessionProjector.node,
  47. SessionStore.node,
  48. AgentV2.node,
  49. SessionTitle.node,
  50. ]),
  51. [
  52. [llmClient, client],
  53. [SessionRunnerModel.node, models],
  54. ],
  55. ),
  56. )
  57. const insertSession = (id: SessionV2.ID) =>
  58. Effect.gen(function* () {
  59. const { db } = yield* Database.Service
  60. yield* db
  61. .insert(ProjectTable)
  62. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  63. .onConflictDoNothing()
  64. .run()
  65. .pipe(Effect.orDie)
  66. yield* db
  67. .insert(SessionTable)
  68. .values({
  69. id,
  70. project_id: Project.ID.global,
  71. slug: id,
  72. directory: "/project",
  73. title: "New session - fake",
  74. version: "test",
  75. })
  76. .onConflictDoNothing()
  77. .run()
  78. .pipe(Effect.orDie)
  79. })
  80. const prompt = (sessionID: SessionV2.ID, text: string) =>
  81. Effect.gen(function* () {
  82. const events = yield* EventV2.Service
  83. const messageID = SessionMessage.ID.create()
  84. yield* events.publish(SessionEvent.PromptAdmitted, {
  85. sessionID,
  86. inputID: messageID,
  87. prompt: Prompt.make({ text }),
  88. delivery: "steer",
  89. })
  90. yield* events.publish(SessionEvent.PromptPromoted, {
  91. sessionID,
  92. inputID: messageID,
  93. })
  94. })
  95. it.effect("generates a title from the sole user message and renames the session", () =>
  96. Effect.gen(function* () {
  97. requests = []
  98. const agentService = yield* AgentV2.Service
  99. yield* agentService.transform((editor) => {
  100. editor.update(AgentV2.ID.make("title"), (agent) => {
  101. agent.mode = "primary"
  102. agent.hidden = true
  103. agent.system = "You are a title generator."
  104. })
  105. })
  106. const sessionID = SessionV2.ID.make("ses_title_generate")
  107. yield* insertSession(sessionID)
  108. yield* prompt(sessionID, "Help me debug the failing build")
  109. const store = yield* SessionStore.Service
  110. const session = yield* store
  111. .get(sessionID)
  112. .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
  113. const title = yield* SessionTitle.Service
  114. yield* title.generateForFirstPrompt(session)
  115. expect(requests).toHaveLength(1)
  116. expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
  117. const renamed = yield* store.get(sessionID)
  118. expect(renamed?.title).toBe("Generated Title")
  119. }),
  120. )
  121. it.effect("does not generate once a second user message exists", () =>
  122. Effect.gen(function* () {
  123. requests = []
  124. const agentService = yield* AgentV2.Service
  125. yield* agentService.transform((editor) => {
  126. editor.update(AgentV2.ID.make("title"), (agent) => {
  127. agent.mode = "primary"
  128. agent.hidden = true
  129. agent.system = "You are a title generator."
  130. })
  131. })
  132. const sessionID = SessionV2.ID.make("ses_title_second_message")
  133. yield* insertSession(sessionID)
  134. yield* prompt(sessionID, "First message")
  135. yield* prompt(sessionID, "Second message")
  136. const store = yield* SessionStore.Service
  137. const session = yield* store
  138. .get(sessionID)
  139. .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
  140. const title = yield* SessionTitle.Service
  141. yield* title.generateForFirstPrompt(session)
  142. expect(requests).toHaveLength(0)
  143. const untouched = yield* store.get(sessionID)
  144. expect(untouched?.title).toBe("New session - fake")
  145. }),
  146. )
  147. it.effect("does not generate for a child session", () =>
  148. Effect.gen(function* () {
  149. requests = []
  150. const agentService = yield* AgentV2.Service
  151. yield* agentService.transform((editor) => {
  152. editor.update(AgentV2.ID.make("title"), (agent) => {
  153. agent.mode = "primary"
  154. agent.hidden = true
  155. agent.system = "You are a title generator."
  156. })
  157. })
  158. const sessionID = SessionV2.ID.make("ses_title_child")
  159. const { db } = yield* Database.Service
  160. yield* db
  161. .insert(ProjectTable)
  162. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  163. .onConflictDoNothing()
  164. .run()
  165. .pipe(Effect.orDie)
  166. yield* db
  167. .insert(SessionTable)
  168. .values({
  169. id: sessionID,
  170. project_id: Project.ID.global,
  171. parent_id: SessionV2.ID.make("ses_title_parent"),
  172. slug: sessionID,
  173. directory: "/project",
  174. title: "Child session - fake",
  175. version: "test",
  176. })
  177. .onConflictDoNothing()
  178. .run()
  179. .pipe(Effect.orDie)
  180. yield* prompt(sessionID, "Do this subtask")
  181. const store = yield* SessionStore.Service
  182. const session = yield* store
  183. .get(sessionID)
  184. .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
  185. const title = yield* SessionTitle.Service
  186. yield* title.generateForFirstPrompt(session)
  187. expect(requests).toHaveLength(0)
  188. }),
  189. )
  190. it.effect("does not generate when the title agent is removed", () =>
  191. Effect.gen(function* () {
  192. requests = []
  193. const sessionID = SessionV2.ID.make("ses_title_no_agent")
  194. yield* insertSession(sessionID)
  195. yield* prompt(sessionID, "Help me debug the failing build")
  196. const store = yield* SessionStore.Service
  197. const session = yield* store
  198. .get(sessionID)
  199. .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
  200. const title = yield* SessionTitle.Service
  201. yield* title.generateForFirstPrompt(session)
  202. expect(requests).toHaveLength(0)
  203. const untouched = yield* store.get(sessionID)
  204. expect(untouched?.title).toBe("New session - fake")
  205. }),
  206. )