session-generate.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { expect } from "bun:test"
  2. import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, ToolDefinition, 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/util/effect/layer-node"
  9. import { EventV2 } from "@opencode-ai/core/event"
  10. import { EventTable } from "@opencode-ai/core/event/sql"
  11. import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
  12. import { Instructions } from "@opencode-ai/core/instructions"
  13. import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
  14. import { Location } from "@opencode-ai/core/location"
  15. import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
  16. import { ModelV2 } from "@opencode-ai/core/model"
  17. import { Project } from "@opencode-ai/core/project"
  18. import { ProjectTable } from "@opencode-ai/core/project/sql"
  19. import { ProviderV2 } from "@opencode-ai/core/provider"
  20. import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
  21. import { AbsolutePath } from "@opencode-ai/core/schema"
  22. import { SessionEvent } from "@opencode-ai/core/session/event"
  23. import { SessionGenerate } from "@opencode-ai/core/session/generate"
  24. import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
  25. import { InstructionState } from "@opencode-ai/core/session/instruction-state"
  26. import { SessionMessage } from "@opencode-ai/core/session/message"
  27. import { SessionProjector } from "@opencode-ai/core/session/projector"
  28. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  29. import { SessionSchema } from "@opencode-ai/core/session/schema"
  30. import {
  31. InstructionBlobTable,
  32. InstructionStateTable,
  33. SessionMessageTable,
  34. SessionPendingTable,
  35. SessionTable,
  36. } from "@opencode-ai/core/session/sql"
  37. import { SessionStore } from "@opencode-ai/core/session/store"
  38. import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
  39. import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
  40. import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
  41. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  42. import { asc, eq } from "drizzle-orm"
  43. import { Effect, Layer, Schema, Stream } from "effect"
  44. import { testEffect } from "./lib/effect"
  45. const requests: LLMRequest[] = []
  46. let instruction: string | Instructions.Unavailable = "Initial context"
  47. const sessionID = SessionSchema.ID.make("ses_generate_test")
  48. const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
  49. const client = Layer.mock(LLMClient.Service)({
  50. prepare: () => Effect.die(new Error("unused")),
  51. stream: () => Stream.die(new Error("unused")),
  52. generate: (request) =>
  53. Effect.sync(() => {
  54. requests.push(request)
  55. const response = LLMResponse.fromEvents([
  56. LLMEvent.stepStart({ index: 0 }),
  57. LLMEvent.textStart({ id: "generate" }),
  58. LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
  59. LLMEvent.textEnd({ id: "generate" }),
  60. LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
  61. LLMEvent.finish({ reason: "stop" }),
  62. ])
  63. if (!response) throw new Error("Incomplete generate response")
  64. return response
  65. }),
  66. })
  67. const models = Layer.mock(SessionRunnerModel.Service)({
  68. resolve: () =>
  69. Effect.succeed(
  70. SessionRunnerModel.resolved(model, {
  71. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  72. cost: [],
  73. }),
  74. ),
  75. })
  76. const builtins = Layer.mock(InstructionBuiltIns.Service, {
  77. load: () =>
  78. Effect.succeed(
  79. Instructions.make({
  80. key: Instructions.Key.make("test/context"),
  81. codec: Schema.toCodecJson(Schema.String),
  82. read: Effect.sync(() => instruction),
  83. render: {
  84. initial: String,
  85. changed: (_previous, current) => current,
  86. },
  87. }),
  88. ),
  89. })
  90. const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
  91. const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
  92. const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
  93. const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
  94. const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
  95. const tools = Layer.mock(ToolRegistry.Service, {
  96. materialize: () =>
  97. Effect.succeed({
  98. definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
  99. settle: () => Effect.die(new Error("unused")),
  100. }),
  101. register: () => Effect.die(new Error("unused")),
  102. registerBatch: () => Effect.die(new Error("unused")),
  103. })
  104. const it = testEffect(
  105. AppNodeBuilder.build(
  106. LayerNode.group([
  107. Database.node,
  108. EventV2.node,
  109. SessionProjector.node,
  110. SessionStore.node,
  111. AgentV2.node,
  112. InstructionBuiltIns.node,
  113. PluginHooks.node,
  114. SessionGenerateNode.node,
  115. ]),
  116. [
  117. [llmClient, client],
  118. [SessionRunnerModel.node, models],
  119. [InstructionBuiltIns.node, builtins],
  120. [InstructionDiscovery.node, discovery],
  121. [SkillInstructions.node, skills],
  122. [ReferenceInstructions.node, references],
  123. [McpInstructions.node, mcp],
  124. [PluginSupervisor.node, plugins],
  125. [ToolRegistry.node, tools],
  126. [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
  127. ],
  128. ),
  129. )
  130. const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
  131. Effect.all({
  132. sequence: EventV2.latestSequence(db, sessionID),
  133. events: db
  134. .select()
  135. .from(EventTable)
  136. .where(eq(EventTable.aggregate_id, sessionID))
  137. .orderBy(asc(EventTable.seq))
  138. .all()
  139. .pipe(Effect.orDie),
  140. messages: db
  141. .select()
  142. .from(SessionMessageTable)
  143. .where(eq(SessionMessageTable.session_id, sessionID))
  144. .orderBy(asc(SessionMessageTable.seq))
  145. .all()
  146. .pipe(Effect.orDie),
  147. pending: db
  148. .select()
  149. .from(SessionPendingTable)
  150. .where(eq(SessionPendingTable.session_id, sessionID))
  151. .orderBy(asc(SessionPendingTable.admitted_seq))
  152. .all()
  153. .pipe(Effect.orDie),
  154. instructions: db
  155. .select()
  156. .from(InstructionStateTable)
  157. .where(eq(InstructionStateTable.session_id, sessionID))
  158. .get()
  159. .pipe(Effect.orDie),
  160. blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie),
  161. session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
  162. })
  163. const userTexts = (request: LLMRequest) =>
  164. request.messages.flatMap((message) =>
  165. message.role === "user"
  166. ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
  167. : [],
  168. )
  169. const setup = Effect.gen(function* () {
  170. const { db } = yield* Database.Service
  171. const events = yield* EventV2.Service
  172. const agents = yield* AgentV2.Service
  173. const instructionBuiltIns = yield* InstructionBuiltIns.Service
  174. yield* agents.transform((draft) =>
  175. draft.update(AgentV2.ID.make("build"), (agent) => {
  176. agent.mode = "primary"
  177. }),
  178. )
  179. yield* db
  180. .insert(ProjectTable)
  181. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  182. .run()
  183. .pipe(Effect.orDie)
  184. yield* db
  185. .insert(SessionTable)
  186. .values({
  187. id: sessionID,
  188. project_id: Project.ID.global,
  189. slug: "generate-test",
  190. directory: "/project",
  191. title: "Generate test",
  192. version: "test",
  193. agent: AgentV2.ID.make("build"),
  194. })
  195. .run()
  196. .pipe(Effect.orDie)
  197. return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) }
  198. })
  199. it.effect("generates from fresh settled Session context without durable mutation", () =>
  200. Effect.gen(function* () {
  201. requests.length = 0
  202. instruction = "Initial context"
  203. const { db, events, instructions } = yield* setup
  204. yield* InstructionState.prepare(db, events, instructions, sessionID)
  205. const existing = SessionMessage.ID.create()
  206. yield* events.publish(SessionEvent.InputAdmitted, {
  207. sessionID,
  208. inputID: existing,
  209. input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
  210. })
  211. yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
  212. const settledAssistant = SessionMessage.ID.create()
  213. yield* events.publish(SessionEvent.Step.Started, {
  214. sessionID,
  215. assistantMessageID: settledAssistant,
  216. agent: AgentV2.ID.make("build"),
  217. model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
  218. })
  219. yield* events.publish(SessionEvent.Text.Started, {
  220. sessionID,
  221. assistantMessageID: settledAssistant,
  222. ordinal: 0,
  223. })
  224. yield* events.publish(SessionEvent.Text.Ended, {
  225. sessionID,
  226. assistantMessageID: settledAssistant,
  227. ordinal: 0,
  228. text: "Settled partial answer",
  229. })
  230. const activeAssistant = SessionMessage.ID.create()
  231. yield* events.publish(SessionEvent.Step.Started, {
  232. sessionID,
  233. assistantMessageID: activeAssistant,
  234. agent: AgentV2.ID.make("build"),
  235. model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
  236. })
  237. yield* events.publish(SessionEvent.Tool.Input.Started, {
  238. sessionID,
  239. assistantMessageID: activeAssistant,
  240. callID: "active-call",
  241. name: "echo",
  242. })
  243. yield* events.publish(SessionEvent.Tool.Input.Ended, {
  244. sessionID,
  245. assistantMessageID: activeAssistant,
  246. callID: "active-call",
  247. text: "{}",
  248. })
  249. yield* events.publish(SessionEvent.Tool.Called, {
  250. sessionID,
  251. assistantMessageID: activeAssistant,
  252. callID: "active-call",
  253. input: {},
  254. executed: false,
  255. })
  256. yield* events.publish(SessionEvent.InputAdmitted, {
  257. sessionID,
  258. inputID: SessionMessage.ID.create(),
  259. input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
  260. })
  261. instruction = "Changed context"
  262. const before = yield* durableState(db, sessionID)
  263. const hooks = yield* PluginHooks.Service
  264. yield* hooks.register("session", "context", (event) =>
  265. Effect.sync(() => {
  266. event.system = [SystemPart.make("Hooked system"), ...event.system]
  267. if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
  268. }),
  269. )
  270. const generate = yield* SessionGenerate.Service
  271. const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
  272. expect(result).toBe("Transient answer")
  273. expect(requests).toHaveLength(1)
  274. expect(requests[0]?.model).toBe(model)
  275. expect(requests[0]?.system[0]?.text).toBe("Hooked system")
  276. expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
  277. expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
  278. expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
  279. expect(
  280. requests[0]?.messages.flatMap((message) =>
  281. message.role === "system"
  282. ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
  283. : [],
  284. ),
  285. ).toEqual(["Changed context"])
  286. expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
  287. expect(
  288. requests[0]?.messages.flatMap((message) =>
  289. message.role === "assistant"
  290. ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
  291. : [],
  292. ),
  293. ).toEqual(["Settled partial answer"])
  294. expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
  295. expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
  296. expect(yield* durableState(db, sessionID)).toEqual(before)
  297. }),
  298. )
  299. it.effect("blocks unavailable initial instructions before generation", () =>
  300. Effect.gen(function* () {
  301. requests.length = 0
  302. instruction = Instructions.unavailable
  303. const { db } = yield* setup
  304. const before = yield* durableState(db, sessionID)
  305. const generate = yield* SessionGenerate.Service
  306. const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
  307. expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
  308. expect(requests).toEqual([])
  309. expect(yield* durableState(db, sessionID)).toEqual(before)
  310. }),
  311. )