session-runner-recorded.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { HttpRecorder } from "@opencode-ai/http-recorder"
  3. import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
  4. import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
  5. import { Database } from "@opencode-ai/core/database/database"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { EventTable } from "@opencode-ai/core/event/sql"
  8. import { PermissionV2 } from "@opencode-ai/core/permission"
  9. import { Project } from "@opencode-ai/core/project"
  10. import { ProjectTable } from "@opencode-ai/core/project/sql"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { SessionV2 } from "@opencode-ai/core/session"
  13. import { Prompt } from "@opencode-ai/core/session/prompt"
  14. import { SessionProjector } from "@opencode-ai/core/session/projector"
  15. import { SessionExecution } from "@opencode-ai/core/session/execution"
  16. import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
  17. import { SessionRunner } from "@opencode-ai/core/session/runner"
  18. import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
  19. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  20. import { ToolRegistry } from "@opencode-ai/core/tool-registry"
  21. import { SessionTable } from "@opencode-ai/core/session/sql"
  22. import { SessionStore } from "@opencode-ai/core/session/store"
  23. import { describe, expect } from "bun:test"
  24. import { eq } from "drizzle-orm"
  25. import { Effect, Layer } from "effect"
  26. import path from "node:path"
  27. import { testEffect } from "./lib/effect"
  28. const database = Database.layerFromPath(":memory:")
  29. const events = EventV2.layer.pipe(Layer.provide(database))
  30. const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
  31. const store = SessionStore.layer.pipe(Layer.provide(database))
  32. const cassette = HttpRecorder.cassetteLayer("session-runner/openai-chat-streams-text", {
  33. directory: path.resolve(import.meta.dir, "fixtures/recordings"),
  34. mode: process.env.RECORD === "true" ? "record" : "replay",
  35. }).pipe(Layer.provide(NodeFileSystem.layer))
  36. const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
  37. const client = LLMClient.layer.pipe(Layer.provide(executor))
  38. const permission = Layer.succeed(
  39. PermissionV2.Service,
  40. PermissionV2.Service.of({
  41. assert: () => Effect.die("unused"),
  42. ask: () => Effect.die("unused"),
  43. reply: () => Effect.die("unused"),
  44. get: () => Effect.die("unused"),
  45. forSession: () => Effect.die("unused"),
  46. list: () => Effect.die("unused"),
  47. }),
  48. )
  49. const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
  50. const model = OpenAIChat.route
  51. .with({
  52. endpoint: { baseURL: "https://api.openai.com/v1" },
  53. auth: Auth.bearer(process.env.OPENAI_API_KEY ?? "fixture"),
  54. generation: { maxTokens: 20, temperature: 0 },
  55. })
  56. .model({ id: "gpt-4o-mini" })
  57. const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
  58. const runner = SessionRunnerLLM.defaultLayer.pipe(
  59. Layer.provide(database),
  60. Layer.provide(store),
  61. Layer.provide(events),
  62. Layer.provide(client),
  63. Layer.provide(registry),
  64. Layer.provide(models),
  65. )
  66. const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
  67. const execution = Layer.effect(
  68. SessionExecution.Service,
  69. SessionRunCoordinator.Service.pipe(
  70. Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
  71. ),
  72. ).pipe(Layer.provide(coordinator))
  73. const sessions = SessionV2.layer.pipe(
  74. Layer.provide(events),
  75. Layer.provide(database),
  76. Layer.provide(store),
  77. Layer.provide(Project.defaultLayer),
  78. Layer.provide(execution),
  79. )
  80. const it = testEffect(
  81. Layer.mergeAll(
  82. database,
  83. events,
  84. projector,
  85. store,
  86. executor,
  87. client,
  88. permission,
  89. registry,
  90. models,
  91. runner,
  92. coordinator,
  93. execution,
  94. sessions,
  95. ),
  96. )
  97. const sessionID = SessionV2.ID.make("ses_runner_recorded")
  98. describe("SessionRunnerLLM recorded", () => {
  99. it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
  100. Effect.gen(function* () {
  101. const { db } = yield* Database.Service
  102. yield* db
  103. .insert(ProjectTable)
  104. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  105. .onConflictDoNothing()
  106. .run()
  107. .pipe(Effect.orDie)
  108. yield* db
  109. .insert(SessionTable)
  110. .values({
  111. id: sessionID,
  112. project_id: Project.ID.global,
  113. slug: "test",
  114. directory: "/project",
  115. title: "test",
  116. version: "test",
  117. })
  118. .onConflictDoNothing()
  119. .run()
  120. .pipe(Effect.orDie)
  121. const session = yield* SessionV2.Service
  122. const prompt = yield* session.prompt({
  123. sessionID,
  124. prompt: new Prompt({ text: "Say hello in one short sentence." }),
  125. resume: false,
  126. })
  127. yield* session.resume(sessionID)
  128. const messages = yield* session.context(sessionID)
  129. expect(messages).toHaveLength(2)
  130. expect(messages[0]).toEqual(prompt)
  131. expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
  132. expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
  133. { type: "text", text: "Hello!" },
  134. ])
  135. expect(
  136. (yield* db
  137. .select({ type: EventTable.type })
  138. .from(EventTable)
  139. .where(eq(EventTable.aggregate_id, sessionID))
  140. .orderBy(EventTable.seq)
  141. .all()).map((event) => event.type),
  142. ).toEqual([
  143. "session.next.prompted.1",
  144. "session.next.step.started.1",
  145. "session.next.text.started.1",
  146. "session.next.text.ended.1",
  147. "session.next.step.ended.2",
  148. ])
  149. }),
  150. )
  151. })