session-title.test.ts 8.0 KB

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