session-runner.test.ts 79 KB

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