session-title.test.ts 7.5 KB

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