session-generate.test.ts 13 KB

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