session-runner.test.ts 76 KB

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