session-runner.test.ts 77 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155
  1. import { describe, expect } from "bun:test"
  2. import {
  3. LLMClient,
  4. LLMError,
  5. LLMEvent,
  6. Model,
  7. Tool,
  8. TransportReason,
  9. type LLMClientShape,
  10. type LLMRequest,
  11. } from "@opencode-ai/llm"
  12. import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
  13. import { Database } from "@opencode-ai/core/database/database"
  14. import { EventV2 } from "@opencode-ai/core/event"
  15. import { PermissionV2 } from "@opencode-ai/core/permission"
  16. import { EventTable } from "@opencode-ai/core/event/sql"
  17. import { Project } from "@opencode-ai/core/project"
  18. import { ProjectTable } from "@opencode-ai/core/project/sql"
  19. import { QuestionV2 } from "@opencode-ai/core/question"
  20. import { AbsolutePath } from "@opencode-ai/core/schema"
  21. import { SessionV2 } from "@opencode-ai/core/session"
  22. import { SessionEvent } from "@opencode-ai/core/session/event"
  23. import { SessionInput } from "@opencode-ai/core/session/input"
  24. import { SessionMessage } from "@opencode-ai/core/session/message"
  25. import { Prompt } from "@opencode-ai/core/session/prompt"
  26. import { SessionProjector } from "@opencode-ai/core/session/projector"
  27. import { SessionExecution } from "@opencode-ai/core/session/execution"
  28. import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
  29. import { SessionRunner } from "@opencode-ai/core/session/runner"
  30. import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
  31. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  32. import { ToolRegistry } from "@opencode-ai/core/tool-registry"
  33. import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
  34. import { SessionStore } from "@opencode-ai/core/session/store"
  35. import { ModelV2 } from "@opencode-ai/core/model"
  36. import { ProviderV2 } from "@opencode-ai/core/provider"
  37. import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
  38. import { asc, eq } from "drizzle-orm"
  39. import { testEffect } from "./lib/effect"
  40. const database = Database.layerFromPath(":memory:")
  41. const events = EventV2.layer.pipe(Layer.provide(database))
  42. const questions = QuestionV2.layer.pipe(Layer.provide(events))
  43. const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
  44. const store = SessionStore.layer.pipe(Layer.provide(database))
  45. const requests: LLMRequest[] = []
  46. let response: LLMEvent[] = []
  47. let responses: LLMEvent[][] | undefined
  48. let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
  49. let streamGate: Deferred.Deferred<void> | undefined
  50. let streamStarted: Deferred.Deferred<void> | undefined
  51. let streamFailure: LLMError | undefined
  52. let toolExecutionGate: Deferred.Deferred<void> | undefined
  53. let toolExecutionsStarted: Deferred.Deferred<void> | undefined
  54. let activeToolExecutions = 0
  55. let maxActiveToolExecutions = 0
  56. const client = Layer.succeed(
  57. LLMClient.Service,
  58. LLMClient.Service.of({
  59. prepare: () => Effect.die("unused"),
  60. stream: ((request: LLMRequest) => {
  61. requests.push(request)
  62. if (responseStream) {
  63. const stream = responseStream
  64. responseStream = undefined
  65. return stream
  66. }
  67. const events = streamFailure
  68. ? Stream.fail(streamFailure)
  69. : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
  70. if (!streamGate) return events
  71. return Stream.unwrap(
  72. (streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
  73. Effect.andThen(Deferred.await(streamGate)),
  74. Effect.as(events),
  75. ),
  76. )
  77. }) as unknown as LLMClientShape["stream"],
  78. generate: () => Effect.die("unused"),
  79. }),
  80. )
  81. const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
  82. const authorizations: ToolRegistry.AuthorizeInput[] = []
  83. const executions: string[] = []
  84. const permission = Layer.succeed(
  85. PermissionV2.Service,
  86. PermissionV2.Service.of({
  87. assert: () => Effect.die("unused"),
  88. ask: () => Effect.die("unused"),
  89. reply: () => Effect.die("unused"),
  90. get: () => Effect.die("unused"),
  91. forSession: () => Effect.die("unused"),
  92. list: () => Effect.die("unused"),
  93. }),
  94. )
  95. const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
  96. const echo = Layer.effectDiscard(
  97. ToolRegistry.Service.use((registry) =>
  98. registry.contribute((editor) => {
  99. ;(editor.set("echo", {
  100. authorize: (input) =>
  101. Effect.sync(() => {
  102. authorizations.push(input)
  103. }),
  104. tool: Tool.make({
  105. description: "Echo text",
  106. parameters: Schema.Struct({ text: Schema.String }),
  107. success: Schema.Struct({ text: Schema.String }),
  108. toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
  109. execute: ({ text }) =>
  110. Effect.gen(function* () {
  111. executions.push(text)
  112. activeToolExecutions++
  113. maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
  114. if (activeToolExecutions === 5 && toolExecutionsStarted) {
  115. yield* Deferred.succeed(toolExecutionsStarted, undefined)
  116. }
  117. if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
  118. return { text }
  119. }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
  120. }),
  121. }),
  122. editor.set("defect", {
  123. tool: Tool.make({
  124. description: "Fail unexpectedly",
  125. parameters: Schema.Struct({}),
  126. success: Schema.Struct({}),
  127. execute: () => Effect.die("unexpected tool defect"),
  128. }),
  129. }))
  130. }),
  131. ),
  132. ).pipe(Layer.provide(registry))
  133. const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
  134. const runner = SessionRunnerLLM.layer.pipe(
  135. Layer.provide(database),
  136. Layer.provide(store),
  137. Layer.provide(events),
  138. Layer.provide(client),
  139. Layer.provide(registry),
  140. Layer.provide(models),
  141. )
  142. const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
  143. const execution = Layer.effect(
  144. SessionExecution.Service,
  145. SessionRunCoordinator.Service.pipe(
  146. Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
  147. ),
  148. ).pipe(Layer.provide(coordinator))
  149. const sessions = SessionV2.layer.pipe(
  150. Layer.provide(events),
  151. Layer.provide(database),
  152. Layer.provide(store),
  153. Layer.provide(Project.defaultLayer),
  154. Layer.provide(execution),
  155. )
  156. const it = testEffect(
  157. Layer.mergeAll(
  158. database,
  159. events,
  160. questions,
  161. projector,
  162. store,
  163. client,
  164. permission,
  165. registry,
  166. echo,
  167. models,
  168. runner,
  169. coordinator,
  170. execution,
  171. sessions,
  172. ),
  173. )
  174. const sessionID = SessionV2.ID.make("ses_runner_test")
  175. const otherSessionID = SessionV2.ID.make("ses_runner_other")
  176. const insertSession = (id: SessionV2.ID) =>
  177. Effect.gen(function* () {
  178. const { db } = yield* Database.Service
  179. yield* db
  180. .insert(SessionTable)
  181. .values({
  182. id,
  183. project_id: Project.ID.global,
  184. slug: id,
  185. directory: "/project",
  186. title: "test",
  187. version: "test",
  188. })
  189. .onConflictDoNothing()
  190. .run()
  191. .pipe(Effect.orDie)
  192. })
  193. const setup = Effect.gen(function* () {
  194. const { db } = yield* Database.Service
  195. response = []
  196. responses = undefined
  197. streamFailure = undefined
  198. responseStream = undefined
  199. streamGate = undefined
  200. streamStarted = undefined
  201. toolExecutionGate = undefined
  202. toolExecutionsStarted = undefined
  203. activeToolExecutions = 0
  204. maxActiveToolExecutions = 0
  205. yield* db
  206. .insert(ProjectTable)
  207. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  208. .onConflictDoNothing()
  209. .run()
  210. .pipe(Effect.orDie)
  211. yield* insertSession(sessionID)
  212. })
  213. const providerUnavailable = () =>
  214. new LLMError({
  215. module: "test",
  216. method: "stream",
  217. reason: new TransportReason({ message: "Provider unavailable" }),
  218. })
  219. const userTexts = (request: LLMRequest) =>
  220. request.messages.flatMap((message) =>
  221. message.role === "user"
  222. ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
  223. : [],
  224. )
  225. const replaySessionProjection = (id: SessionV2.ID) =>
  226. Effect.gen(function* () {
  227. const { db } = yield* Database.Service
  228. const events = yield* EventV2.Service
  229. const recorded = yield* db
  230. .select()
  231. .from(EventTable)
  232. .where(eq(EventTable.aggregate_id, id))
  233. .orderBy(asc(EventTable.seq))
  234. .all()
  235. .pipe(Effect.orDie)
  236. yield* events.remove(id)
  237. yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie)
  238. yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
  239. yield* events.replayAll(
  240. recorded.map((event) => ({
  241. id: event.id,
  242. aggregateID: event.aggregate_id,
  243. seq: event.seq,
  244. type: event.type,
  245. data: event.data,
  246. })),
  247. )
  248. })
  249. type FragmentKind = "text" | "reasoning" | "tool input"
  250. type FragmentFixture = {
  251. readonly delta: EventV2.Definition
  252. readonly completeEvents: LLMEvent[]
  253. readonly partialEvents: LLMEvent[]
  254. readonly expectedAssistant: unknown
  255. readonly expectedContent: unknown
  256. }
  257. const fragmentKinds: readonly FragmentKind[] = ["text", "reasoning", "tool input"]
  258. const fragmentID = (kind: FragmentKind, suffix: string) => `${kind === "tool input" ? "call" : kind}-${suffix}`
  259. const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string[]): FragmentFixture => {
  260. const text = chunks.join("")
  261. switch (kind) {
  262. case "text": {
  263. const partialEvents = [
  264. LLMEvent.stepStart({ index: 0 }),
  265. LLMEvent.textStart({ id }),
  266. ...chunks.map((text) => LLMEvent.textDelta({ id, text })),
  267. ]
  268. const expectedContent = { type: "text", id, text }
  269. return {
  270. delta: SessionEvent.Text.Delta,
  271. partialEvents,
  272. completeEvents: [
  273. ...partialEvents,
  274. LLMEvent.textEnd({ id }),
  275. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  276. LLMEvent.finish({ reason: "stop" }),
  277. ],
  278. expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
  279. expectedContent,
  280. }
  281. }
  282. case "reasoning": {
  283. const partialEvents = [
  284. LLMEvent.stepStart({ index: 0 }),
  285. LLMEvent.reasoningStart({ id }),
  286. ...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })),
  287. ]
  288. const expectedContent = { type: "reasoning", id, text }
  289. return {
  290. delta: SessionEvent.Reasoning.Delta,
  291. partialEvents,
  292. completeEvents: [
  293. ...partialEvents,
  294. LLMEvent.reasoningEnd({ id }),
  295. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  296. LLMEvent.finish({ reason: "stop" }),
  297. ],
  298. expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
  299. expectedContent,
  300. }
  301. }
  302. case "tool input": {
  303. const partialEvents = [
  304. LLMEvent.stepStart({ index: 0 }),
  305. LLMEvent.toolInputStart({ id, name: "echo" }),
  306. ...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })),
  307. ]
  308. const expectedContent = { type: "tool", id, state: { status: "pending", input: text } }
  309. return {
  310. delta: SessionEvent.Tool.Input.Delta,
  311. partialEvents,
  312. completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
  313. expectedAssistant: { type: "assistant", content: [expectedContent] },
  314. expectedContent,
  315. }
  316. }
  317. }
  318. }
  319. const verifyEphemeralDeltas = (kind: FragmentKind) =>
  320. Effect.gen(function* () {
  321. yield* setup
  322. const session = yield* SessionV2.Service
  323. const prompt = `Stream ${kind}`
  324. const chunks = Array.from({ length: 32 }, (_, index) => `${index},`)
  325. const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks)
  326. const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
  327. yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
  328. const events = yield* EventV2.Service
  329. const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
  330. yield* Effect.yieldNow
  331. response = fixture.completeEvents
  332. yield* session.resume(sessionID)
  333. const { db } = yield* Database.Service
  334. const deltas = yield* db
  335. .select({ type: EventTable.type })
  336. .from(EventTable)
  337. .where(eq(EventTable.type, EventV2.versionedType(fixture.delta.type, 1)))
  338. .all()
  339. .pipe(Effect.orDie)
  340. expect(Array.from(yield* Fiber.join(live))).toHaveLength(32)
  341. expect(deltas).toHaveLength(0)
  342. expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
  343. yield* replaySessionProjection(sessionID)
  344. expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
  345. })
  346. const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
  347. Effect.gen(function* () {
  348. yield* setup
  349. const session = yield* SessionV2.Service
  350. const prompt = `Fail after ${kind}`
  351. const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
  352. const failure = providerUnavailable()
  353. yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
  354. responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
  355. expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
  356. expect(yield* session.context(sessionID)).toMatchObject([
  357. { type: "user", text: prompt },
  358. {
  359. type: "assistant",
  360. finish: "error",
  361. error: { type: "unknown", message: "Provider unavailable" },
  362. content: [fixture.expectedContent],
  363. },
  364. ])
  365. })
  366. const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
  367. Effect.gen(function* () {
  368. yield* setup
  369. const session = yield* SessionV2.Service
  370. const prompt = `Interrupt after ${kind}`
  371. const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
  372. const streamed = yield* Deferred.make<void>()
  373. yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
  374. responseStream = Stream.concat(
  375. Stream.fromIterable(fixture.partialEvents),
  376. Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
  377. )
  378. const runner = yield* SessionRunner.Service
  379. const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
  380. yield* Deferred.await(streamed)
  381. yield* Fiber.interrupt(fiber)
  382. expect(yield* session.context(sessionID)).toMatchObject([
  383. { type: "user", text: prompt },
  384. {
  385. type: "assistant",
  386. content: [
  387. kind === "tool input"
  388. ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
  389. : fixture.expectedContent,
  390. ],
  391. },
  392. ])
  393. })
  394. describe("SessionRunnerLLM", () => {
  395. it.effect("starts a real runner turn after default prompt recording", () =>
  396. Effect.gen(function* () {
  397. yield* setup
  398. const session = yield* SessionV2.Service
  399. requests.length = 0
  400. responses = undefined
  401. streamGate = undefined
  402. streamStarted = undefined
  403. response = []
  404. const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) })
  405. expect(requests).toHaveLength(1)
  406. expect(yield* session.messages({ sessionID })).toMatchObject([
  407. { id: message.id, type: "user", text: "Run automatically" },
  408. ])
  409. }),
  410. )
  411. it.effect("streams one request with registry definitions from chronological V2 user history", () =>
  412. Effect.gen(function* () {
  413. yield* setup
  414. const session = yield* SessionV2.Service
  415. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
  416. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
  417. requests.length = 0
  418. responses = undefined
  419. streamGate = undefined
  420. streamStarted = undefined
  421. response = []
  422. yield* session.resume(sessionID)
  423. expect(requests).toHaveLength(1)
  424. expect(requests[0]?.model).toBe(model)
  425. expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
  426. expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
  427. { role: "user", content: [{ type: "text", text: "First" }] },
  428. { role: "user", content: [{ type: "text", text: "Second" }] },
  429. ])
  430. expect(yield* session.messages({ sessionID })).toHaveLength(2)
  431. }),
  432. )
  433. it.effect("projects reasoning and tool events without executing or continuing tools", () =>
  434. Effect.gen(function* () {
  435. yield* setup
  436. const session = yield* SessionV2.Service
  437. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use tools" }), resume: false })
  438. requests.length = 0
  439. responses = undefined
  440. streamGate = undefined
  441. streamStarted = undefined
  442. response = [
  443. LLMEvent.stepStart({ index: 0 }),
  444. LLMEvent.reasoningStart({ id: "reasoning-1" }),
  445. LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }),
  446. LLMEvent.reasoningEnd({ id: "reasoning-1" }),
  447. LLMEvent.toolInputStart({ id: "call-error", name: "write" }),
  448. LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }),
  449. LLMEvent.toolInputEnd({ id: "call-error", name: "write" }),
  450. LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
  451. LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }),
  452. LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }),
  453. LLMEvent.toolCall({
  454. id: "call-provider",
  455. name: "web_search",
  456. input: { query: "hello" },
  457. providerExecuted: true,
  458. providerMetadata: { fake: { source: "provider" } },
  459. }),
  460. LLMEvent.toolResult({
  461. id: "call-provider",
  462. name: "web_search",
  463. result: {
  464. type: "content",
  465. value: [
  466. { type: "text", text: "Hello" },
  467. { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
  468. ],
  469. },
  470. providerExecuted: true,
  471. providerMetadata: { fake: { source: "provider" } },
  472. }),
  473. LLMEvent.stepFinish({
  474. index: 0,
  475. reason: "tool-calls",
  476. usage: {
  477. inputTokens: 10,
  478. nonCachedInputTokens: 8,
  479. outputTokens: 4,
  480. reasoningTokens: 1,
  481. cacheReadInputTokens: 2,
  482. },
  483. }),
  484. LLMEvent.finish({ reason: "tool-calls" }),
  485. ]
  486. yield* session.resume(sessionID)
  487. expect(requests).toHaveLength(1)
  488. expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
  489. expect(yield* session.context(sessionID)).toMatchObject([
  490. { type: "user", text: "Use tools" },
  491. {
  492. type: "assistant",
  493. finish: "tool-calls",
  494. tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
  495. content: [
  496. { type: "reasoning", id: "reasoning-1", text: "Think" },
  497. {
  498. type: "tool",
  499. id: "call-error",
  500. name: "write",
  501. state: {
  502. status: "error",
  503. input: { path: "README.md" },
  504. error: { type: "unknown", message: "Denied" },
  505. },
  506. },
  507. {
  508. type: "tool",
  509. id: "call-provider",
  510. name: "web_search",
  511. provider: { executed: true, metadata: { fake: { source: "provider" } } },
  512. state: {
  513. status: "completed",
  514. input: { query: "hello" },
  515. structured: {},
  516. content: [
  517. { type: "text", text: "Hello" },
  518. { type: "file", mime: "image/png", source: { type: "data", data: "aGVsbG8=" }, name: "hello.png" },
  519. ],
  520. },
  521. },
  522. ],
  523. },
  524. ])
  525. }),
  526. )
  527. it.effect("continues with reloaded history after durably settling one local tool call", () =>
  528. Effect.gen(function* () {
  529. yield* setup
  530. const session = yield* SessionV2.Service
  531. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
  532. requests.length = 0
  533. authorizations.length = 0
  534. executions.length = 0
  535. streamGate = undefined
  536. streamStarted = undefined
  537. responses = [
  538. [
  539. LLMEvent.stepStart({ index: 0 }),
  540. LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
  541. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  542. LLMEvent.finish({ reason: "tool-calls" }),
  543. ],
  544. [
  545. LLMEvent.stepStart({ index: 0 }),
  546. LLMEvent.textStart({ id: "text-final" }),
  547. LLMEvent.textDelta({ id: "text-final", text: "Done" }),
  548. LLMEvent.textEnd({ id: "text-final" }),
  549. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  550. LLMEvent.finish({ reason: "stop" }),
  551. ],
  552. ]
  553. yield* session.resume(sessionID)
  554. expect(requests).toHaveLength(2)
  555. expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
  556. expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }])
  557. expect(executions).toEqual(["hello"])
  558. expect(yield* session.context(sessionID)).toMatchObject([
  559. { type: "user", text: "Echo this" },
  560. {
  561. type: "assistant",
  562. finish: "tool-calls",
  563. content: [
  564. {
  565. type: "tool",
  566. id: "call-echo",
  567. name: "echo",
  568. state: {
  569. status: "completed",
  570. input: { text: "hello" },
  571. structured: { text: "hello" },
  572. content: [{ type: "text", text: "hello" }],
  573. },
  574. },
  575. ],
  576. },
  577. { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
  578. ])
  579. }),
  580. )
  581. it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
  582. Effect.gen(function* () {
  583. yield* setup
  584. const session = yield* SessionV2.Service
  585. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Think first" }), resume: false })
  586. requests.length = 0
  587. response = [
  588. LLMEvent.stepStart({ index: 0 }),
  589. LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
  590. LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
  591. LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }),
  592. LLMEvent.reasoningStart({
  593. id: "reasoning-openai",
  594. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
  595. }),
  596. LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
  597. LLMEvent.reasoningEnd({
  598. id: "reasoning-openai",
  599. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  600. }),
  601. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  602. LLMEvent.finish({ reason: "stop" }),
  603. ]
  604. yield* session.resume(sessionID)
  605. yield* replaySessionProjection(sessionID)
  606. expect(yield* session.context(sessionID)).toMatchObject([
  607. { type: "user", text: "Think first" },
  608. {
  609. type: "assistant",
  610. content: [
  611. { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
  612. {
  613. type: "reasoning",
  614. text: "Encrypted thought",
  615. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  616. },
  617. ],
  618. },
  619. ])
  620. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
  621. response = []
  622. yield* session.resume(sessionID)
  623. expect(requests[1]?.messages[1]?.content).toEqual([
  624. { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
  625. {
  626. type: "reasoning",
  627. text: "Encrypted thought",
  628. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  629. },
  630. ])
  631. }),
  632. )
  633. it.effect("replays durable provider-executed tool results inline in a second-turn request", () =>
  634. Effect.gen(function* () {
  635. yield* setup
  636. const session = yield* SessionV2.Service
  637. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Search first" }), resume: false })
  638. requests.length = 0
  639. response = [
  640. LLMEvent.stepStart({ index: 0 }),
  641. LLMEvent.toolCall({
  642. id: "hosted-search",
  643. name: "web_search",
  644. input: { query: "Effect" },
  645. providerExecuted: true,
  646. providerMetadata: { openai: { itemId: "hosted-search" } },
  647. }),
  648. LLMEvent.toolResult({
  649. id: "hosted-search",
  650. name: "web_search",
  651. result: { type: "json", value: [{ title: "Effect" }] },
  652. providerExecuted: true,
  653. providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
  654. }),
  655. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  656. LLMEvent.finish({ reason: "stop" }),
  657. ]
  658. yield* session.resume(sessionID)
  659. yield* replaySessionProjection(sessionID)
  660. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
  661. response = []
  662. yield* session.resume(sessionID)
  663. expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"])
  664. expect(requests[1]?.messages[1]?.content).toMatchObject([
  665. {
  666. type: "tool-call",
  667. id: "hosted-search",
  668. name: "web_search",
  669. input: { query: "Effect" },
  670. providerExecuted: true,
  671. providerMetadata: { openai: { itemId: "hosted-search" } },
  672. },
  673. {
  674. type: "tool-result",
  675. id: "hosted-search",
  676. name: "web_search",
  677. result: { type: "json", value: [{ title: "Effect" }] },
  678. providerExecuted: true,
  679. providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
  680. },
  681. ])
  682. }),
  683. )
  684. it.effect("starts recorded local tools eagerly and awaits settlement before continuing", () =>
  685. Effect.gen(function* () {
  686. yield* setup
  687. const session = yield* SessionV2.Service
  688. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo five times" }), resume: false })
  689. requests.length = 0
  690. executions.length = 0
  691. toolExecutionGate = yield* Deferred.make<void>()
  692. toolExecutionsStarted = yield* Deferred.make<void>()
  693. const providerGate = yield* Deferred.make<void>()
  694. response = []
  695. responses = undefined
  696. const initial = Stream.fromIterable([
  697. LLMEvent.stepStart({ index: 0 }),
  698. ...Array.from({ length: 5 }, (_, index) =>
  699. LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
  700. ),
  701. ])
  702. const final = Stream.fromIterable([
  703. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  704. LLMEvent.finish({ reason: "tool-calls" }),
  705. ])
  706. streamGate = undefined
  707. responseStream = Stream.concat(
  708. initial,
  709. Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)),
  710. )
  711. const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
  712. yield* Deferred.await(toolExecutionsStarted)
  713. expect(executions).toHaveLength(5)
  714. expect(maxActiveToolExecutions).toBe(5)
  715. expect(yield* session.context(sessionID)).toMatchObject([
  716. { type: "user", text: "Echo five times" },
  717. {
  718. type: "assistant",
  719. content: Array.from({ length: 5 }, (_, index) => ({
  720. type: "tool",
  721. id: `call-echo-${index}`,
  722. state: { status: "running", input: { text: `${index}` } },
  723. })),
  724. },
  725. ])
  726. yield* Deferred.succeed(providerGate, undefined)
  727. yield* Effect.yieldNow
  728. expect(requests).toHaveLength(1)
  729. yield* Deferred.succeed(toolExecutionGate, undefined)
  730. yield* Fiber.join(run)
  731. toolExecutionGate = undefined
  732. toolExecutionsStarted = undefined
  733. expect(executions).toHaveLength(5)
  734. expect(maxActiveToolExecutions).toBe(5)
  735. expect(requests).toHaveLength(2)
  736. }),
  737. )
  738. it.effect("settles repeated provider-local tool call IDs against their owning assistant messages", () =>
  739. Effect.gen(function* () {
  740. yield* setup
  741. const session = yield* SessionV2.Service
  742. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo twice" }), resume: false })
  743. requests.length = 0
  744. executions.length = 0
  745. responses = [
  746. [
  747. LLMEvent.stepStart({ index: 0 }),
  748. LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "first" } }),
  749. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  750. LLMEvent.finish({ reason: "tool-calls" }),
  751. ],
  752. [
  753. LLMEvent.stepStart({ index: 0 }),
  754. LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "second" } }),
  755. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  756. LLMEvent.finish({ reason: "tool-calls" }),
  757. ],
  758. [],
  759. ]
  760. yield* session.resume(sessionID)
  761. expect(executions).toEqual(["first", "second"])
  762. expect(requests).toHaveLength(3)
  763. expect(yield* session.context(sessionID)).toMatchObject([
  764. { type: "user", text: "Echo twice" },
  765. {
  766. type: "assistant",
  767. content: [
  768. {
  769. type: "tool",
  770. id: "tool_0",
  771. state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
  772. },
  773. ],
  774. },
  775. {
  776. type: "assistant",
  777. content: [
  778. {
  779. type: "tool",
  780. id: "tool_0",
  781. state: {
  782. status: "completed",
  783. structured: { text: "second" },
  784. content: [{ type: "text", text: "second" }],
  785. },
  786. },
  787. ],
  788. },
  789. ])
  790. yield* replaySessionProjection(sessionID)
  791. expect(yield* session.context(sessionID)).toMatchObject([
  792. { type: "user", text: "Echo twice" },
  793. {
  794. type: "assistant",
  795. content: [
  796. {
  797. type: "tool",
  798. id: "tool_0",
  799. state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
  800. },
  801. ],
  802. },
  803. {
  804. type: "assistant",
  805. content: [
  806. {
  807. type: "tool",
  808. id: "tool_0",
  809. state: {
  810. status: "completed",
  811. structured: { text: "second" },
  812. content: [{ type: "text", text: "second" }],
  813. },
  814. },
  815. ],
  816. },
  817. ])
  818. }),
  819. )
  820. it.effect("joins concurrent resume calls into one active provider run", () =>
  821. Effect.gen(function* () {
  822. yield* setup
  823. const session = yield* SessionV2.Service
  824. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run once" }), resume: false })
  825. requests.length = 0
  826. responses = undefined
  827. response = [
  828. LLMEvent.stepStart({ index: 0 }),
  829. LLMEvent.textStart({ id: "text-once" }),
  830. LLMEvent.textDelta({ id: "text-once", text: "Once" }),
  831. LLMEvent.textEnd({ id: "text-once" }),
  832. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  833. LLMEvent.finish({ reason: "stop" }),
  834. ]
  835. streamGate = yield* Deferred.make<void>()
  836. streamStarted = yield* Deferred.make<void>()
  837. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  838. yield* Deferred.await(streamStarted)
  839. const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
  840. yield* Effect.yieldNow
  841. expect(requests).toHaveLength(1)
  842. yield* Deferred.succeed(streamGate, undefined)
  843. yield* Fiber.join(first)
  844. yield* Fiber.join(second)
  845. streamGate = undefined
  846. streamStarted = undefined
  847. expect(requests).toHaveLength(1)
  848. expect(yield* session.context(sessionID)).toMatchObject([
  849. { type: "user", text: "Run once" },
  850. { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] },
  851. ])
  852. }),
  853. )
  854. it.effect("steers an active provider turn with newly recorded prompts", () =>
  855. Effect.gen(function* () {
  856. yield* setup
  857. const session = yield* SessionV2.Service
  858. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
  859. requests.length = 0
  860. responses = [
  861. [
  862. LLMEvent.stepStart({ index: 0 }),
  863. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  864. LLMEvent.finish({ reason: "stop" }),
  865. ],
  866. [
  867. LLMEvent.stepStart({ index: 0 }),
  868. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  869. LLMEvent.finish({ reason: "stop" }),
  870. ],
  871. ]
  872. streamGate = yield* Deferred.make<void>()
  873. streamStarted = yield* Deferred.make<void>()
  874. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  875. yield* Deferred.await(streamStarted)
  876. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) })
  877. yield* Deferred.succeed(streamGate, undefined)
  878. yield* Fiber.join(first)
  879. streamGate = undefined
  880. streamStarted = undefined
  881. yield* Effect.yieldNow
  882. expect(requests).toHaveLength(2)
  883. expect(userTexts(requests[0]!)).toEqual(["Start working"])
  884. expect(userTexts(requests[1]!)).toEqual(["Start working", "Change direction"])
  885. expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
  886. "user",
  887. "assistant",
  888. "user",
  889. "assistant",
  890. ])
  891. }),
  892. )
  893. it.effect("starts queued input after the active activity settles", () =>
  894. Effect.gen(function* () {
  895. yield* setup
  896. const session = yield* SessionV2.Service
  897. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
  898. requests.length = 0
  899. responses = [
  900. [
  901. LLMEvent.stepStart({ index: 0 }),
  902. LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
  903. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  904. LLMEvent.finish({ reason: "tool-calls" }),
  905. ],
  906. [
  907. LLMEvent.stepStart({ index: 0 }),
  908. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  909. LLMEvent.finish({ reason: "stop" }),
  910. ],
  911. [
  912. LLMEvent.stepStart({ index: 0 }),
  913. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  914. LLMEvent.finish({ reason: "stop" }),
  915. ],
  916. ]
  917. streamGate = yield* Deferred.make<void>()
  918. streamStarted = yield* Deferred.make<void>()
  919. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  920. yield* Deferred.await(streamStarted)
  921. yield* session.prompt({
  922. sessionID,
  923. prompt: new Prompt({ text: "Wait until the next activity" }),
  924. delivery: "queue",
  925. })
  926. yield* Deferred.succeed(streamGate, undefined)
  927. yield* Fiber.join(first)
  928. streamGate = undefined
  929. streamStarted = undefined
  930. expect(requests).toHaveLength(3)
  931. expect(userTexts(requests[0]!)).toEqual(["Start working"])
  932. expect(userTexts(requests[1]!)).toEqual(["Start working"])
  933. expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until the next activity"])
  934. }),
  935. )
  936. it.effect("runs queued active inputs as separate FIFO activities", () =>
  937. Effect.gen(function* () {
  938. yield* setup
  939. const session = yield* SessionV2.Service
  940. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
  941. requests.length = 0
  942. responses = [
  943. [
  944. LLMEvent.stepStart({ index: 0 }),
  945. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  946. LLMEvent.finish({ reason: "stop" }),
  947. ],
  948. [
  949. LLMEvent.stepStart({ index: 0 }),
  950. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  951. LLMEvent.finish({ reason: "stop" }),
  952. ],
  953. [
  954. LLMEvent.stepStart({ index: 0 }),
  955. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  956. LLMEvent.finish({ reason: "stop" }),
  957. ],
  958. ]
  959. streamGate = yield* Deferred.make<void>()
  960. streamStarted = yield* Deferred.make<void>()
  961. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  962. yield* Deferred.await(streamStarted)
  963. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" })
  964. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" })
  965. yield* Deferred.succeed(streamGate, undefined)
  966. yield* Fiber.join(first)
  967. streamGate = undefined
  968. streamStarted = undefined
  969. expect(requests).toHaveLength(3)
  970. expect(userTexts(requests[0]!)).toEqual(["Start working"])
  971. expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
  972. expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"])
  973. }),
  974. )
  975. it.effect("opens queued input after idle steering activity settles", () =>
  976. Effect.gen(function* () {
  977. yield* setup
  978. const session = yield* SessionV2.Service
  979. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering activity" }), resume: false })
  980. yield* session.prompt({
  981. sessionID,
  982. prompt: new Prompt({ text: "Queue later activity" }),
  983. delivery: "queue",
  984. resume: false,
  985. })
  986. requests.length = 0
  987. responses = [
  988. [
  989. LLMEvent.stepStart({ index: 0 }),
  990. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  991. LLMEvent.finish({ reason: "stop" }),
  992. ],
  993. [
  994. LLMEvent.stepStart({ index: 0 }),
  995. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  996. LLMEvent.finish({ reason: "stop" }),
  997. ],
  998. ]
  999. yield* session.resume(sessionID)
  1000. expect(requests).toHaveLength(2)
  1001. expect(userTexts(requests[0]!)).toEqual(["Start steering activity"])
  1002. expect(userTexts(requests[1]!)).toEqual(["Start steering activity", "Queue later activity"])
  1003. }),
  1004. )
  1005. it.effect("coalesces steers into the active queued activity before starting the next queued activity", () =>
  1006. Effect.gen(function* () {
  1007. yield* setup
  1008. const session = yield* SessionV2.Service
  1009. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
  1010. requests.length = 0
  1011. responses = [
  1012. [
  1013. LLMEvent.stepStart({ index: 0 }),
  1014. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1015. LLMEvent.finish({ reason: "stop" }),
  1016. ],
  1017. [
  1018. LLMEvent.stepStart({ index: 0 }),
  1019. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1020. LLMEvent.finish({ reason: "stop" }),
  1021. ],
  1022. [
  1023. LLMEvent.stepStart({ index: 0 }),
  1024. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1025. LLMEvent.finish({ reason: "stop" }),
  1026. ],
  1027. [
  1028. LLMEvent.stepStart({ index: 0 }),
  1029. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1030. LLMEvent.finish({ reason: "stop" }),
  1031. ],
  1032. ]
  1033. const firstGate = yield* Deferred.make<void>()
  1034. const secondGate = yield* Deferred.make<void>()
  1035. streamGate = firstGate
  1036. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1037. while (requests.length < 1) yield* Effect.yieldNow
  1038. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" })
  1039. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" })
  1040. streamGate = secondGate
  1041. yield* Deferred.succeed(firstGate, undefined)
  1042. while (requests.length < 2) yield* Effect.yieldNow
  1043. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer first queued activity" }) })
  1044. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer first queued activity" }) })
  1045. yield* Deferred.succeed(secondGate, undefined)
  1046. yield* Fiber.join(first)
  1047. streamGate = undefined
  1048. expect(requests).toHaveLength(4)
  1049. expect(userTexts(requests[0]!)).toEqual(["Start working"])
  1050. expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
  1051. expect(userTexts(requests[2]!)).toEqual([
  1052. "Start working",
  1053. "Queue first",
  1054. "Steer first queued activity",
  1055. "Also steer first queued activity",
  1056. ])
  1057. expect(userTexts(requests[3]!)).toEqual([
  1058. "Start working",
  1059. "Queue first",
  1060. "Steer first queued activity",
  1061. "Also steer first queued activity",
  1062. "Queue second",
  1063. ])
  1064. }),
  1065. )
  1066. it.effect("coalesces multiple active steering prompts into one continuation turn", () =>
  1067. Effect.gen(function* () {
  1068. yield* setup
  1069. const session = yield* SessionV2.Service
  1070. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
  1071. requests.length = 0
  1072. responses = [
  1073. [
  1074. LLMEvent.stepStart({ index: 0 }),
  1075. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1076. LLMEvent.finish({ reason: "stop" }),
  1077. ],
  1078. [
  1079. LLMEvent.stepStart({ index: 0 }),
  1080. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1081. LLMEvent.finish({ reason: "stop" }),
  1082. ],
  1083. ]
  1084. streamGate = yield* Deferred.make<void>()
  1085. streamStarted = yield* Deferred.make<void>()
  1086. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1087. yield* Deferred.await(streamStarted)
  1088. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First steer" }) })
  1089. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second steer" }) })
  1090. yield* Deferred.succeed(streamGate, undefined)
  1091. yield* Fiber.join(first)
  1092. streamGate = undefined
  1093. streamStarted = undefined
  1094. yield* Effect.yieldNow
  1095. expect(requests).toHaveLength(2)
  1096. expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"])
  1097. yield* (yield* SessionRunCoordinator.Service).wake(sessionID)
  1098. yield* Effect.yieldNow
  1099. expect(requests).toHaveLength(2)
  1100. }),
  1101. )
  1102. it.effect("runs steering input accepted while the active provider turn fails", () =>
  1103. Effect.gen(function* () {
  1104. yield* setup
  1105. const session = yield* SessionV2.Service
  1106. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
  1107. requests.length = 0
  1108. responses = undefined
  1109. response = []
  1110. streamFailure = providerUnavailable()
  1111. streamGate = yield* Deferred.make<void>()
  1112. streamStarted = yield* Deferred.make<void>()
  1113. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1114. yield* Deferred.await(streamStarted)
  1115. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover with this" }) })
  1116. yield* Deferred.succeed(streamGate, undefined)
  1117. expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
  1118. streamFailure = undefined
  1119. streamGate = undefined
  1120. streamStarted = undefined
  1121. yield* Effect.yieldNow
  1122. expect(requests).toHaveLength(2)
  1123. expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
  1124. }),
  1125. )
  1126. it.effect("durably fails local tools left running by a prior process before continuing", () =>
  1127. Effect.gen(function* () {
  1128. yield* setup
  1129. const session = yield* SessionV2.Service
  1130. const events = yield* EventV2.Service
  1131. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false })
  1132. yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
  1133. const assistantMessageID = SessionMessage.ID.create()
  1134. yield* events.publish(SessionEvent.Step.Started, {
  1135. sessionID,
  1136. assistantMessageID,
  1137. timestamp: yield* DateTime.now,
  1138. agent: "build",
  1139. model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
  1140. })
  1141. yield* events.publish(SessionEvent.Tool.Input.Started, {
  1142. sessionID,
  1143. timestamp: yield* DateTime.now,
  1144. assistantMessageID,
  1145. callID: "call-interrupted",
  1146. name: "echo",
  1147. })
  1148. yield* events.publish(SessionEvent.Tool.Input.Ended, {
  1149. sessionID,
  1150. timestamp: yield* DateTime.now,
  1151. assistantMessageID,
  1152. callID: "call-interrupted",
  1153. text: '{"text":"stale"}',
  1154. })
  1155. yield* events.publish(SessionEvent.Tool.Called, {
  1156. sessionID,
  1157. timestamp: yield* DateTime.now,
  1158. assistantMessageID,
  1159. callID: "call-interrupted",
  1160. tool: "echo",
  1161. input: { text: "stale" },
  1162. provider: { executed: false },
  1163. })
  1164. requests.length = 0
  1165. response = []
  1166. yield* session.resume(sessionID)
  1167. expect(requests).toHaveLength(1)
  1168. expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
  1169. expect(yield* session.context(sessionID)).toMatchObject([
  1170. { type: "user", text: "Recover interrupted tool" },
  1171. {
  1172. type: "assistant",
  1173. content: [
  1174. {
  1175. type: "tool",
  1176. id: "call-interrupted",
  1177. state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
  1178. },
  1179. ],
  1180. },
  1181. ])
  1182. }),
  1183. )
  1184. it.effect("durably fails hosted tools left running by a prior process before continuing inline", () =>
  1185. Effect.gen(function* () {
  1186. yield* setup
  1187. const session = yield* SessionV2.Service
  1188. const events = yield* EventV2.Service
  1189. yield* session.prompt({
  1190. sessionID,
  1191. prompt: new Prompt({ text: "Recover interrupted hosted tool" }),
  1192. resume: false,
  1193. })
  1194. yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
  1195. const assistantMessageID = SessionMessage.ID.create()
  1196. yield* events.publish(SessionEvent.Step.Started, {
  1197. sessionID,
  1198. assistantMessageID,
  1199. timestamp: yield* DateTime.now,
  1200. agent: "build",
  1201. model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
  1202. })
  1203. yield* events.publish(SessionEvent.Tool.Input.Started, {
  1204. sessionID,
  1205. timestamp: yield* DateTime.now,
  1206. assistantMessageID,
  1207. callID: "call-hosted-interrupted",
  1208. name: "web_search",
  1209. })
  1210. yield* events.publish(SessionEvent.Tool.Input.Ended, {
  1211. sessionID,
  1212. timestamp: yield* DateTime.now,
  1213. assistantMessageID,
  1214. callID: "call-hosted-interrupted",
  1215. text: '{"query":"stale"}',
  1216. })
  1217. yield* events.publish(SessionEvent.Tool.Called, {
  1218. sessionID,
  1219. timestamp: yield* DateTime.now,
  1220. assistantMessageID,
  1221. callID: "call-hosted-interrupted",
  1222. tool: "web_search",
  1223. input: { query: "stale" },
  1224. provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } },
  1225. })
  1226. requests.length = 0
  1227. response = []
  1228. yield* session.resume(sessionID)
  1229. expect(requests).toHaveLength(1)
  1230. expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant"])
  1231. expect(requests[0]?.messages[1]?.content).toMatchObject([
  1232. {
  1233. type: "tool-call",
  1234. id: "call-hosted-interrupted",
  1235. providerExecuted: true,
  1236. providerMetadata: { openai: { itemId: "call-hosted-interrupted" } },
  1237. },
  1238. { type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
  1239. ])
  1240. }),
  1241. )
  1242. it.effect("durably fails pending tool input left by a prior process before continuing", () =>
  1243. Effect.gen(function* () {
  1244. yield* setup
  1245. const session = yield* SessionV2.Service
  1246. const events = yield* EventV2.Service
  1247. yield* session.prompt({
  1248. sessionID,
  1249. prompt: new Prompt({ text: "Recover interrupted tool input" }),
  1250. resume: false,
  1251. })
  1252. yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
  1253. const assistantMessageID = SessionMessage.ID.create()
  1254. yield* events.publish(SessionEvent.Step.Started, {
  1255. sessionID,
  1256. assistantMessageID,
  1257. timestamp: yield* DateTime.now,
  1258. agent: "build",
  1259. model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
  1260. })
  1261. yield* events.publish(SessionEvent.Tool.Input.Started, {
  1262. sessionID,
  1263. timestamp: yield* DateTime.now,
  1264. assistantMessageID,
  1265. callID: "call-pending-interrupted",
  1266. name: "echo",
  1267. })
  1268. requests.length = 0
  1269. response = []
  1270. yield* session.resume(sessionID)
  1271. expect(requests).toHaveLength(1)
  1272. expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
  1273. expect(yield* session.context(sessionID)).toMatchObject([
  1274. { type: "user", text: "Recover interrupted tool input" },
  1275. { type: "assistant", content: [{ type: "tool", id: "call-pending-interrupted", state: { status: "error" } }] },
  1276. ])
  1277. }),
  1278. )
  1279. it.effect("starts the first queued activity when woken while idle", () =>
  1280. Effect.gen(function* () {
  1281. yield* setup
  1282. const session = yield* SessionV2.Service
  1283. yield* session.prompt({
  1284. sessionID,
  1285. prompt: new Prompt({ text: "Wait for fresh activity" }),
  1286. delivery: "queue",
  1287. resume: false,
  1288. })
  1289. requests.length = 0
  1290. yield* (yield* SessionRunCoordinator.Service).wake(sessionID)
  1291. yield* Effect.yieldNow
  1292. expect(requests).toHaveLength(1)
  1293. expect(userTexts(requests[0]!)).toEqual(["Wait for fresh activity"])
  1294. }),
  1295. )
  1296. it.effect("does not spend one activity step budget across queued activities", () =>
  1297. Effect.gen(function* () {
  1298. yield* setup
  1299. const session = yield* SessionV2.Service
  1300. const queued = Array.from({ length: 26 }, (_, index) => `Queued activity ${index + 1}`)
  1301. for (const text of queued) {
  1302. yield* session.prompt({ sessionID, prompt: new Prompt({ text }), delivery: "queue", resume: false })
  1303. }
  1304. requests.length = 0
  1305. responses = queued.map(() => [
  1306. LLMEvent.stepStart({ index: 0 }),
  1307. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1308. LLMEvent.finish({ reason: "stop" }),
  1309. ])
  1310. yield* session.resume(sessionID)
  1311. expect(requests).toHaveLength(queued.length)
  1312. expect(userTexts(requests.at(-1)!)).toEqual(queued)
  1313. }),
  1314. )
  1315. it.effect("retries inbox input after prompt projection rolls back", () =>
  1316. Effect.gen(function* () {
  1317. yield* setup
  1318. const session = yield* SessionV2.Service
  1319. const events = yield* EventV2.Service
  1320. const defect = new Error("fail after prompt promotion")
  1321. let fail = true
  1322. yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void))
  1323. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false })
  1324. expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
  1325. fail = false
  1326. requests.length = 0
  1327. response = [
  1328. LLMEvent.stepStart({ index: 0 }),
  1329. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1330. LLMEvent.finish({ reason: "stop" }),
  1331. ]
  1332. yield* (yield* SessionRunCoordinator.Service).wake(sessionID)
  1333. while (requests.length === 0) yield* Effect.yieldNow
  1334. expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
  1335. }),
  1336. )
  1337. it.effect("does not strand a committed promotion when a post-commit listener defects", () =>
  1338. Effect.gen(function* () {
  1339. yield* setup
  1340. const session = yield* SessionV2.Service
  1341. const events = yield* EventV2.Service
  1342. yield* events.listen((event) =>
  1343. event.type === SessionEvent.PromptLifecycle.Promoted.type
  1344. ? Effect.die("fail after prompt promotion commits")
  1345. : Effect.void,
  1346. )
  1347. yield* session.prompt({
  1348. sessionID,
  1349. prompt: new Prompt({ text: "Run committed promotion" }),
  1350. resume: false,
  1351. })
  1352. requests.length = 0
  1353. yield* session.resume(sessionID)
  1354. expect(requests).toHaveLength(1)
  1355. expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"])
  1356. }),
  1357. )
  1358. it.effect("runs different sessions concurrently", () =>
  1359. Effect.gen(function* () {
  1360. yield* setup
  1361. yield* insertSession(otherSessionID)
  1362. const session = yield* SessionV2.Service
  1363. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run first" }), resume: false })
  1364. yield* session.prompt({ sessionID: otherSessionID, prompt: new Prompt({ text: "Run second" }), resume: false })
  1365. requests.length = 0
  1366. responses = undefined
  1367. response = []
  1368. streamGate = yield* Deferred.make<void>()
  1369. streamStarted = yield* Deferred.make<void>()
  1370. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1371. yield* Deferred.await(streamStarted)
  1372. const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
  1373. yield* Effect.yieldNow
  1374. expect(requests).toHaveLength(2)
  1375. yield* Deferred.succeed(streamGate, undefined)
  1376. yield* Fiber.join(first)
  1377. yield* Fiber.join(second)
  1378. streamGate = undefined
  1379. streamStarted = undefined
  1380. }),
  1381. )
  1382. it.effect("fans out one failed run and allows a later retry", () =>
  1383. Effect.gen(function* () {
  1384. yield* setup
  1385. const session = yield* SessionV2.Service
  1386. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Retry after failure" }), resume: false })
  1387. requests.length = 0
  1388. responses = undefined
  1389. response = []
  1390. streamFailure = providerUnavailable()
  1391. streamGate = yield* Deferred.make<void>()
  1392. streamStarted = yield* Deferred.make<void>()
  1393. const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1394. yield* Deferred.await(streamStarted)
  1395. const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1396. yield* Effect.yieldNow
  1397. expect(requests).toHaveLength(1)
  1398. yield* Deferred.succeed(streamGate, undefined)
  1399. const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
  1400. expect(secondExit).toEqual(firstExit)
  1401. streamFailure = undefined
  1402. streamGate = undefined
  1403. streamStarted = undefined
  1404. yield* session.resume(sessionID)
  1405. expect(requests).toHaveLength(2)
  1406. }),
  1407. )
  1408. it.effect("durably settles local tool failures before continuing", () =>
  1409. Effect.gen(function* () {
  1410. yield* setup
  1411. const session = yield* SessionV2.Service
  1412. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call missing" }), resume: false })
  1413. requests.length = 0
  1414. responses = [
  1415. [
  1416. LLMEvent.stepStart({ index: 0 }),
  1417. LLMEvent.toolCall({ id: "call-missing", name: "missing", input: {} }),
  1418. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1419. LLMEvent.finish({ reason: "tool-calls" }),
  1420. ],
  1421. [
  1422. LLMEvent.stepStart({ index: 0 }),
  1423. LLMEvent.textStart({ id: "text-after-error" }),
  1424. LLMEvent.textDelta({ id: "text-after-error", text: "Recovered" }),
  1425. LLMEvent.textEnd({ id: "text-after-error" }),
  1426. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1427. LLMEvent.finish({ reason: "stop" }),
  1428. ],
  1429. ]
  1430. streamGate = undefined
  1431. streamStarted = undefined
  1432. yield* session.resume(sessionID)
  1433. expect(requests).toHaveLength(2)
  1434. expect(yield* session.context(sessionID)).toMatchObject([
  1435. { type: "user", text: "Call missing" },
  1436. {
  1437. type: "assistant",
  1438. content: [
  1439. {
  1440. type: "tool",
  1441. id: "call-missing",
  1442. state: { status: "error", error: { message: "Unknown tool: missing" } },
  1443. },
  1444. ],
  1445. },
  1446. { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] },
  1447. ])
  1448. }),
  1449. )
  1450. it.effect("durably settles unexpected local tool defects before continuing", () =>
  1451. Effect.gen(function* () {
  1452. yield* setup
  1453. const session = yield* SessionV2.Service
  1454. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call defect" }), resume: false })
  1455. requests.length = 0
  1456. responses = [
  1457. [
  1458. LLMEvent.stepStart({ index: 0 }),
  1459. LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }),
  1460. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1461. LLMEvent.finish({ reason: "tool-calls" }),
  1462. ],
  1463. [],
  1464. ]
  1465. yield* session.resume(sessionID)
  1466. expect(requests).toHaveLength(2)
  1467. expect(yield* session.context(sessionID)).toMatchObject([
  1468. { type: "user", text: "Call defect" },
  1469. {
  1470. type: "assistant",
  1471. content: [
  1472. {
  1473. type: "tool",
  1474. id: "call-defect",
  1475. state: { status: "error", error: { message: "unexpected tool defect" } },
  1476. },
  1477. ],
  1478. },
  1479. ])
  1480. }),
  1481. )
  1482. it.effect("interrupts runner continuation when a question is dismissed", () =>
  1483. Effect.gen(function* () {
  1484. yield* setup
  1485. const session = yield* SessionV2.Service
  1486. const registry = yield* ToolRegistry.Service
  1487. const questions = yield* QuestionV2.Service
  1488. const transform = yield* registry.transform()
  1489. yield* transform((editor) =>
  1490. editor.set("question", {
  1491. tool: Tool.make({
  1492. description: "Ask the user",
  1493. parameters: Schema.Struct({}),
  1494. success: Schema.Struct({}),
  1495. }),
  1496. execute: ({ sessionID }) => questions.ask({ sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
  1497. }),
  1498. )
  1499. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
  1500. requests.length = 0
  1501. responses = [
  1502. [
  1503. LLMEvent.stepStart({ index: 0 }),
  1504. LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }),
  1505. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1506. LLMEvent.finish({ reason: "tool-calls" }),
  1507. ],
  1508. [],
  1509. ]
  1510. const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
  1511. let pending = yield* questions.list()
  1512. while (pending.length === 0) {
  1513. yield* Effect.yieldNow
  1514. pending = yield* questions.list()
  1515. }
  1516. yield* questions.reject(pending[0]!.id)
  1517. const exit = yield* Fiber.join(run)
  1518. expect(exit._tag).toBe("Failure")
  1519. if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
  1520. expect(requests).toHaveLength(1)
  1521. expect(yield* session.context(sessionID)).toMatchObject([
  1522. { type: "user", text: "Ask then stop" },
  1523. {
  1524. type: "assistant",
  1525. content: [
  1526. {
  1527. type: "tool",
  1528. id: "call-question",
  1529. state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
  1530. },
  1531. ],
  1532. },
  1533. ])
  1534. }),
  1535. )
  1536. it.effect("awaits started local tools before surfacing provider stream failure", () =>
  1537. Effect.gen(function* () {
  1538. yield* setup
  1539. const session = yield* SessionV2.Service
  1540. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Settle before failing" }), resume: false })
  1541. const failure = providerUnavailable()
  1542. toolExecutionGate = yield* Deferred.make<void>()
  1543. responseStream = Stream.concat(
  1544. Stream.fromIterable([
  1545. LLMEvent.stepStart({ index: 0 }),
  1546. LLMEvent.toolCall({ id: "call-before-failure", name: "echo", input: { text: "settle" } }),
  1547. ]),
  1548. Stream.fail(failure),
  1549. )
  1550. const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1551. while (executions.length === 0) yield* Effect.yieldNow
  1552. yield* Effect.yieldNow
  1553. yield* Deferred.succeed(toolExecutionGate, undefined)
  1554. expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
  1555. toolExecutionGate = undefined
  1556. expect(yield* session.context(sessionID)).toMatchObject([
  1557. { type: "user", text: "Settle before failing" },
  1558. {
  1559. type: "assistant",
  1560. content: [
  1561. { type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } },
  1562. ],
  1563. },
  1564. ])
  1565. }),
  1566. )
  1567. it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
  1568. Effect.gen(function* () {
  1569. yield* setup
  1570. const session = yield* SessionV2.Service
  1571. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt blocked tool" }), resume: false })
  1572. executions.length = 0
  1573. toolExecutionGate = yield* Deferred.make<void>()
  1574. responseStream = Stream.concat(
  1575. Stream.fromIterable([
  1576. LLMEvent.stepStart({ index: 0 }),
  1577. LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }),
  1578. ]),
  1579. Stream.never,
  1580. )
  1581. const runner = yield* SessionRunner.Service
  1582. const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
  1583. while (executions.length === 0) yield* Effect.yieldNow
  1584. yield* Fiber.interrupt(run)
  1585. toolExecutionGate = undefined
  1586. expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
  1587. expect(yield* session.context(sessionID)).toMatchObject([
  1588. { type: "user", text: "Interrupt blocked tool" },
  1589. {
  1590. type: "assistant",
  1591. content: [
  1592. {
  1593. type: "tool",
  1594. id: "call-before-interrupt",
  1595. state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
  1596. },
  1597. ],
  1598. },
  1599. ])
  1600. yield* replaySessionProjection(sessionID)
  1601. expect(yield* session.context(sessionID)).toMatchObject([
  1602. { type: "user", text: "Interrupt blocked tool" },
  1603. { type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
  1604. ])
  1605. requests.length = 0
  1606. responseStream = undefined
  1607. response = []
  1608. yield* session.resume(sessionID)
  1609. expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
  1610. }),
  1611. )
  1612. it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
  1613. Effect.gen(function* () {
  1614. yield* setup
  1615. const session = yield* SessionV2.Service
  1616. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt tool settlement" }), resume: false })
  1617. executions.length = 0
  1618. toolExecutionGate = yield* Deferred.make<void>()
  1619. response = [
  1620. LLMEvent.stepStart({ index: 0 }),
  1621. LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }),
  1622. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1623. LLMEvent.finish({ reason: "tool-calls" }),
  1624. ]
  1625. const runner = yield* SessionRunner.Service
  1626. const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
  1627. while (executions.length === 0) yield* Effect.yieldNow
  1628. yield* Fiber.interrupt(run)
  1629. toolExecutionGate = undefined
  1630. expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
  1631. expect(yield* session.context(sessionID)).toMatchObject([
  1632. { type: "user", text: "Interrupt tool settlement" },
  1633. {
  1634. type: "assistant",
  1635. content: [
  1636. {
  1637. type: "tool",
  1638. id: "call-await-interrupt",
  1639. state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
  1640. },
  1641. ],
  1642. },
  1643. ])
  1644. }),
  1645. )
  1646. it.effect("fails after the bounded number of local tool continuation steps", () =>
  1647. Effect.gen(function* () {
  1648. yield* setup
  1649. const session = yield* SessionV2.Service
  1650. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
  1651. requests.length = 0
  1652. authorizations.length = 0
  1653. executions.length = 0
  1654. streamGate = undefined
  1655. streamStarted = undefined
  1656. responses = Array.from({ length: 25 }, (_, index) => [
  1657. LLMEvent.stepStart({ index: 0 }),
  1658. LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
  1659. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1660. LLMEvent.finish({ reason: "tool-calls" }),
  1661. ])
  1662. const failure = yield* session.resume(sessionID).pipe(Effect.flip)
  1663. expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
  1664. expect(requests).toHaveLength(25)
  1665. expect(executions).toHaveLength(25)
  1666. }),
  1667. )
  1668. it.effect("does not restart a capped tool loop for a coalesced stale wake", () =>
  1669. Effect.gen(function* () {
  1670. yield* setup
  1671. const session = yield* SessionV2.Service
  1672. const coordinator = yield* SessionRunCoordinator.Service
  1673. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
  1674. requests.length = 0
  1675. responses = Array.from({ length: 25 }, (_, index) => [
  1676. LLMEvent.stepStart({ index: 0 }),
  1677. LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }),
  1678. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1679. LLMEvent.finish({ reason: "tool-calls" }),
  1680. ])
  1681. streamGate = yield* Deferred.make<void>()
  1682. streamStarted = yield* Deferred.make<void>()
  1683. const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
  1684. yield* Deferred.await(streamStarted)
  1685. yield* coordinator.wake(sessionID)
  1686. yield* Deferred.succeed(streamGate, undefined)
  1687. expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" })
  1688. streamGate = undefined
  1689. streamStarted = undefined
  1690. yield* Effect.yieldNow
  1691. expect(requests).toHaveLength(25)
  1692. }),
  1693. )
  1694. it.effect("accepts a terminal response on the final bounded provider turn", () =>
  1695. Effect.gen(function* () {
  1696. yield* setup
  1697. const session = yield* SessionV2.Service
  1698. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
  1699. requests.length = 0
  1700. responses = [
  1701. ...Array.from({ length: 24 }, (_, index) => [
  1702. LLMEvent.stepStart({ index: 0 }),
  1703. LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
  1704. LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
  1705. LLMEvent.finish({ reason: "tool-calls" }),
  1706. ]),
  1707. [
  1708. LLMEvent.stepStart({ index: 0 }),
  1709. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1710. LLMEvent.finish({ reason: "stop" }),
  1711. ],
  1712. ]
  1713. yield* session.resume(sessionID)
  1714. expect(requests).toHaveLength(25)
  1715. }),
  1716. )
  1717. it.effect("projects provider errors as terminal assistant step failures", () =>
  1718. Effect.gen(function* () {
  1719. yield* setup
  1720. const session = yield* SessionV2.Service
  1721. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail durably" }), resume: false })
  1722. requests.length = 0
  1723. responses = undefined
  1724. streamGate = undefined
  1725. streamStarted = undefined
  1726. response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
  1727. yield* session.resume(sessionID)
  1728. expect(requests).toHaveLength(1)
  1729. expect(yield* session.context(sessionID)).toMatchObject([
  1730. { type: "user", text: "Fail durably" },
  1731. { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
  1732. ])
  1733. }),
  1734. )
  1735. it.effect("projects provider errors emitted before assistant step start", () =>
  1736. Effect.gen(function* () {
  1737. yield* setup
  1738. const session = yield* SessionV2.Service
  1739. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail before step" }), resume: false })
  1740. requests.length = 0
  1741. response = [LLMEvent.providerError({ message: "Provider unavailable" })]
  1742. yield* session.resume(sessionID)
  1743. expect(requests).toHaveLength(1)
  1744. expect(yield* session.context(sessionID)).toMatchObject([
  1745. { type: "user", text: "Fail before step" },
  1746. { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
  1747. ])
  1748. }),
  1749. )
  1750. it.effect("projects raw provider stream failures as terminal assistant step failures", () =>
  1751. Effect.gen(function* () {
  1752. yield* setup
  1753. const session = yield* SessionV2.Service
  1754. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail raw stream durably" }), resume: false })
  1755. const failure = providerUnavailable()
  1756. responseStream = Stream.fail(failure)
  1757. expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
  1758. yield* replaySessionProjection(sessionID)
  1759. expect(yield* session.context(sessionID)).toMatchObject([
  1760. { type: "user", text: "Fail raw stream durably" },
  1761. { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
  1762. ])
  1763. }),
  1764. )
  1765. it.effect("does not continue automatically after a provider error follows a local tool call", () =>
  1766. Effect.gen(function* () {
  1767. yield* setup
  1768. const session = yield* SessionV2.Service
  1769. yield* session.prompt({
  1770. sessionID,
  1771. prompt: new Prompt({ text: "Do not continue failed provider" }),
  1772. resume: false,
  1773. })
  1774. requests.length = 0
  1775. const executionCount = executions.length
  1776. response = [
  1777. LLMEvent.stepStart({ index: 0 }),
  1778. LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
  1779. LLMEvent.providerError({ message: "Provider unavailable" }),
  1780. ]
  1781. yield* session.resume(sessionID)
  1782. expect(requests).toHaveLength(1)
  1783. expect(executions.slice(executionCount)).toEqual(["settled"])
  1784. }),
  1785. )
  1786. it.effect("durably fails a hosted tool when its provider errors before returning a result", () =>
  1787. Effect.gen(function* () {
  1788. yield* setup
  1789. const session = yield* SessionV2.Service
  1790. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool durably" }), resume: false })
  1791. requests.length = 0
  1792. response = [
  1793. LLMEvent.stepStart({ index: 0 }),
  1794. LLMEvent.toolCall({
  1795. id: "call-hosted-provider-error",
  1796. name: "web_search",
  1797. input: { query: "effect" },
  1798. providerExecuted: true,
  1799. }),
  1800. LLMEvent.providerError({ message: "Provider unavailable" }),
  1801. ]
  1802. yield* session.resume(sessionID)
  1803. expect(requests).toHaveLength(1)
  1804. expect(yield* session.context(sessionID)).toMatchObject([
  1805. { type: "user", text: "Fail hosted tool durably" },
  1806. {
  1807. type: "assistant",
  1808. content: [{ type: "tool", id: "call-hosted-provider-error", state: { status: "error" } }],
  1809. },
  1810. ])
  1811. }),
  1812. )
  1813. it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () =>
  1814. Effect.gen(function* () {
  1815. yield* setup
  1816. const session = yield* SessionV2.Service
  1817. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool at EOF" }), resume: false })
  1818. response = [
  1819. LLMEvent.stepStart({ index: 0 }),
  1820. LLMEvent.toolCall({
  1821. id: "call-hosted-eof",
  1822. name: "web_search",
  1823. input: { query: "effect" },
  1824. providerExecuted: true,
  1825. }),
  1826. ]
  1827. yield* session.resume(sessionID)
  1828. yield* replaySessionProjection(sessionID)
  1829. expect(yield* session.context(sessionID)).toMatchObject([
  1830. { type: "user", text: "Fail hosted tool at EOF" },
  1831. { type: "assistant", content: [{ type: "tool", id: "call-hosted-eof", state: { status: "error" } }] },
  1832. ])
  1833. }),
  1834. )
  1835. it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () =>
  1836. Effect.gen(function* () {
  1837. yield* setup
  1838. const session = yield* SessionV2.Service
  1839. yield* session.prompt({
  1840. sessionID,
  1841. prompt: new Prompt({ text: "Fail hosted tool on raw failure" }),
  1842. resume: false,
  1843. })
  1844. const failure = providerUnavailable()
  1845. responseStream = Stream.concat(
  1846. Stream.fromIterable([
  1847. LLMEvent.stepStart({ index: 0 }),
  1848. LLMEvent.toolCall({
  1849. id: "call-hosted-raw-failure",
  1850. name: "web_search",
  1851. input: { query: "effect" },
  1852. providerExecuted: true,
  1853. }),
  1854. ]),
  1855. Stream.fail(failure),
  1856. )
  1857. expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
  1858. yield* replaySessionProjection(sessionID)
  1859. expect(yield* session.context(sessionID)).toMatchObject([
  1860. { type: "user", text: "Fail hosted tool on raw failure" },
  1861. {
  1862. type: "assistant",
  1863. finish: "error",
  1864. error: { type: "unknown", message: "Provider unavailable" },
  1865. content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
  1866. },
  1867. ])
  1868. }),
  1869. )
  1870. it.effect("keeps interleaved assistant text blocks separate", () =>
  1871. Effect.gen(function* () {
  1872. yield* setup
  1873. const session = yield* SessionV2.Service
  1874. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Two blocks" }), resume: false })
  1875. responses = undefined
  1876. streamGate = undefined
  1877. streamStarted = undefined
  1878. response = [
  1879. LLMEvent.stepStart({ index: 0 }),
  1880. LLMEvent.textStart({ id: "text-1" }),
  1881. LLMEvent.textStart({ id: "text-2" }),
  1882. LLMEvent.textDelta({ id: "text-1", text: "First" }),
  1883. LLMEvent.textDelta({ id: "text-2", text: "Second" }),
  1884. LLMEvent.textEnd({ id: "text-1" }),
  1885. LLMEvent.textEnd({ id: "text-2" }),
  1886. LLMEvent.stepFinish({ index: 0, reason: "stop" }),
  1887. LLMEvent.finish({ reason: "stop" }),
  1888. ]
  1889. yield* session.resume(sessionID)
  1890. expect(yield* session.context(sessionID)).toMatchObject([
  1891. { type: "user", text: "Two blocks" },
  1892. {
  1893. type: "assistant",
  1894. content: [
  1895. { type: "text", id: "text-1", text: "First" },
  1896. { type: "text", id: "text-2", text: "Second" },
  1897. ],
  1898. },
  1899. ])
  1900. }),
  1901. )
  1902. for (const kind of fragmentKinds) {
  1903. it.effect(`broadcasts provider ${kind} deltas without storing projection rewrites`, () =>
  1904. verifyEphemeralDeltas(kind),
  1905. )
  1906. it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind))
  1907. it.effect(`durably closes partial ${kind} when the provider stream is interrupted`, () =>
  1908. verifyPartialFlushOnInterruption(kind),
  1909. )
  1910. }
  1911. it.effect("rejects duplicate streamed text starts", () =>
  1912. Effect.gen(function* () {
  1913. yield* setup
  1914. const session = yield* SessionV2.Service
  1915. responses = undefined
  1916. streamGate = undefined
  1917. streamStarted = undefined
  1918. response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]
  1919. expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
  1920. "Duplicate text start: text-1",
  1921. )
  1922. }),
  1923. )
  1924. it.effect("transitions streamed raw tool input to parsed called input", () =>
  1925. Effect.gen(function* () {
  1926. yield* setup
  1927. const session = yield* SessionV2.Service
  1928. yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call provider tool" }), resume: false })
  1929. responses = undefined
  1930. streamGate = undefined
  1931. streamStarted = undefined
  1932. response = [
  1933. LLMEvent.stepStart({ index: 0 }),
  1934. LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }),
  1935. LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
  1936. LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
  1937. LLMEvent.toolCall({ id: "call-parsed", name: "web_search", input: { query: "hello" }, providerExecuted: true }),
  1938. ]
  1939. yield* session.resume(sessionID)
  1940. expect(yield* session.context(sessionID)).toMatchObject([
  1941. { type: "user", text: "Call provider tool" },
  1942. {
  1943. type: "assistant",
  1944. content: [{ type: "tool", id: "call-parsed", state: { status: "error", input: { query: "hello" } } }],
  1945. },
  1946. ])
  1947. }),
  1948. )
  1949. it.effect("rejects malformed streamed tool input ordering", () =>
  1950. Effect.gen(function* () {
  1951. yield* setup
  1952. const session = yield* SessionV2.Service
  1953. responses = undefined
  1954. streamGate = undefined
  1955. streamStarted = undefined
  1956. response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
  1957. expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
  1958. "Tool input delta before start: call-1",
  1959. )
  1960. }),
  1961. )
  1962. })