session-generate.test.ts 13 KB

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