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