| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402 |
- import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
- import { SessionV1 } from "@kirincode-ai/core/v1/session"
- import { Database } from "@kirincode-ai/core/database/database"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { SessionProjector } from "@kirincode-ai/core/session/projector"
- import { eq } from "drizzle-orm"
- import { EventV2Bridge } from "@/event-v2-bridge"
- import { expect } from "bun:test"
- import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
- import path from "path"
- import { fileURLToPath } from "url"
- import { NamedError } from "@kirincode-ai/core/util/error"
- import { Agent as AgentSvc } from "../../src/agent/agent"
- import { BackgroundJob } from "@/background/job"
- import { Command } from "../../src/command"
- import { Config } from "@/config/config"
- import { LSP } from "@/lsp/lsp"
- import { MCP } from "../../src/mcp"
- import { Permission } from "../../src/permission"
- import { Plugin } from "../../src/plugin"
- import { Provider as ProviderSvc } from "@/provider/provider"
- import { Env } from "../../src/env"
- import { Git } from "../../src/git"
- import { Image } from "../../src/image/image"
- import { Question } from "../../src/question"
- import { Todo } from "../../src/session/todo"
- import { Session } from "@/session/session"
- import { SessionMessageTable } from "@kirincode-ai/core/session/sql"
- import { LLM } from "../../src/session/llm"
- import { MessageV2 } from "../../src/session/message-v2"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { SessionCompaction } from "../../src/session/compaction"
- import { SessionSummary } from "../../src/session/summary"
- import { Instruction } from "../../src/session/instruction"
- import { SessionProcessor } from "../../src/session/processor"
- import { SessionPrompt } from "../../src/session/prompt"
- import { SessionRevert } from "../../src/session/revert"
- import { SessionRunState } from "../../src/session/run-state"
- import { MessageID, PartID, SessionID } from "../../src/session/schema"
- import { SessionStatus } from "../../src/session/status"
- import { SessionV2 } from "@kirincode-ai/core/session"
- import { SessionExecution } from "@kirincode-ai/core/session/execution"
- import { Skill } from "../../src/skill"
- import { SystemPrompt } from "../../src/session/system"
- import { Shell } from "@kirincode-ai/core/shell"
- import { Snapshot } from "../../src/snapshot"
- import { ToolRegistry } from "@/tool/registry"
- import { Truncate } from "@/tool/truncate"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { Ripgrep } from "@kirincode-ai/core/ripgrep"
- import { Format } from "../../src/format"
- import { TestInstance } from "../fixture/fixture"
- import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
- import { reply, TestLLMServer } from "../lib/llm-server"
- import { RuntimeFlags } from "@/effect/runtime-flags"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { LocationServiceMap, locationServiceMapLayer } from "@kirincode-ai/core/location-services"
- const summary = Layer.succeed(
- SessionSummary.Service,
- SessionSummary.Service.of({
- summarize: () => Effect.void,
- diff: () => Effect.succeed([]),
- computeDiff: () => Effect.succeed([]),
- }),
- )
- const ref = {
- providerID: ProviderV2.ID.make("test"),
- modelID: ModelV2.ID.make("test-model"),
- }
- function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
- return Effect.acquireUseRelease(
- Effect.sync(() => {
- const prev = process.env.SHELL
- process.env.SHELL = "/bin/sh"
- Shell.preferred.reset()
- return prev
- }),
- () => fx(),
- (prev) =>
- Effect.sync(() => {
- if (prev === undefined) delete process.env.SHELL
- else process.env.SHELL = prev
- Shell.preferred.reset()
- }),
- )
- }
- function toolPart(parts: SessionV1.Part[]) {
- return parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
- }
- type CompletedToolPart = SessionV1.ToolPart & { state: SessionV1.ToolStateCompleted }
- type ErrorToolPart = SessionV1.ToolPart & { state: SessionV1.ToolStateError }
- function completedTool(parts: SessionV1.Part[]) {
- const part = toolPart(parts)
- expect(part?.state.status).toBe("completed")
- return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined
- }
- function errorTool(parts: SessionV1.Part[]) {
- const part = toolPart(parts)
- expect(part?.state.status).toBe("error")
- return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
- }
- function makeMcp(instructions: MCP.ServerInstructions[] = []) {
- return Layer.succeed(
- MCP.Service,
- MCP.Service.of({
- status: () => Effect.succeed({}),
- clients: () => Effect.succeed({}),
- instructions: () => Effect.succeed(instructions),
- tools: () => Effect.succeed({}),
- prompts: () => Effect.succeed({}),
- resources: () => Effect.succeed({}),
- resourceTemplates: () => Effect.succeed({}),
- add: () => Effect.succeed({ status: { status: "disabled" as const } }),
- connect: () => Effect.void,
- disconnect: () => Effect.void,
- getPrompt: () => Effect.succeed(undefined),
- readResource: () => Effect.succeed(undefined),
- startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- removeAuth: () => Effect.void,
- supportsOAuth: () => Effect.succeed(false),
- hasStoredTokens: () => Effect.succeed(false),
- getAuthStatus: () => Effect.succeed("not_authenticated" as const),
- }),
- )
- }
- const lsp = Layer.succeed(
- LSP.Service,
- LSP.Service.of({
- init: () => Effect.void,
- status: () => Effect.succeed([]),
- hasClients: () => Effect.succeed(false),
- touchFile: () => Effect.void,
- diagnostics: () => Effect.succeed({}),
- hover: () => Effect.succeed(undefined),
- definition: () => Effect.succeed([]),
- references: () => Effect.succeed([]),
- implementation: () => Effect.succeed([]),
- documentSymbol: () => Effect.succeed([]),
- workspaceSymbol: () => Effect.succeed([]),
- prepareCallHierarchy: () => Effect.succeed([]),
- incomingCalls: () => Effect.succeed([]),
- outgoingCalls: () => Effect.succeed([]),
- }),
- )
- const processorCreateStarted: Array<() => void> = []
- const blockingProcessor = Layer.succeed(
- SessionProcessor.Service,
- SessionProcessor.Service.of({
- create: () => Effect.sync(() => processorCreateStarted.shift()?.()).pipe(Effect.andThen(Effect.never)),
- }),
- )
- const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true })
- const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })
- const promptRoot = LayerNode.group([
- SessionPrompt.node,
- Session.node,
- SessionProjector.node,
- MessageV2.node,
- Snapshot.node,
- LLM.node,
- Env.node,
- AgentSvc.node,
- Command.node,
- Permission.node,
- Plugin.node,
- Config.node,
- ProviderSvc.node,
- LSP.node,
- MCP.node,
- FSUtil.node,
- BackgroundJob.node,
- SessionStatus.node,
- SessionRunState.node,
- Database.node,
- EventV2Bridge.node,
- Question.node,
- Todo.node,
- ToolRegistry.node,
- Skill.node,
- Git.node,
- Ripgrep.node,
- Format.node,
- Truncate.node,
- SessionProcessor.node,
- Image.node,
- SessionCompaction.node,
- SessionRevert.node,
- Instruction.node,
- SystemPrompt.node,
- CrossSpawnSpawner.node,
- RuntimeFlags.node,
- ])
- function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) {
- const replacements = [
- [SessionSummary.node, summary],
- [LSP.node, lsp],
- [MCP.node, makeMcp(input?.mcpInstructions)],
- [RuntimeFlags.node, runtimeFlags],
- ] as const
- if (input?.processor === "blocking") {
- return LayerNode.compile(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]])
- }
- return LayerNode.compile(promptRoot, replacements)
- }
- function makeHttp(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) {
- const root = LayerNode.group([promptRoot, testLLMServerNode])
- const replacements = [
- [SessionSummary.node, summary],
- [LSP.node, lsp],
- [MCP.node, makeMcp(input?.mcpInstructions)],
- [RuntimeFlags.node, runtimeFlags],
- ] as const
- if (input?.processor === "blocking") {
- return LayerNode.compile(root, [...replacements, [SessionProcessor.node, blockingProcessor]])
- }
- return LayerNode.compile(root, replacements)
- }
- function makeHttpNoLLMServer(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) {
- return makePrompt(input)
- }
- const it = testEffect(makeHttp())
- const noLLMServer = testEffect(makeHttpNoLLMServer())
- const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" }))
- const withMcpInstructions = testEffect(
- makeHttp({
- mcpInstructions: [
- {
- name: "guide-server",
- instructions: "Use lookup before mutate.",
- tools: ["guide-server_lookup"],
- },
- ],
- }),
- )
- const unix = process.platform !== "win32" ? it.instance : it.instance.skip
- const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip
- // Config that registers a custom "test" provider with a "test-model" model
- // so provider model lookup succeeds inside the loop.
- const cfg = {
- provider: {
- test: {
- name: "Test",
- id: "test",
- env: [],
- npm: "@ai-sdk/openai-compatible",
- models: {
- "test-model": {
- id: "test-model",
- name: "Test Model",
- attachment: false,
- reasoning: false,
- temperature: false,
- tool_call: true,
- release_date: "2025-01-01",
- limit: { context: 100000, output: 10000 },
- cost: { input: 0, output: 0 },
- options: {},
- },
- },
- options: {
- apiKey: "test-key",
- baseURL: "http://localhost:1/v1",
- },
- },
- },
- }
- function providerCfg(url: string) {
- return {
- ...cfg,
- provider: {
- ...cfg.provider,
- test: {
- ...cfg.provider.test,
- options: {
- ...cfg.provider.test.options,
- baseURL: url,
- },
- },
- },
- }
- }
- const writeText = Effect.fn("test.writeText")(function* (file: string, text: string) {
- const fs = yield* FSUtil.Service
- yield* fs.writeWithDirs(file, text)
- })
- const writeConfig = Effect.fn("test.writeConfig")(function* (dir: string, config: Partial<ConfigV1.Info>) {
- yield* writeText(
- path.join(dir, "kirincode.json"),
- JSON.stringify({ $schema: "https://kirincode.ai/config.json", ...config }),
- )
- })
- const useServerConfig = Effect.fn("test.useServerConfig")(function* (config: (url: string) => Partial<ConfigV1.Info>) {
- const { directory: dir } = yield* TestInstance
- const llm = yield* TestLLMServer
- yield* writeConfig(dir, config(llm.url))
- return { dir, llm }
- })
- // Wait for a session's runner to enter a busy state. SessionStatus is flipped
- // inside Runner.startShell's serialized transition, so cancel can't no-op once
- // we observe it.
- const waitForBusy = (sessionID: SessionID, duration: Duration.Input = "2 seconds") =>
- pollWithTimeout(
- Effect.gen(function* () {
- const status = yield* SessionStatus.Service
- const s = yield* status.get(sessionID)
- return s.type === "busy" ? (true as const) : undefined
- }),
- `session ${sessionID} never became busy`,
- duration,
- )
- const hasBash = Effect.sync(() => Bun.which("bash") !== null)
- const deferredAsPromise = <A>(deferred: Deferred.Deferred<A>): PromiseLike<A> => ({
- then: (onfulfilled, onrejected) => {
- Effect.runFork(
- Deferred.await(deferred).pipe(
- Effect.match({
- onFailure: (error) => {
- onrejected?.(error)
- },
- onSuccess: (value) => {
- onfulfilled?.(value)
- },
- }),
- ),
- )
- return deferredAsPromise(deferred) as PromiseLike<never>
- },
- })
- function defer<T>() {
- let resolve!: (value: T | PromiseLike<T>) => void
- const promise = new Promise<T>((done) => {
- resolve = done
- })
- return { promise, resolve }
- }
- const succeedVoid = (deferred: Deferred.Deferred<void>) => {
- Effect.runSync(Deferred.succeed(deferred, void 0).pipe(Effect.ignore))
- }
- const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) {
- const session = yield* Session.Service
- const msg = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: "build",
- model: ref,
- time: { created: Date.now() },
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: msg.id,
- sessionID,
- type: "text",
- text,
- })
- return msg
- })
- const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) {
- const session = yield* Session.Service
- const msg = yield* user(sessionID, "hello")
- const assistant: SessionV1.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- parentID: msg.id,
- sessionID,
- mode: "build",
- agent: "build",
- cost: 0,
- path: { cwd: "/tmp", root: "/tmp" },
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- modelID: ref.modelID,
- providerID: ref.providerID,
- time: { created: Date.now() },
- ...(opts?.finish ? { finish: opts.finish } : {}),
- }
- yield* session.updateMessage(assistant)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: assistant.id,
- sessionID,
- type: "text",
- text: "hi there",
- })
- return { user: msg, assistant }
- })
- const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID,
- sessionID,
- type: "subtask",
- prompt: "look into the cache key path",
- description: "inspect bug",
- agent: "general",
- model,
- })
- })
- const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
- const config = yield* Config.Service
- const prompt = yield* SessionPrompt.Service
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- yield* config.get()
- const chat = yield* sessions.create(input ?? { title: "Pinned" })
- return { prompt, run, sessions, chat }
- })
- // Loop semantics
- noLLMServer.instance(
- "loop exits immediately when last assistant has stop finish",
- () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* seed(chat.id, { finish: "stop" })
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
- }),
- { config: cfg },
- )
- it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- const seeded = yield* seed(chat.id, { finish: "stop" })
- yield* sessions.updatePart({
- id: PartID.ascending(),
- messageID: seeded.assistant.id,
- sessionID: chat.id,
- type: "tool",
- callID: "interrupted-call",
- tool: "edit",
- state: {
- status: "error",
- input: {},
- error: "Tool execution aborted",
- metadata: { interrupted: true },
- time: { start: 1, end: 2 },
- },
- })
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.id).toBe(seeded.assistant.id)
- expect(yield* llm.hits).toHaveLength(0)
- }),
- )
- it.instance("loop calls LLM and returns assistant message", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.text("world")
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- const parts = result.parts.filter((p) => p.type === "text")
- expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
- expect(yield* llm.hits).toHaveLength(1)
- }),
- )
- withMcpInstructions.instance(
- "loop includes MCP instructions in model system context",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* awaitWithTimeout(llm.wait(1), "timed out waiting for MCP instruction request", "10 seconds")
- const hits = yield* llm.hits
- const body = JSON.stringify(hits[0]?.body)
- expect(body).toContain('<server name=\\"guide-server\\">')
- expect(body).toContain("Use lookup before mutate.")
- yield* Fiber.interrupt(fiber)
- }),
- 15_000,
- )
- it.instance("legacy prompt emits message events without session.next events", () =>
- Effect.gen(function* () {
- const events = yield* EventV2Bridge.Service
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- agent: "plan",
- model: { providerID: ProviderV2.ID.make("old"), id: ModelV2.ID.make("old-model") },
- })
- const seen: string[] = []
- const off = yield* events.listen((event) => {
- seen.push(event.type)
- return Effect.void
- })
- const first = yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- model: ref,
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- const second = yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "again" }],
- })
- yield* off
- expect(first.info.role).toBe("user")
- expect(second.info.role).toBe("user")
- if (first.info.role === "user" && second.info.role === "user") {
- expect(first.info.model).toEqual(ref)
- expect(second.info.model).toEqual(ref)
- }
- expect(yield* sessions.get(chat.id)).toMatchObject({
- agent: "build",
- model: { providerID: ref.providerID, id: ref.modelID },
- })
- expect(seen).toContain(Session.Event.Updated.type)
- expect(seen).toContain(MessageV2.Event.Updated.type)
- expect(seen).toContain(MessageV2.Event.PartUpdated.type)
- expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([])
- }),
- )
- it.instance("loop surfaces content-filter finishes as session errors", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const events = yield* EventV2Bridge.Service
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- const errors: NonNullable<SessionV1.Assistant["error"]>[] = []
- const expected = {
- name: "ContentFilterError",
- data: { message: "The response was blocked by the provider's content filter" },
- } satisfies NonNullable<SessionV1.Assistant["error"]>
- const off = yield* events.listen((event) => {
- if (event.type !== Session.Event.Error.type) return Effect.void
- const data = event.data as typeof Session.Event.Error.data.Type
- if (data.sessionID === chat.id && data.error) errors.push(data.error)
- return Effect.void
- })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.push(reply().text("partial response").contentFilter())
- const result = yield* prompt.loop({ sessionID: chat.id })
- const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: result.info.id })
- yield* off
- expect(yield* llm.hits).toHaveLength(1)
- expect(result.info.role).toBe("assistant")
- expect(stored.info.role).toBe("assistant")
- if (result.info.role === "assistant" && stored.info.role === "assistant") {
- expect(result.info.finish).toBe("content-filter")
- expect(result.info.error).toEqual(expected)
- expect(stored.info.error).toEqual(result.info.error)
- expect(errors).toContainEqual(expected)
- }
- expect(result.parts).toEqual(
- expect.arrayContaining([expect.objectContaining({ type: "text", text: "partial response" })]),
- )
- }),
- )
- it.instance("loop stops provider overflow instead of auto-compacting when disabled", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig((url) => ({
- ...providerCfg(url),
- compaction: { auto: false },
- }))
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.error(413, { error: { message: "request entity too large" } })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- const result = yield* prompt.loop({ sessionID: chat.id })
- const messages = yield* sessions.messages({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.info.error?.name).toBe("ContextOverflowError")
- expect(result.info.finish).toBe("error")
- }
- expect(messages.some((message) => message.parts.some((part) => part.type === "compaction"))).toBe(false)
- }),
- )
- noLLMServer.instance.skip(
- "prompt emits v2 prompted and synthetic events (v2 projector disabled)",
- () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [
- { type: "text", text: "hello v2" },
- {
- type: "file",
- mime: "text/plain",
- filename: "note.txt",
- url: "data:text/plain;base64,bm90ZSBjb250ZW50",
- },
- ],
- })
- const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
- Effect.provide(
- LayerNode.compile(SessionV2.node, [
- [SessionExecution.node, SessionExecution.noopLayer],
- [LocationServiceMap.node, locationServiceMapLayer],
- ]),
- ),
- )
- const { db } = yield* Database.Service
- const row = yield* db
- .select()
- .from(SessionMessageTable)
- .where(eq(SessionMessageTable.session_id, chat.id))
- .get()
- .pipe(Effect.orDie)
- expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
- expect(typeof row?.data.time.created).toBe("number")
- expect(messages).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
- expect.objectContaining({ type: "synthetic", text: "note content" }),
- ]),
- )
- }),
- { config: cfg },
- )
- it.instance("static loop returns assistant text through local provider", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Prompt provider",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.text("world")
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(result.info.role).toBe("assistant")
- expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
- expect(yield* llm.hits).toHaveLength(1)
- expect(yield* llm.pending).toBe(0)
- }),
- )
- it.instance("static loop consumes queued replies across turns", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Prompt provider turns",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello one" }],
- })
- yield* llm.text("world one")
- const first = yield* prompt.loop({ sessionID: session.id })
- expect(first.info.role).toBe("assistant")
- expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello two" }],
- })
- yield* llm.text("world two")
- const second = yield* prompt.loop({ sessionID: session.id })
- expect(second.info.role).toBe("assistant")
- expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
- expect(yield* llm.hits).toHaveLength(2)
- expect(yield* llm.pending).toBe(0)
- }),
- )
- it.instance("loop continues when finish is tool-calls", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.tool("first", { value: "first" })
- yield* llm.text("second")
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(yield* llm.calls).toBe(2)
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- expect(result.info.finish).toBe("stop")
- }
- }),
- )
- it.instance("glob tool keeps instance context during prompt runs", () =>
- Effect.gen(function* () {
- const { dir, llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Glob context",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- const file = path.join(dir, "probe.txt")
- yield* writeText(file, "probe")
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "find text files" }],
- })
- yield* llm.tool("glob", { pattern: "**/*.txt" })
- yield* llm.text("done")
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(result.info.role).toBe("assistant")
- const msgs = yield* MessageV2.filterCompactedEffect(session.id)
- const tool = msgs
- .flatMap((msg) => msg.parts)
- .find(
- (part): part is CompletedToolPart =>
- part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
- )
- if (!tool) return
- expect(tool.state.output).toContain(file)
- expect(tool.state.output).not.toContain("No context found for instance")
- expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
- }),
- )
- it.instance("loop continues when finish is stop but assistant has tool parts", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.push(reply().tool("first", { value: "first" }).stop())
- yield* llm.text("second")
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(yield* llm.calls).toBe(2)
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- expect(result.info.finish).toBe("stop")
- }
- }),
- )
- it.instance("failed subtask preserves metadata on error tool state", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig((url) => ({
- ...providerCfg(url),
- agent: {
- general: {
- model: "test/missing-model",
- },
- },
- }))
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.tool("task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- })
- yield* llm.text("done")
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- expect(yield* llm.calls).toBe(2)
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- expect(taskMsg?.info.role).toBe("assistant")
- if (!taskMsg || taskMsg.info.role !== "assistant") return
- const tool = errorTool(taskMsg.parts)
- if (!tool) return
- expect(tool.state.error).toContain("Tool execution failed")
- expect(tool.state.metadata).toBeDefined()
- expect(tool.state.metadata?.sessionId).toBeDefined()
- expect(tool.state.metadata?.model).toEqual({
- providerID: ProviderV2.ID.make("test"),
- modelID: ModelV2.ID.make("missing-model"),
- })
- }),
- )
- it.instance("subtask child inherits parent session external_directory allow", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Parent",
- permission: [{ permission: "external_directory", pattern: "/tmp/allowed/*", action: "allow" }],
- })
- yield* llm.text("done")
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- yield* prompt.loop({ sessionID: chat.id })
- const kids = yield* sessions.children(chat.id)
- expect(kids).toHaveLength(1)
- const child = kids[0]!
- const rules = child.permission ?? []
- expect(rules).toEqual(
- expect.arrayContaining([{ permission: "external_directory", pattern: "/tmp/allowed/*", action: "allow" }]),
- )
- expect(Permission.evaluate("external_directory", "/tmp/allowed/file", rules).action).toBe("allow")
- expect(Permission.evaluate("task", "anything", rules).action).toBe("deny")
- }),
- )
- noLLMServer.instance("prompt tools replace previous prompt tool rules", () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({ title: "Prompt tools" })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- tools: { bash: false },
- parts: [{ type: "text", text: "first" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- tools: { read: true },
- parts: [{ type: "text", text: "second" }],
- })
- const reloaded = yield* sessions.get(session.id)
- expect(reloaded.permission).toEqual([{ permission: "read", pattern: "*", action: "allow" }])
- expect(Permission.evaluate("bash", "anything", reloaded.permission ?? []).action).toBe("ask")
- }),
- )
- it.instance(
- "running subtask preserves metadata after tool-call transition",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const tool = yield* pollWithTimeout(
- Effect.gen(function* () {
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- const tool = taskMsg?.parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
- if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
- }),
- "timed out waiting for running subtask metadata",
- )
- if (tool.state.status !== "running") return
- expect(typeof tool.state.metadata?.sessionId).toBe("string")
- expect(tool.state.title).toBeDefined()
- expect(tool.state.metadata?.model).toBeDefined()
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- 5_000,
- )
- it.instance(
- "running task tool preserves metadata after tool-call transition",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.tool("task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const tool = yield* pollWithTimeout(
- Effect.gen(function* () {
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build")
- const tool = assistant?.parts.find(
- (part): part is SessionV1.ToolPart => part.type === "tool" && part.tool === "task",
- )
- if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
- }),
- "timed out waiting for running task metadata",
- )
- if (tool.state.status !== "running") return
- expect(typeof tool.state.metadata?.sessionId).toBe("string")
- expect(tool.state.title).toBe("inspect bug")
- expect(tool.state.metadata?.model).toBeDefined()
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- 10_000,
- )
- it.instance(
- "loop sets status to busy then idle",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const status = yield* SessionStatus.Service
- yield* llm.hang
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- expect((yield* status.get(chat.id)).type).toBe("busy")
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- expect((yield* status.get(chat.id)).type).toBe("idle")
- }),
- 3_000,
- )
- // Cancel semantics
- it.instance("cancel interrupts loop and resolves with an assistant message", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* seed(chat.id)
- yield* llm.hang
- yield* user(chat.id, "more")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* waitForBusy(chat.id)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- }
- }),
- )
- it.instance("cancel records MessageAbortedError on interrupted process", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* waitForBusy(chat.id)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- const info = exit.value.info
- if (info.role === "assistant") {
- expect(info.error?.name).toBe("MessageAbortedError")
- }
- }
- }),
- )
- raceNoLLMServer.instance(
- "finalizes assistant when cancelled before processor creation completes",
- () =>
- Effect.gen(function* () {
- processorCreateStarted.length = 0
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- processorCreateStarted.length = 0
- }),
- )
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Processor creation race" })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "first" }],
- })
- const firstCreate = defer<void>()
- processorCreateStarted.push(firstCreate.resolve)
- const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => firstCreate.promise)
- yield* prompt.cancel(chat.id)
- const firstExit = yield* Fiber.await(first)
- expect(Exit.isSuccess(firstExit)).toBe(true)
- let messages = yield* sessions.messages({ sessionID: chat.id })
- const firstInterrupted = messages.at(-1)
- expect(firstInterrupted?.info.role).toBe("assistant")
- expect(firstInterrupted?.parts).toHaveLength(0)
- if (firstInterrupted?.info.role === "assistant") {
- expect(firstInterrupted.info.finish).toBeUndefined()
- expect(firstInterrupted.info.time.completed).toBeNumber()
- expect(firstInterrupted.info.error?.name).toBe("MessageAbortedError")
- }
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "second" }],
- })
- const secondCreate = defer<void>()
- processorCreateStarted.push(secondCreate.resolve)
- const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => secondCreate.promise)
- yield* prompt.cancel(chat.id)
- const secondExit = yield* Fiber.await(second)
- expect(Exit.isSuccess(secondExit)).toBe(true)
- messages = yield* sessions.messages({ sessionID: chat.id })
- const poisonMessages = messages.filter(
- (message) =>
- message.info.role === "assistant" &&
- message.parts.length === 0 &&
- !message.info.finish &&
- !message.info.time.completed &&
- !message.info.error,
- )
- expect(poisonMessages).toHaveLength(0)
- const interruptedMessages = messages.filter(
- (message) =>
- message.info.role === "assistant" &&
- message.parts.length === 0 &&
- message.info.time.completed &&
- message.info.error?.name === "MessageAbortedError",
- )
- expect(interruptedMessages).toHaveLength(2)
- const lastUser = messages.at(-2)
- const lastAssistant = messages.at(-1)
- expect(lastUser?.info.role).toBe("user")
- expect(lastAssistant?.info.role).toBe("assistant")
- if (lastUser?.info.role === "user" && lastAssistant?.info.role === "assistant") {
- expect(lastAssistant.info.parentID).toBe(lastUser?.info.id)
- }
- }),
- { config: cfg },
- 3_000,
- )
- noLLMServer.instance(
- "cancel finalizes subtask tool state",
- () =>
- Effect.gen(function* () {
- const ready = yield* Deferred.make<void>()
- const aborted = yield* Deferred.make<void>()
- const registry = yield* ToolRegistry.Service
- const { task } = yield* registry.named()
- const original = task.execute
- task.execute = (_args, ctx) =>
- Effect.callback<never>((_resume) => {
- ctx.abort.addEventListener("abort", () => succeedVoid(aborted), { once: true })
- if (ctx.abort.aborted) succeedVoid(aborted)
- succeedVoid(ready)
- return Effect.sync(() => succeedVoid(aborted))
- })
- yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
- const { prompt, chat } = yield* boot()
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for task tool to start", "10 seconds")
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- yield* awaitWithTimeout(Deferred.await(aborted), "timed out waiting for task tool abort", "10 seconds")
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- expect(taskMsg?.info.role).toBe("assistant")
- if (!taskMsg || taskMsg.info.role !== "assistant") return
- const tool = toolPart(taskMsg.parts)
- expect(tool?.type).toBe("tool")
- if (!tool) return
- expect(tool.state.status).not.toBe("running")
- expect(taskMsg.info.time.completed).toBeDefined()
- expect(taskMsg.info.finish).toBeDefined()
- }),
- { config: cfg },
- 30_000,
- )
- it.instance(
- "cancel propagates from slash command subtask to child session",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const status = yield* SessionStatus.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
- const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined
- expect(typeof sessionID).toBe("string")
- if (typeof sessionID !== "string") throw new Error("missing child session id")
- const childID = SessionID.make(sessionID)
- expect((yield* status.get(childID)).type).toBe("busy")
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- expect((yield* status.get(chat.id)).type).toBe("idle")
- expect((yield* status.get(childID)).type).toBe("idle")
- }),
- 10_000,
- )
- it.instance(
- "cancel with queued callers resolves all cleanly",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- yield* prompt.cancel(chat.id)
- const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(exitA)).toBe(true)
- expect(Exit.isSuccess(exitB)).toBe(true)
- if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) {
- expect(exitA.value.info.id).toBe(exitB.value.info.id)
- }
- }),
- { git: true },
- 10_000,
- )
- // Queue semantics
- noLLMServer.instance("concurrent loop callers get same result", () =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- yield* seed(chat.id, { finish: "stop" })
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- yield* run.assertNotBusy(chat.id)
- }),
- )
- it.instance("concurrent loop callers all receive same error result", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.fail("boom")
- yield* user(chat.id, "hello")
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- }),
- )
- it.instance("prompt submitted during an active run is included in the next LLM input", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const gate = yield* Deferred.make<void>()
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hold("first", deferredAsPromise(gate))
- yield* llm.text("second")
- const a = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- model: ref,
- parts: [{ type: "text", text: "first" }],
- })
- .pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* waitForBusy(chat.id)
- const id = MessageID.ascending()
- const b = yield* prompt
- .prompt({
- sessionID: chat.id,
- messageID: id,
- agent: "build",
- model: ref,
- parts: [{ type: "text", text: "second" }],
- })
- .pipe(Effect.forkChild)
- yield* pollWithTimeout(
- sessions
- .messages({ sessionID: chat.id })
- .pipe(
- Effect.map((msgs) => (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined)),
- ),
- "timed out waiting for second prompt to save",
- )
- yield* Deferred.succeed(gate, void 0)
- const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(ea)).toBe(true)
- expect(Exit.isSuccess(eb)).toBe(true)
- expect(yield* llm.calls).toBe(2)
- const msgs = yield* sessions.messages({ sessionID: chat.id })
- const assistants = msgs.filter((msg) => msg.info.role === "assistant")
- expect(assistants).toHaveLength(2)
- const last = assistants.at(-1)
- if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
- expect(last.info.parentID).toBe(id)
- expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- const inputs = yield* llm.inputs
- expect(inputs).toHaveLength(2)
- const messages = inputs.at(-1)?.messages
- if (!Array.isArray(messages)) throw new Error("expected LLM messages")
- expect(messages.at(-1)).toEqual({ role: "user", content: "second" })
- }),
- )
- it.instance("assertNotBusy fails with BusyError when loop running", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- yield* llm.hang
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* waitForBusy(chat.id)
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id })
- }
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- )
- noLLMServer.instance("assertNotBusy succeeds when idle", () =>
- Effect.gen(function* () {
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({})
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- )
- // Shell semantics
- it.instance("shell rejects with BusyError when loop running", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* waitForBusy(chat.id)
- const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id })
- }
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- )
- unixNoLLMServer(
- "shell captures stdout and stderr in completed tool output",
- () =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "printf out && printf err >&2",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain("out")
- expect(tool.state.output).toContain("err")
- expect(tool.state.metadata.output).toContain("out")
- expect(tool.state.metadata.output).toContain("err")
- yield* run.assertNotBusy(chat.id)
- }),
- { config: cfg },
- )
- unixNoLLMServer(
- "shell completes a fast command on the preferred shell",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "pwd",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.input.command).toBe("pwd")
- expect(tool.state.output).toContain(dir)
- expect(tool.state.metadata.output).toContain(dir)
- yield* run.assertNotBusy(chat.id)
- }),
- { config: cfg },
- )
- unixNoLLMServer(
- "shell uses configured shell over env shell",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- if (!(yield* hasBash)) return
- const { prompt, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "[[ 1 -eq 1 ]] && printf configured",
- })
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain("configured")
- }),
- ),
- { config: { ...cfg, shell: "bash" } },
- 30_000,
- )
- unixNoLLMServer(
- "shell commands can change directory after startup",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const { prompt, run, chat } = yield* boot()
- const parent = path.dirname(dir)
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "cd .. && pwd",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain(parent)
- expect(tool.state.metadata.output).toContain(parent)
- yield* run.assertNotBusy(chat.id)
- }),
- ),
- { config: cfg },
- )
- unixNoLLMServer(
- "shell lists files from the project directory",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const { prompt, run, chat } = yield* boot()
- yield* writeText(path.join(dir, "README.md"), "# e2e\n")
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "command ls",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.input.command).toBe("command ls")
- expect(tool.state.output).toContain("README.md")
- expect(tool.state.metadata.output).toContain("README.md")
- yield* run.assertNotBusy(chat.id)
- }),
- { config: cfg },
- )
- unixNoLLMServer(
- "shell captures stderr from a failing command",
- () =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain("not found")
- expect(tool.state.metadata.output).toContain("not found")
- yield* run.assertNotBusy(chat.id)
- }),
- { config: cfg },
- )
- unixNoLLMServer(
- "shell updates running metadata before process exit",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const fiber = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" })
- .pipe(Effect.forkChild)
- yield* pollWithTimeout(
- Effect.gen(function* () {
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant")
- const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
- if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return true
- }),
- "timed out waiting for running shell metadata",
- )
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- ),
- { config: cfg },
- 30_000,
- )
- it.instance(
- "loop waits while shell runs and starts after shell exits",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.text("after-shell")
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* waitForBusy(chat.id)
- const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- expect(yield* llm.calls).toBe(0)
- yield* Fiber.await(sh)
- const exit = yield* Fiber.await(loop)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
- }
- expect(yield* llm.calls).toBe(1)
- }),
- { git: true },
- 10_000,
- )
- it.instance(
- "shell completion resumes queued loop callers",
- () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.text("done")
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* waitForBusy(chat.id)
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- expect(yield* llm.calls).toBe(0)
- yield* Fiber.await(sh)
- const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(ea)).toBe(true)
- expect(Exit.isSuccess(eb)).toBe(true)
- if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
- expect(ea.value.info.id).toBe(eb.value.info.id)
- expect(ea.value.info.role).toBe("assistant")
- }
- expect(yield* llm.calls).toBe(1)
- }),
- { git: true },
- 10_000,
- )
- unix(
- "command ! expansion uses configured shell over env shell",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- if (!(yield* hasBash)) return
- const { llm } = yield* useServerConfig((url) => ({
- ...providerCfg(url),
- shell: "bash",
- command: {
- probe: {
- template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
- },
- },
- }))
- const { prompt, chat } = yield* boot()
- yield* llm.text("done")
- const result = yield* prompt.command({
- sessionID: chat.id,
- command: "probe",
- arguments: "",
- })
- expect(result.info.role).toBe("assistant")
- const inputs = yield* llm.inputs
- expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
- }),
- ),
- 30_000,
- )
- unixNoLLMServer(
- "cancel interrupts shell and resolves cleanly",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const { directory: dir } = yield* TestInstance
- const afs = yield* FSUtil.Service
- const ready = path.join(dir, ".shell-ready")
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: ": > '.shell-ready'; sleep 30" })
- .pipe(Effect.forkChild)
- yield* pollWithTimeout(
- afs.existsSafe(ready).pipe(Effect.map((exists) => (exists ? (true as const) : undefined))),
- "shell never created readiness marker",
- )
- yield* prompt.cancel(chat.id)
- const status = yield* SessionStatus.Service
- expect((yield* status.get(chat.id)).type).toBe("idle")
- const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(busy)).toBe(true)
- const exit = yield* Fiber.await(sh)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- const tool = completedTool(exit.value.parts)
- if (tool) {
- expect(tool.state.output).toContain("User aborted the command")
- }
- }
- }),
- ),
- { git: true, config: cfg },
- 30_000,
- )
- unixNoLLMServer(
- "cancel persists aborted shell result when shell ignores TERM",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const { directory: dir } = yield* TestInstance
- const afs = yield* FSUtil.Service
- const ready = path.join(dir, ".trap-ready")
- const sh = yield* prompt
- .shell({
- sessionID: chat.id,
- agent: "build",
- // Touch marker AFTER trap installs so the test waits for the actual
- // ignore-TERM state before cancelling; otherwise SIGTERM can arrive
- // before `trap` runs and the escalation path is never exercised.
- command: `trap '' TERM; touch "${ready}"; sleep 30`,
- })
- .pipe(Effect.forkChild)
- yield* Effect.gen(function* () {
- while (!(yield* afs.existsSafe(ready))) {
- yield* Effect.sleep(Duration.millis(10))
- }
- }).pipe(Effect.timeout(Duration.seconds(5)))
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(sh)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- const tool = completedTool(exit.value.parts)
- if (tool) {
- expect(tool.state.output).toContain("User aborted the command")
- }
- }
- }),
- ),
- { git: true, config: cfg },
- 30_000,
- )
- unix(
- "cancel finalizes interrupted bash tool output through normal truncation",
- () =>
- Effect.gen(function* () {
- const { dir, llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Interrupted bash truncation",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "run bash" }],
- })
- yield* llm.tool("bash", {
- command:
- 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30',
- timeout: 30_000,
- workdir: path.resolve(dir),
- })
- const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* pollWithTimeout(
- Effect.gen(function* () {
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const assistant = msgs.findLast((item) => item.info.role === "assistant")
- const tool = assistant ? toolPart(assistant.parts) : undefined
- if (tool?.state.status === "running" && tool.state.metadata?.output.includes("truncation-ready")) return true
- }),
- "timed out waiting for truncated shell output",
- )
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(run)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isFailure(exit)) return
- const tool = completedTool(exit.value.parts)
- if (!tool) return
- expect(tool.state.metadata.truncated).toBe(true)
- expect(typeof tool.state.metadata.outputPath).toBe("string")
- expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
- expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
- expect(tool.state.output).not.toContain("Tool execution aborted")
- }),
- { git: true },
- 30_000,
- )
- unixNoLLMServer(
- "cancel interrupts loop queued behind shell",
- () =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const sh = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }).pipe(Effect.forkChild)
- yield* waitForBusy(chat.id)
- const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(loop)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- const tool = completedTool(exit.value.parts)
- expect(tool?.state.output).toContain("User aborted the command")
- }
- yield* Fiber.await(sh)
- }),
- { git: true, config: cfg },
- 30_000,
- )
- unixNoLLMServer(
- "shell rejects when another shell is already running",
- () =>
- withSh(() =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const a = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* waitForBusy(chat.id)
- const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(a)
- }),
- ),
- { git: true, config: cfg },
- 30_000,
- )
- // Abort signal propagation tests for inline tool execution
- function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
- return Effect.gen(function* () {
- const ready = yield* Deferred.make<void>()
- const aborted = yield* Deferred.make<void>()
- const original = tool.execute
- tool.execute = (_args: any, ctx: any) => {
- ctx.abort.addEventListener("abort", () => succeedVoid(aborted), { once: true })
- if (ctx.abort.aborted) succeedVoid(aborted)
- succeedVoid(ready)
- return Effect.callback<never>(() => Effect.sync(() => succeedVoid(aborted)))
- }
- const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
- return { ready, aborted, restore }
- })
- }
- noLLMServer.instance(
- "interrupt propagates abort signal to read tool via file part (text/plain)",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const registry = yield* ToolRegistry.Service
- const { read } = yield* registry.named()
- const { ready, restore } = yield* hangUntilAborted(read)
- yield* restore
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Abort Test" })
- const testFile = path.join(dir, "test.txt")
- yield* writeText(testFile, "hello world")
- const fiber = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- parts: [
- { type: "text", text: "read this" },
- { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
- ],
- })
- .pipe(Effect.forkChild)
- yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for read tool to start", "10 seconds")
- yield* prompt.cancel(chat.id)
- yield* Fiber.interrupt(fiber)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isFailure(exit)).toBe(true)
- }),
- { config: cfg },
- 30_000,
- )
- noLLMServer.instance(
- "interrupt propagates abort signal to read tool via file part (directory)",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const registry = yield* ToolRegistry.Service
- const { read } = yield* registry.named()
- const { ready, restore } = yield* hangUntilAborted(read)
- yield* restore
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Abort Test" })
- const fiber = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- parts: [
- { type: "text", text: "read this" },
- { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
- ],
- })
- .pipe(Effect.forkChild)
- yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for read tool to start", "10 seconds")
- yield* prompt.cancel(chat.id)
- yield* Fiber.interrupt(fiber)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isFailure(exit)).toBe(true)
- }),
- { config: cfg },
- 30_000,
- )
- // Missing file handling
- noLLMServer.instance(
- "does not fail the prompt when a file part is missing",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const missing = path.join(dir, "does-not-exist.ts")
- const msg = yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [
- { type: "text", text: "please review @does-not-exist.ts" },
- {
- type: "file",
- mime: "text/plain",
- url: `file://${missing}`,
- filename: "does-not-exist.ts",
- },
- ],
- })
- if (msg.info.role !== "user") throw new Error("expected user message")
- const hasFailure = msg.parts.some(
- (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
- )
- expect(hasFailure).toBe(true)
- yield* sessions.remove(session.id)
- }),
- { config: cfg },
- )
- noLLMServer.instance(
- "keeps stored part order stable when file resolution is async",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const missing = path.join(dir, "still-missing.ts")
- const msg = yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [
- {
- type: "file",
- mime: "text/plain",
- url: `file://${missing}`,
- filename: "still-missing.ts",
- },
- { type: "text", text: "after-file" },
- ],
- })
- if (msg.info.role !== "user") throw new Error("expected user message")
- const stored = yield* MessageV2.get({
- sessionID: session.id,
- messageID: msg.info.id,
- })
- const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
- expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
- expect(text[1]?.includes("Read tool failed to read")).toBe(true)
- expect(text[2]).toBe("after-file")
- yield* sessions.remove(session.id)
- }),
- { config: cfg },
- )
- // Special characters in filenames
- noLLMServer.instance(
- "handles filenames with # character",
- () =>
- Effect.gen(function* () {
- const { directory: dir } = yield* TestInstance
- yield* writeText(path.join(dir, "file#name.txt"), "special content\n")
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const parts = yield* prompt.resolvePromptParts("Read @file#name.txt")
- const fileParts = parts.filter((part) => part.type === "file")
- expect(fileParts.length).toBe(1)
- expect(fileParts[0].filename).toBe("file#name.txt")
- expect(fileParts[0].url).toContain("%23")
- const decodedPath = fileURLToPath(fileParts[0].url)
- expect(decodedPath).toBe(path.join(dir, "file#name.txt"))
- const message = yield* prompt.prompt({
- sessionID: session.id,
- parts,
- noReply: true,
- })
- const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
- const textParts = stored.parts.filter((part) => part.type === "text")
- const hasContent = textParts.some((part) => part.text.includes("special content"))
- expect(hasContent).toBe(true)
- yield* sessions.remove(session.id)
- }),
- { git: true, config: cfg },
- )
- // Regression: empty assistant turn loop
- it.instance("does not loop empty assistant turns for a simple reply", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({ title: "Prompt regression" })
- yield* llm.text("packages/kirincode/src/session/processor.ts")
- const result = yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- parts: [{ type: "text", text: "Where is SessionProcessor?" }],
- })
- expect(result.info.role).toBe("assistant")
- expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
- const msgs = yield* sessions.messages({ sessionID: session.id })
- expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
- expect(yield* llm.calls).toBe(1)
- }),
- )
- it.instance("records aborted errors when prompt is cancelled mid-stream", () =>
- Effect.gen(function* () {
- const { llm } = yield* useServerConfig(providerCfg)
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({ title: "Prompt cancel regression" })
- yield* llm.hang
- const fiber = yield* prompt
- .prompt({
- sessionID: session.id,
- agent: "build",
- parts: [{ type: "text", text: "Cancel me" }],
- })
- .pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* waitForBusy(session.id)
- yield* prompt.cancel(session.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- if (exit.value.info.role === "assistant") {
- expect(exit.value.info.error?.name).toBe("MessageAbortedError")
- }
- }
- const msgs = yield* sessions.messages({ sessionID: session.id })
- const last = msgs.findLast((msg) => msg.info.role === "assistant")
- expect(last?.info.role).toBe("assistant")
- if (last?.info.role === "assistant") {
- expect(last.info.error?.name).toBe("MessageAbortedError")
- }
- }),
- )
- // Agent variant
- noLLMServer.instance(
- "applies agent variant only when using agent model",
- () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const other = yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- model: { providerID: ProviderV2.ID.make("kirincode"), modelID: ModelV2.ID.make("kimi-k2.5-free") },
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- if (other.info.role !== "user") throw new Error("expected user message")
- expect(other.info.model.variant).toBeUndefined()
- const match = yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello again" }],
- })
- if (match.info.role !== "user") throw new Error("expected user message")
- expect(match.info.model).toEqual({
- providerID: ProviderV2.ID.make("test"),
- modelID: ModelV2.ID.make("test-model"),
- variant: "xhigh",
- })
- expect(match.info.model.variant).toBe("xhigh")
- const override = yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- variant: "high",
- parts: [{ type: "text", text: "hello third" }],
- })
- if (override.info.role !== "user") throw new Error("expected user message")
- expect(override.info.model.variant).toBe("high")
- yield* sessions.remove(session.id)
- }),
- {
- config: {
- ...cfg,
- provider: {
- ...cfg.provider,
- test: {
- ...cfg.provider.test,
- models: {
- "test-model": {
- ...cfg.provider.test.models["test-model"],
- variants: { xhigh: {}, high: {} },
- },
- },
- },
- },
- agent: {
- build: {
- model: "test/test-model",
- variant: "xhigh",
- },
- },
- },
- },
- )
- // Agent / command resolution errors
- noLLMServer.instance(
- "unknown agent throws typed error",
- () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const exit = yield* prompt
- .prompt({
- sessionID: session.id,
- agent: "nonexistent-agent-xyz",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- const err = Cause.squash(exit.cause)
- expect(err).not.toBeInstanceOf(TypeError)
- expect(NamedError.Unknown.isInstance(err)).toBe(true)
- if (NamedError.Unknown.isInstance(err)) {
- expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
- }
- }
- }),
- 30_000,
- )
- noLLMServer.instance(
- "unknown agent error includes available agent names",
- () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const exit = yield* prompt
- .prompt({
- sessionID: session.id,
- agent: "nonexistent-agent-xyz",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- const err = Cause.squash(exit.cause)
- expect(NamedError.Unknown.isInstance(err)).toBe(true)
- if (NamedError.Unknown.isInstance(err)) {
- expect(err.data.message).toContain("build")
- }
- }
- }),
- 30_000,
- )
- noLLMServer.instance(
- "unknown command throws typed error with available names",
- () =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const exit = yield* prompt
- .command({
- sessionID: session.id,
- command: "nonexistent-command-xyz",
- arguments: "",
- })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- const err = Cause.squash(exit.cause)
- expect(err).not.toBeInstanceOf(TypeError)
- expect(NamedError.Unknown.isInstance(err)).toBe(true)
- if (NamedError.Unknown.isInstance(err)) {
- expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
- expect(err.data.message).toContain("init")
- }
- }
- }),
- 30_000,
- )
|