|
|
|
@@ -1,8 +1,8 @@
|
|
|
|
|
import { describe, expect, test } from "bun:test"
|
|
|
|
|
import {
|
|
|
|
|
LLMClient,
|
|
|
|
|
LLMError,
|
|
|
|
|
LLMEvent,
|
|
|
|
|
LLMRequest,
|
|
|
|
|
Message,
|
|
|
|
|
Model,
|
|
|
|
|
SystemPart,
|
|
|
|
@@ -11,10 +11,9 @@ import {
|
|
|
|
|
InvalidProviderOutputReason,
|
|
|
|
|
InvalidRequestReason,
|
|
|
|
|
RateLimitReason,
|
|
|
|
|
type LLMClientShape,
|
|
|
|
|
type LLMRequest,
|
|
|
|
|
} from "@opencode-ai/ai"
|
|
|
|
|
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
|
|
|
|
import { TestLLM } from "@opencode-ai/ai/testing"
|
|
|
|
|
import { Catalog } from "@opencode-ai/core/catalog"
|
|
|
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
|
|
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
|
|
@@ -78,77 +77,59 @@ import { agentHost, catalogHost, host } from "./plugin/host"
|
|
|
|
|
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
|
|
|
|
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
|
|
|
|
|
|
|
|
|
const requests: LLMRequest[] = []
|
|
|
|
|
let requests: LLMRequest[] = []
|
|
|
|
|
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
|
|
|
|
|
let response: LLMEvent[] = []
|
|
|
|
|
let responses: LLMEvent[][] | undefined
|
|
|
|
|
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
|
|
|
|
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
|
|
|
|
let streamGate: Deferred.Deferred<void> | undefined
|
|
|
|
|
let streamStarted: Deferred.Deferred<void> | undefined
|
|
|
|
|
let streamFailure: LLMError | undefined
|
|
|
|
|
let toolExecutionGate: Deferred.Deferred<void> | undefined
|
|
|
|
|
let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
|
|
|
|
let toolExecutionsReady = 5
|
|
|
|
|
let activeToolExecutions = 0
|
|
|
|
|
let maxActiveToolExecutions = 0
|
|
|
|
|
const client = Layer.succeed(
|
|
|
|
|
LLMClient.Service,
|
|
|
|
|
LLMClient.Service.of({
|
|
|
|
|
prepare: () => Effect.die("unused"),
|
|
|
|
|
stream: ((request: LLMRequest) => {
|
|
|
|
|
requests.push({
|
|
|
|
|
...request,
|
|
|
|
|
system: request.system.map((part) => ({
|
|
|
|
|
...part,
|
|
|
|
|
text: part.text.replace(emptyCodeMode, ""),
|
|
|
|
|
})),
|
|
|
|
|
tools: request.tools.filter((tool) => tool.name !== "execute"),
|
|
|
|
|
})
|
|
|
|
|
if (responseStreams) return responseStreams.shift() ?? Stream.empty
|
|
|
|
|
if (responseStream) {
|
|
|
|
|
const stream = responseStream
|
|
|
|
|
responseStream = undefined
|
|
|
|
|
return stream
|
|
|
|
|
}
|
|
|
|
|
const bus = streamFailure
|
|
|
|
|
? Stream.fail(streamFailure)
|
|
|
|
|
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
|
|
|
|
if (!streamGate) return bus
|
|
|
|
|
return Stream.unwrap(
|
|
|
|
|
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
|
|
|
|
Effect.andThen(Deferred.await(streamGate)),
|
|
|
|
|
Effect.as(bus),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}) as unknown as LLMClientShape["stream"],
|
|
|
|
|
generate: () => Effect.die("unused"),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const reply = {
|
|
|
|
|
stop: () => [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
],
|
|
|
|
|
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
|
|
|
|
|
textWithUsage: (text: string, id: string, inputTokens: number) =>
|
|
|
|
|
fragmentFixture("text", id, [text]).completeEvents.map((event) =>
|
|
|
|
|
LLMEvent.is.stepFinish(event)
|
|
|
|
|
? LLMEvent.stepFinish({
|
|
|
|
|
index: event.index,
|
|
|
|
|
reason: event.reason,
|
|
|
|
|
usage: { inputTokens, nonCachedInputTokens: inputTokens },
|
|
|
|
|
})
|
|
|
|
|
: event,
|
|
|
|
|
),
|
|
|
|
|
tool: (id: string, name: string, input: unknown) => [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id, name, input }),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
],
|
|
|
|
|
type ToolBarrier = {
|
|
|
|
|
readonly count: number
|
|
|
|
|
readonly started: Deferred.Deferred<void>
|
|
|
|
|
readonly release: Deferred.Deferred<void>
|
|
|
|
|
active: number
|
|
|
|
|
maxActive: number
|
|
|
|
|
}
|
|
|
|
|
let toolBarrier: ToolBarrier | undefined
|
|
|
|
|
const releaseTools = (barrier: ToolBarrier) =>
|
|
|
|
|
Effect.sync(() => {
|
|
|
|
|
if (toolBarrier === barrier) toolBarrier = undefined
|
|
|
|
|
}).pipe(Effect.andThen(Deferred.succeed(barrier.release, undefined)), Effect.asVoid)
|
|
|
|
|
const blockTools = (count = 1) =>
|
|
|
|
|
Effect.acquireRelease(
|
|
|
|
|
Effect.all({ started: Deferred.make<void>(), release: Deferred.make<void>() }).pipe(
|
|
|
|
|
Effect.map((deferreds) => {
|
|
|
|
|
const barrier = { count, ...deferreds, active: 0, maxActive: 0 }
|
|
|
|
|
toolBarrier = barrier
|
|
|
|
|
return barrier
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
releaseTools,
|
|
|
|
|
).pipe(
|
|
|
|
|
Effect.map((barrier) => ({
|
|
|
|
|
started: Deferred.await(barrier.started),
|
|
|
|
|
release: releaseTools(barrier),
|
|
|
|
|
maxActive: Effect.sync(() => barrier.maxActive),
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
const awaitToolBarrier = Effect.suspend(() => {
|
|
|
|
|
const barrier = toolBarrier
|
|
|
|
|
if (!barrier) return Effect.void
|
|
|
|
|
barrier.active++
|
|
|
|
|
barrier.maxActive = Math.max(barrier.maxActive, barrier.active)
|
|
|
|
|
return (barrier.active === barrier.count ? Deferred.succeed(barrier.started, undefined) : Effect.void).pipe(
|
|
|
|
|
Effect.andThen(Deferred.await(barrier.release)),
|
|
|
|
|
Effect.ensuring(Effect.sync(() => barrier.active--)),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
const testLLM = TestLLM.layer({
|
|
|
|
|
fallback: [],
|
|
|
|
|
transformRequest: (request) =>
|
|
|
|
|
LLMRequest.update(request, {
|
|
|
|
|
system: request.system.map((part) => ({
|
|
|
|
|
...part,
|
|
|
|
|
text: part.text.replace(emptyCodeMode, ""),
|
|
|
|
|
})),
|
|
|
|
|
tools: request.tools.filter((tool) => tool.name !== "execute"),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
const client = TestLLM.clientLayer
|
|
|
|
|
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
|
|
|
|
const defaultSystem = PROMPT_DEFAULT
|
|
|
|
|
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
|
|
|
|
@@ -221,7 +202,7 @@ test("does not apply an ineligible tier without base pricing", () => {
|
|
|
|
|
|
|
|
|
|
const authorizations: Tool.Context[] = []
|
|
|
|
|
const executions: string[] = []
|
|
|
|
|
const permissionFail = ({
|
|
|
|
|
const permissionFail = {
|
|
|
|
|
name: "permission_fail",
|
|
|
|
|
description: "Reject a permission",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
@@ -235,7 +216,7 @@ const permissionFail = ({
|
|
|
|
|
resources: ["src/index.ts"],
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const permission = Layer.succeed(
|
|
|
|
|
Permission.Service,
|
|
|
|
|
Permission.Service.of({
|
|
|
|
@@ -247,11 +228,7 @@ const permission = Layer.succeed(
|
|
|
|
|
list: () => Effect.die("unused"),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const transformTools = (
|
|
|
|
|
registry: Tool.Interface,
|
|
|
|
|
tools: Readonly<Record<string, Info>>,
|
|
|
|
|
options?: Tool.Options,
|
|
|
|
|
) =>
|
|
|
|
|
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
|
|
|
|
|
registry.transform((draft) =>
|
|
|
|
|
Object.entries(tools).forEach(([name, tool]) =>
|
|
|
|
|
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
|
|
|
|
@@ -259,9 +236,10 @@ const transformTools = (
|
|
|
|
|
)
|
|
|
|
|
const echo = Layer.effectDiscard(
|
|
|
|
|
Tool.Service.use((registry) =>
|
|
|
|
|
transformTools(registry,
|
|
|
|
|
transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
echo: ({
|
|
|
|
|
echo: {
|
|
|
|
|
name: "echo",
|
|
|
|
|
description: "Echo text",
|
|
|
|
|
input: Schema.Struct({ text: Schema.String }),
|
|
|
|
@@ -270,32 +248,24 @@ const echo = Layer.effectDiscard(
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
authorizations.push(context)
|
|
|
|
|
executions.push(text)
|
|
|
|
|
activeToolExecutions++
|
|
|
|
|
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
|
|
|
|
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
|
|
|
|
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
|
|
|
|
}
|
|
|
|
|
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
|
|
|
|
yield* awaitToolBarrier
|
|
|
|
|
return { output: { text }, content: text }
|
|
|
|
|
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
|
|
|
|
}),
|
|
|
|
|
defect: ({
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
defect: {
|
|
|
|
|
name: "defect",
|
|
|
|
|
description: "Fail unexpectedly",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({}),
|
|
|
|
|
execute: () =>
|
|
|
|
|
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
|
|
|
|
|
Effect.andThen(Effect.die("unexpected tool defect")),
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
storefail: ({
|
|
|
|
|
execute: () => awaitToolBarrier.pipe(Effect.andThen(Effect.die("unexpected tool defect"))),
|
|
|
|
|
},
|
|
|
|
|
storefail: {
|
|
|
|
|
name: "storefail",
|
|
|
|
|
description: "Produce output that cannot be persisted",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({}),
|
|
|
|
|
execute: () => Effect.succeed({ output: {} }),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
),
|
|
|
|
@@ -469,11 +439,16 @@ const it = testEffect(
|
|
|
|
|
[Config.node, config],
|
|
|
|
|
[PluginSupervisor.node, pluginSupervisor],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
).pipe(Layer.provideMerge(testLLM)),
|
|
|
|
|
)
|
|
|
|
|
const sessionID = Session.ID.make("ses_runner_test")
|
|
|
|
|
const otherSessionID = Session.ID.make("ses_runner_other")
|
|
|
|
|
const admit = (session: Session.Interface, text: string) => session.prompt({ sessionID, text, resume: false })
|
|
|
|
|
const runPrompt = Effect.fnUntraced(function* (session: Session.Interface, text: string) {
|
|
|
|
|
const message = yield* admit(session, text)
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
return message
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const insertSession = (id: Session.ID) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
@@ -506,10 +481,9 @@ const setup = Effect.gen(function* () {
|
|
|
|
|
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
|
|
|
|
discard: true,
|
|
|
|
|
})
|
|
|
|
|
requests.length = 0
|
|
|
|
|
requests = (yield* TestLLM.Service).requests
|
|
|
|
|
authorizations.length = 0
|
|
|
|
|
executions.length = 0
|
|
|
|
|
response = []
|
|
|
|
|
systemBaseline = "Initial context"
|
|
|
|
|
systemRemoved = false
|
|
|
|
|
systemUnavailable = false
|
|
|
|
@@ -518,17 +492,7 @@ const setup = Effect.gen(function* () {
|
|
|
|
|
pluginFlushHook = Effect.void
|
|
|
|
|
currentModel = model
|
|
|
|
|
skillBaselines.clear()
|
|
|
|
|
responses = undefined
|
|
|
|
|
streamFailure = undefined
|
|
|
|
|
responseStream = undefined
|
|
|
|
|
responseStreams = undefined
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
toolExecutionsStarted = undefined
|
|
|
|
|
toolExecutionsReady = 5
|
|
|
|
|
activeToolExecutions = 0
|
|
|
|
|
maxActiveToolExecutions = 0
|
|
|
|
|
toolBarrier = undefined
|
|
|
|
|
yield* agents.transform((draft) =>
|
|
|
|
|
draft.update(Agent.ID.make("build"), (agent) => {
|
|
|
|
|
agent.mode = "primary"
|
|
|
|
@@ -567,9 +531,8 @@ const rateLimited = (retryAfterMs?: number) =>
|
|
|
|
|
|
|
|
|
|
const setupOverflowRecovery = Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.text("Earlier answer", "text-earlier")
|
|
|
|
|
yield* admit(session, "Earlier question ".repeat(700))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-earlier"))
|
|
|
|
|
yield* runPrompt(session, "Earlier question ".repeat(700))
|
|
|
|
|
currentModel = recoveryModel
|
|
|
|
|
requests.length = 0
|
|
|
|
|
return session
|
|
|
|
@@ -581,6 +544,7 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
|
|
|
|
|
)
|
|
|
|
|
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
|
|
|
|
|
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
|
|
|
|
|
const messageRoles = (request: LLMRequest | undefined) => request?.messages.map((message) => message.role)
|
|
|
|
|
|
|
|
|
|
const recordedEventTypes = (id: Session.ID) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
@@ -619,6 +583,9 @@ const recordedStepSettlementEvents = (id: Session.ID, assistantMessageID: Sessio
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const recordedStepSettlementTypes = (id: Session.ID, assistantMessageID: SessionMessage.ID) =>
|
|
|
|
|
recordedStepSettlementEvents(id, assistantMessageID).pipe(Effect.map((events) => events.map((event) => event.type)))
|
|
|
|
|
|
|
|
|
|
const hostedCall = (id: string, query: string) =>
|
|
|
|
|
LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true })
|
|
|
|
|
|
|
|
|
@@ -742,7 +709,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
response = fixture.completeEvents
|
|
|
|
|
yield* TestLLM.push(fixture.completeEvents)
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -769,7 +736,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
|
|
|
|
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
|
|
|
|
|
const failure = providerUnavailable()
|
|
|
|
|
yield* admit(session, prompt)
|
|
|
|
|
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
|
|
|
|
|
yield* TestLLM.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -802,9 +769,11 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|
|
|
|
const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
|
|
|
|
|
const streamed = yield* Deferred.make<void>()
|
|
|
|
|
yield* admit(session, prompt)
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
Stream.fromIterable(fixture.partialEvents),
|
|
|
|
|
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.concat(
|
|
|
|
|
Stream.fromIterable(fixture.partialEvents),
|
|
|
|
|
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const runner = yield* SessionRunner.Service
|
|
|
|
@@ -840,7 +809,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Original message")
|
|
|
|
|
responses = [reply.tool("call-removed", "echo", { text: "blocked" })]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-removed", "echo", { text: "blocked" }))
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -872,9 +841,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
const contexts: Tool.Context[] = []
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
location_context: ({
|
|
|
|
|
location_context: {
|
|
|
|
|
name: "location_context",
|
|
|
|
|
description: "Read application context",
|
|
|
|
|
input: Schema.Struct({ query: Schema.String }),
|
|
|
|
@@ -885,12 +855,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* context.progress({ phase: "reading" })
|
|
|
|
|
return { output: { answer: query.toUpperCase() } }
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Use application context")
|
|
|
|
|
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe(
|
|
|
|
|
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
|
|
|
|
@@ -934,50 +904,42 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
const scope = yield* Scope.make()
|
|
|
|
|
const executions: string[] = []
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
{
|
|
|
|
|
reloaded: ({
|
|
|
|
|
name: "reloaded",
|
|
|
|
|
description: "Record the advertised tool",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({ value: Schema.String }),
|
|
|
|
|
execute: () =>
|
|
|
|
|
Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })),
|
|
|
|
|
}),
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
reloaded: {
|
|
|
|
|
name: "reloaded",
|
|
|
|
|
description: "Record the advertised tool",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({ value: Schema.String }),
|
|
|
|
|
execute: () =>
|
|
|
|
|
Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })),
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
.pipe(Scope.provide(scope))
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
).pipe(Scope.provide(scope))
|
|
|
|
|
yield* admit(session, "Use the reloaded tool")
|
|
|
|
|
responses = [
|
|
|
|
|
[
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
],
|
|
|
|
|
[],
|
|
|
|
|
]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-reloaded", "reloaded", {}), [])
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* Scope.close(scope, Exit.void)
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
reloaded: ({
|
|
|
|
|
reloaded: {
|
|
|
|
|
name: "reloaded",
|
|
|
|
|
description: "Record the replacement tool",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({ value: Schema.String }),
|
|
|
|
|
execute: () =>
|
|
|
|
|
Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ output: { value: "replacement" } })),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(run)
|
|
|
|
|
|
|
|
|
|
expect(executions).toEqual(["advertised"])
|
|
|
|
@@ -1019,16 +981,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const secondStarted = yield* Deferred.make<void>()
|
|
|
|
|
const releaseSecond = yield* Deferred.make<void>()
|
|
|
|
|
responseStreams = [
|
|
|
|
|
Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.fromIterable(TestLLM.tool("call-echo", "echo", { text: "background started" })),
|
|
|
|
|
Stream.unwrap(
|
|
|
|
|
Deferred.succeed(secondStarted, undefined).pipe(
|
|
|
|
|
Effect.andThen(Deferred.await(releaseSecond)),
|
|
|
|
|
Effect.as(Stream.fromIterable(reply.stop())),
|
|
|
|
|
Effect.as(Stream.fromIterable(TestLLM.stop())),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Stream.fromIterable(reply.text("Handled completion", "text-completion")),
|
|
|
|
|
]
|
|
|
|
|
Stream.fromIterable(TestLLM.text("Handled completion", "text-completion")),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Start background work")
|
|
|
|
|
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
|
|
|
|
yield* Deferred.await(secondStarted)
|
|
|
|
@@ -1038,7 +1000,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* Fiber.join(running)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(userTexts(requests[2]!)).toContain("Background work completed")
|
|
|
|
|
expect(userTexts(requests[2])).toContain("Background work completed")
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1046,9 +1008,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(requests[0]?.model).toBe(model)
|
|
|
|
@@ -1071,12 +1031,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
if (event.type === "session.instructions.updated") instructionEvents.push(event)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemBaseline = "Changed context"
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
yield* unsubscribe
|
|
|
|
|
|
|
|
|
|
expect(instructionEvents).toHaveLength(2)
|
|
|
|
@@ -1113,7 +1070,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* session.wait(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
|
|
|
|
|
expect(messageRoles(requests[0])).toEqual(["user"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1122,8 +1079,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
const { db } = yield* Database.Service
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* bus.publish(SessionEvent.Moved, {
|
|
|
|
|
sessionID,
|
|
|
|
@@ -1145,14 +1101,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const first = yield* admit(session, "First")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemBaseline = "Changed context"
|
|
|
|
|
const second = yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
const second = yield* runPrompt(session, "Second")
|
|
|
|
|
systemBaseline = "Latest context"
|
|
|
|
|
yield* admit(session, "Third")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Third")
|
|
|
|
|
|
|
|
|
|
const forked = yield* session.fork({ sessionID, messageID: second.id })
|
|
|
|
|
expect(
|
|
|
|
@@ -1201,11 +1154,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("caps nested fork instruction ancestry at the selected message", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemBaseline = "Changed context"
|
|
|
|
|
const second = yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
const second = yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
const child = yield* session.fork({ sessionID, messageID: second.id })
|
|
|
|
|
const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find(
|
|
|
|
@@ -1224,6 +1175,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
initial_values: { "test/context": Instructions.hash("Initial context") },
|
|
|
|
|
current_values: { "test/context": Instructions.hash("Initial context") },
|
|
|
|
|
})
|
|
|
|
|
return undefined
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1231,8 +1183,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const { db } = yield* Database.Service
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).run()
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
requests.length = 0
|
|
|
|
@@ -1241,7 +1192,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
|
|
|
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
|
|
|
|
|
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
|
|
|
|
expect(
|
|
|
|
|
yield* db
|
|
|
|
|
.select({ id: EventTable.id })
|
|
|
|
@@ -1259,24 +1210,21 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("keeps the initial instructions stable and derives a chronological update from values", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemBaseline = "Changed context"
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
PromptCacheDiagnostics.compare(
|
|
|
|
|
PromptCacheDiagnostics.snapshot(requests[0]!),
|
|
|
|
|
PromptCacheDiagnostics.snapshot(requests[1]!),
|
|
|
|
|
PromptCacheDiagnostics.snapshot(requests[0]),
|
|
|
|
|
PromptCacheDiagnostics.snapshot(requests[1]),
|
|
|
|
|
),
|
|
|
|
|
).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 })
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
])
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
|
|
|
|
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
|
|
|
|
const { db } = yield* Database.Service
|
|
|
|
@@ -1307,7 +1255,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Done", "text-provider-prompt")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Done", "text-provider-prompt"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
|
|
|
@@ -1330,7 +1278,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Done", "text-empty-agent-system")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Done", "text-empty-agent-system"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
|
|
|
@@ -1352,7 +1300,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Done", "text-build")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Done", "text-build"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
|
|
|
|
@@ -1376,7 +1324,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
})
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Done", "text-reviewer")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Done", "text-reviewer"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
|
|
|
|
@@ -1396,7 +1344,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Done", "text-no-system")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Done", "text-no-system"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
|
|
|
|
@@ -1422,7 +1370,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
.pipe(Effect.orDie)
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Done", "text-selected")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Done", "text-selected"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
|
|
|
|
@@ -1444,7 +1392,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Inspect files", resume: false })
|
|
|
|
|
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
|
|
|
|
|
|
|
|
|
expect(failure).toMatchObject({
|
|
|
|
@@ -1465,7 +1413,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false })
|
|
|
|
|
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
|
|
|
|
@@ -1489,22 +1437,19 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
skillBaselines.set(Agent.ID.make("build"), "Build skills")
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
skillBaselines.set(Agent.ID.make("reviewer"), "Reviewer skills")
|
|
|
|
|
yield* bus.publish(SessionEvent.AgentSelected, {
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: Agent.ID.make("reviewer"),
|
|
|
|
|
})
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context\n\nBuild skills"],
|
|
|
|
|
[defaultSystem, "Initial context\n\nBuild skills"],
|
|
|
|
|
])
|
|
|
|
|
expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
|
|
|
|
|
expect(systemTexts(requests[1])).toContainEqual(expect.stringContaining("Reviewer skills"))
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1525,9 +1470,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
})
|
|
|
|
|
.pipe(Effect.asVoid)
|
|
|
|
|
})
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context\n\nBuild skills"],
|
|
|
|
@@ -1550,9 +1493,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
})
|
|
|
|
|
.pipe(Effect.asVoid)
|
|
|
|
|
})
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
expect(requests.map((request) => request.model)).toEqual([model])
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
@@ -1563,14 +1504,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("admits removed context as a chronological System message", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemRemoved = true
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
|
|
|
|
{ type: "text", text: "System context source removed: test/context" },
|
|
|
|
|
])
|
|
|
|
@@ -1583,9 +1521,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const contextEntries = yield* InstructionEntry.Service
|
|
|
|
|
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
|
|
|
|
|
// String values render verbatim inside the initial tagged block.
|
|
|
|
|
expect(requests[0]?.system.map((part) => part.text)).toEqual([
|
|
|
|
@@ -1595,10 +1531,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
|
|
|
|
|
// Non-string JSON pretty-prints; the change narrates as a System update.
|
|
|
|
|
yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } })
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
@@ -1616,10 +1551,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
|
|
|
|
|
// Deleting the row announces removal through the stored removal text.
|
|
|
|
|
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
|
|
|
|
|
yield* admit(session, "Third")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Third")
|
|
|
|
|
|
|
|
|
|
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"])
|
|
|
|
|
expect(messageRoles(requests[2])).toEqual(["user", "system", "user", "system", "user"])
|
|
|
|
|
expect(requests[2]?.messages.at(-2)?.content).toEqual([
|
|
|
|
|
{ type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' },
|
|
|
|
|
])
|
|
|
|
@@ -1632,12 +1566,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const entries = yield* InstructionEntry.Service
|
|
|
|
|
yield* entries.put({ sessionID, key: "nullable", value: "present" })
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* entries.put({ sessionID, key: "nullable", value: null })
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
|
|
|
|
{
|
|
|
|
@@ -1673,26 +1605,22 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemBaseline = "Changed context"
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
yield* bus.publish(SessionEvent.ModelSelected, {
|
|
|
|
|
sessionID,
|
|
|
|
|
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
|
|
|
|
})
|
|
|
|
|
systemBaseline = "Replacement context"
|
|
|
|
|
yield* admit(session, "Third")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Third")
|
|
|
|
|
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
])
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
|
|
|
|
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
|
|
|
|
"user",
|
|
|
|
@@ -1702,8 +1630,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
])
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
|
|
|
|
yield* admit(session, "Fourth")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Fourth")
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1711,20 +1638,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
yield* bus.publish(SessionEvent.ModelSelected, {
|
|
|
|
|
sessionID,
|
|
|
|
|
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
|
|
|
|
})
|
|
|
|
|
systemUnavailable = true
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
systemUnavailable = false
|
|
|
|
|
systemBaseline = "Replacement context"
|
|
|
|
|
yield* admit(session, "Third")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Third")
|
|
|
|
|
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
@@ -1738,9 +1661,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
yield* bus.publish(SessionEvent.Compaction.Started, {
|
|
|
|
|
sessionID,
|
|
|
|
|
reason: "manual",
|
|
|
|
@@ -1753,18 +1674,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
recent: "",
|
|
|
|
|
})
|
|
|
|
|
systemBaseline = "Replacement context"
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
|
|
|
|
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
])
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
|
|
|
|
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Replacement context" }])
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
yield* admit(session, "Third")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Third")
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1772,17 +1691,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
currentModel = recoveryModel
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
responses = [
|
|
|
|
|
reply.tool("call-active", "echo", { text: "active" }),
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.tool("call-active", "echo", { text: "active" }),
|
|
|
|
|
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
|
|
|
|
reply.text("Steer complete", "text-steer"),
|
|
|
|
|
reply.text("Queue complete", "text-queue"),
|
|
|
|
|
]
|
|
|
|
|
TestLLM.text("Steer complete", "text-steer"),
|
|
|
|
|
TestLLM.text("Queue complete", "text-queue"),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Active work")
|
|
|
|
|
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
|
|
|
|
|
const first = yield* session.compact({ sessionID })
|
|
|
|
|
const second = yield* session.compact({ sessionID })
|
|
|
|
@@ -1802,7 +1720,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
})
|
|
|
|
|
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
|
|
|
|
|
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(active)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(4)
|
|
|
|
@@ -1823,16 +1741,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
currentModel = recoveryModel
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
responses = [
|
|
|
|
|
reply.text("Active complete", "text-active-failure"),
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.text("Active complete", "text-active-failure"),
|
|
|
|
|
[],
|
|
|
|
|
reply.text("Continued", "text-after-failure"),
|
|
|
|
|
]
|
|
|
|
|
TestLLM.text("Continued", "text-after-failure"),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Active work")
|
|
|
|
|
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
|
|
|
|
|
const compaction = yield* session.compact({ sessionID })
|
|
|
|
|
yield* session.prompt({
|
|
|
|
@@ -1841,7 +1758,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
delivery: "queue",
|
|
|
|
|
resume: false,
|
|
|
|
|
})
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(active)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
@@ -1886,12 +1803,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("manually compacts when the model has no context limit", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.text("Earlier answer", "text-manual-unknown-history")
|
|
|
|
|
yield* admit(session, "Earlier question")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history"))
|
|
|
|
|
yield* runPrompt(session, "Earlier question")
|
|
|
|
|
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = reply.text("Manual summary", "text-manual-unknown-summary")
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
|
|
|
|
|
const compaction = yield* session.compact({ sessionID })
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -1908,11 +1824,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("preserves provider errors from manual compaction", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.text("Earlier answer", "text-manual-provider-history")
|
|
|
|
|
yield* admit(session, "Earlier question")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-provider-history"))
|
|
|
|
|
yield* runPrompt(session, "Earlier question")
|
|
|
|
|
|
|
|
|
|
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
|
|
|
|
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
|
|
|
|
const compaction = yield* session.compact({ sessionID })
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -1927,11 +1842,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("preserves typed provider failures from manual compaction", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.text("Earlier answer", "text-manual-failure-history")
|
|
|
|
|
yield* admit(session, "Earlier question")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
|
|
|
|
|
yield* runPrompt(session, "Earlier question")
|
|
|
|
|
|
|
|
|
|
responseStream = Stream.fail(providerUnavailable())
|
|
|
|
|
yield* TestLLM.push(Stream.fail(providerUnavailable()))
|
|
|
|
|
const compaction = yield* session.compact({ sessionID })
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -1946,15 +1860,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("records cancelled manual compaction without surfacing an internal failure", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.text("Earlier answer", "text-manual-interrupt-history")
|
|
|
|
|
yield* admit(session, "Earlier question")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
|
|
|
|
|
yield* runPrompt(session, "Earlier question")
|
|
|
|
|
|
|
|
|
|
const streamed = yield* Deferred.make<void>()
|
|
|
|
|
const partial = fragmentFixture("text", "text-manual-interrupt-summary", ["Partial summary"])
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
Stream.fromIterable(partial.partialEvents),
|
|
|
|
|
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.concat(
|
|
|
|
|
Stream.fromIterable(partial.partialEvents),
|
|
|
|
|
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
const compaction = yield* session.compact({ sessionID })
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
@@ -1975,9 +1890,8 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.text("Earlier answer", "text-manual-resolution-history")
|
|
|
|
|
yield* admit(session, "Earlier question")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-resolution-history"))
|
|
|
|
|
yield* runPrompt(session, "Earlier question")
|
|
|
|
|
|
|
|
|
|
const compaction = yield* session.compact({ sessionID })
|
|
|
|
|
modelResolveHook = Effect.die("model resolution failed")
|
|
|
|
@@ -2001,18 +1915,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.textWithUsage("Earlier answer", "text-first", 3_950)
|
|
|
|
|
yield* admit(session, "Earlier question ".repeat(180))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950))
|
|
|
|
|
yield* runPrompt(session, "Earlier question ".repeat(180))
|
|
|
|
|
|
|
|
|
|
currentModel = compactModel
|
|
|
|
|
requests.length = 0
|
|
|
|
|
responses = [
|
|
|
|
|
reply.text("## Objective\n- Preserve the task", "text-summary"),
|
|
|
|
|
reply.textWithUsage("Continued", "text-final", 3_950),
|
|
|
|
|
]
|
|
|
|
|
yield* admit(session, "Recent exact request ".repeat(180))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.text("## Objective\n- Preserve the task", "text-summary"),
|
|
|
|
|
TestLLM.textWithUsage("Continued", "text-final", 3_950),
|
|
|
|
|
)
|
|
|
|
|
yield* runPrompt(session, "Recent exact request ".repeat(180))
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[0])[0]).toContain("## Objective")
|
|
|
|
@@ -2029,12 +1941,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
|
|
|
|
|
requests.length = 0
|
|
|
|
|
executions.length = 0
|
|
|
|
|
responses = [
|
|
|
|
|
reply.text("## Objective\n- Preserve the updated task", "text-summary-2"),
|
|
|
|
|
reply.text("Continued again", "text-final-2"),
|
|
|
|
|
]
|
|
|
|
|
yield* admit(session, "Newest exact request ".repeat(180))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.text("## Objective\n- Preserve the updated task", "text-summary-2"),
|
|
|
|
|
TestLLM.text("Continued again", "text-final-2"),
|
|
|
|
|
)
|
|
|
|
|
yield* runPrompt(session, "Newest exact request ".repeat(180))
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[0])[0]).toContain(
|
|
|
|
@@ -2052,14 +1963,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
currentModel = fullOutputModel
|
|
|
|
|
response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500)
|
|
|
|
|
yield* admit(session, "Earlier question")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500))
|
|
|
|
|
yield* runPrompt(session, "Earlier question")
|
|
|
|
|
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = reply.text("Continued", "text-full-output-final")
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Continued", "text-full-output-final"))
|
|
|
|
|
yield* runPrompt(session, "Continue")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(userTexts(requests[0])).toContain("Continue")
|
|
|
|
@@ -2070,16 +1979,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("stops after required automatic compaction fails", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = reply.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950)
|
|
|
|
|
yield* admit(session, "Earlier question ".repeat(180))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950))
|
|
|
|
|
yield* runPrompt(session, "Earlier question ".repeat(180))
|
|
|
|
|
|
|
|
|
|
currentModel = compactModel
|
|
|
|
|
requests.length = 0
|
|
|
|
|
responses = [
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
|
|
|
|
reply.text("Must not run", "text-after-failed-compaction"),
|
|
|
|
|
]
|
|
|
|
|
TestLLM.text("Must not run", "text-after-failed-compaction"),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Recent exact request ".repeat(180))
|
|
|
|
|
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
|
|
|
|
|
|
|
|
|
@@ -2099,16 +2007,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("forces one compaction and retries after provider context overflow", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setupOverflowRecovery
|
|
|
|
|
responses = [
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
[
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
|
|
|
],
|
|
|
|
|
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
|
|
|
|
reply.text("Recovered", "text-final"),
|
|
|
|
|
]
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
TestLLM.text("## Objective\n- Recover overflow", "text-summary"),
|
|
|
|
|
TestLLM.text("Recovered", "text-final"),
|
|
|
|
|
)
|
|
|
|
|
yield* runPrompt(session, "Continue")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(userTexts(requests[1])[0]).toContain("## Objective")
|
|
|
|
@@ -2129,13 +2036,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setupOverflowRecovery
|
|
|
|
|
currentModel = model
|
|
|
|
|
responses = [
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
|
|
|
|
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
|
|
|
|
reply.text("Recovered", "text-final-unknown-limit"),
|
|
|
|
|
]
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
TestLLM.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
|
|
|
|
TestLLM.text("Recovered", "text-final-unknown-limit"),
|
|
|
|
|
)
|
|
|
|
|
yield* runPrompt(session, "Continue")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -2149,13 +2055,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setupOverflowRecovery
|
|
|
|
|
currentModel = undersizedContextModel
|
|
|
|
|
responses = [
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
|
|
|
|
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
|
|
|
|
reply.text("Recovered", "text-final-undersized-limit"),
|
|
|
|
|
]
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
|
|
|
|
TestLLM.text("Recovered", "text-final-undersized-limit"),
|
|
|
|
|
)
|
|
|
|
|
yield* runPrompt(session, "Continue")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -2172,7 +2077,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
|
|
|
]
|
|
|
|
|
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
|
|
|
|
yield* TestLLM.push(overflow(), TestLLM.text("## Objective\n- Recover once", "text-summary"), overflow())
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
|
|
|
|
|
|
|
|
@@ -2187,22 +2092,23 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("recovers once from a raw context overflow failure", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setupOverflowRecovery
|
|
|
|
|
responseStream = Stream.fail(
|
|
|
|
|
new LLMError({
|
|
|
|
|
module: "test",
|
|
|
|
|
method: "stream",
|
|
|
|
|
reason: new InvalidRequestReason({
|
|
|
|
|
message: "prompt too long",
|
|
|
|
|
classification: "context-overflow",
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.fail(
|
|
|
|
|
new LLMError({
|
|
|
|
|
module: "test",
|
|
|
|
|
method: "stream",
|
|
|
|
|
reason: new InvalidRequestReason({
|
|
|
|
|
message: "prompt too long",
|
|
|
|
|
classification: "context-overflow",
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
responses = [
|
|
|
|
|
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
|
|
|
|
reply.text("Recovered", "text-final"),
|
|
|
|
|
]
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.text("## Objective\n- Recover raw overflow", "text-summary"),
|
|
|
|
|
TestLLM.text("Recovered", "text-final"),
|
|
|
|
|
)
|
|
|
|
|
yield* runPrompt(session, "Continue")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -2215,10 +2121,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setupOverflowRecovery
|
|
|
|
|
responses = [
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
|
|
|
|
[LLMEvent.providerError({ message: "summary unavailable" })],
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
|
|
|
|
|
|
|
|
@@ -2243,26 +2149,29 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setupOverflowRecovery
|
|
|
|
|
responses = [
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
|
|
|
|
reply.text("## Objective\n- Interrupted", "text-summary"),
|
|
|
|
|
]
|
|
|
|
|
const firstGate = yield* Deferred.make<void>()
|
|
|
|
|
const summaryGate = yield* Deferred.make<void>()
|
|
|
|
|
streamGate = firstGate
|
|
|
|
|
TestLLM.text("## Objective\n- Interrupted", "text-summary"),
|
|
|
|
|
)
|
|
|
|
|
const first = yield* TestLLM.gate
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 1) yield* Effect.yieldNow
|
|
|
|
|
streamGate = summaryGate
|
|
|
|
|
yield* Deferred.succeed(firstGate, undefined)
|
|
|
|
|
while (requests.length < 2) yield* Effect.yieldNow
|
|
|
|
|
yield* first.started
|
|
|
|
|
|
|
|
|
|
const summary = yield* TestLLM.gate
|
|
|
|
|
yield* first.release
|
|
|
|
|
yield* summary.started
|
|
|
|
|
|
|
|
|
|
yield* session.interrupt(sessionID)
|
|
|
|
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
const exit = yield* Fiber.await(run)
|
|
|
|
|
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
|
|
|
|
expect(yield* session.context(sessionID)).toContainEqual(
|
|
|
|
|
expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
type: "compaction",
|
|
|
|
|
status: "failed",
|
|
|
|
|
reason: "auto",
|
|
|
|
|
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
@@ -2271,12 +2180,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
yield* admit(session, "First")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "First")
|
|
|
|
|
systemBaseline = "Changed context"
|
|
|
|
|
yield* admit(session, "Second")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Second")
|
|
|
|
|
yield* bus.publish(SessionEvent.Compaction.Started, {
|
|
|
|
|
sessionID,
|
|
|
|
|
reason: "manual",
|
|
|
|
@@ -2289,8 +2195,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
recent: "",
|
|
|
|
|
})
|
|
|
|
|
systemUnavailable = true
|
|
|
|
|
yield* admit(session, "Third")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Third")
|
|
|
|
|
|
|
|
|
|
// Compaction already moved current values into the new epoch before the unavailable read.
|
|
|
|
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
|
|
|
@@ -2303,50 +2208,49 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Use tools")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.reasoningStart({ id: "reasoning-1" }),
|
|
|
|
|
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }),
|
|
|
|
|
LLMEvent.reasoningEnd({ id: "reasoning-1" }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-error", name: "write" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }),
|
|
|
|
|
LLMEvent.toolInputEnd({ id: "call-error", name: "write" }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
|
|
|
|
|
LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }),
|
|
|
|
|
LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }),
|
|
|
|
|
LLMEvent.toolCall({
|
|
|
|
|
id: "call-provider",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
input: { query: "hello" },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { source: "provider" } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.toolResult({
|
|
|
|
|
id: "call-provider",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
result: {
|
|
|
|
|
type: "content",
|
|
|
|
|
value: [
|
|
|
|
|
{ type: "text", text: "Hello" },
|
|
|
|
|
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
|
|
|
|
|
],
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.complete(
|
|
|
|
|
{
|
|
|
|
|
reason: { normalized: "tool-calls" },
|
|
|
|
|
usage: {
|
|
|
|
|
inputTokens: 10,
|
|
|
|
|
nonCachedInputTokens: 8,
|
|
|
|
|
outputTokens: 4,
|
|
|
|
|
reasoningTokens: 1,
|
|
|
|
|
cacheReadInputTokens: 2,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { source: "provider" } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({
|
|
|
|
|
index: 0,
|
|
|
|
|
reason: { normalized: "tool-calls" },
|
|
|
|
|
usage: {
|
|
|
|
|
inputTokens: 10,
|
|
|
|
|
nonCachedInputTokens: 8,
|
|
|
|
|
outputTokens: 4,
|
|
|
|
|
reasoningTokens: 1,
|
|
|
|
|
cacheReadInputTokens: 2,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
]
|
|
|
|
|
LLMEvent.reasoningStart({ id: "reasoning-1" }),
|
|
|
|
|
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }),
|
|
|
|
|
LLMEvent.reasoningEnd({ id: "reasoning-1" }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-error", name: "write" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }),
|
|
|
|
|
LLMEvent.toolInputEnd({ id: "call-error", name: "write" }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
|
|
|
|
|
LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }),
|
|
|
|
|
LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }),
|
|
|
|
|
LLMEvent.toolCall({
|
|
|
|
|
id: "call-provider",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
input: { query: "hello" },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { source: "provider" } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.toolResult({
|
|
|
|
|
id: "call-provider",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
result: {
|
|
|
|
|
type: "content",
|
|
|
|
|
value: [
|
|
|
|
|
{ type: "text", text: "Hello" },
|
|
|
|
|
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { source: "provider" } },
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -2398,12 +2302,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Echo this")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.text("Done", "text-final"))
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
|
|
|
|
|
expect(executions).toEqual(["hello"])
|
|
|
|
|
const context = yield* session.context(sessionID)
|
|
|
|
@@ -2428,7 +2332,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
|
|
|
|
|
])
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.called.1",
|
|
|
|
|
"session.tool.success.2",
|
|
|
|
@@ -2443,18 +2347,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
yield* admit(session, "Echo this")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()]
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsReady = 1
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop())
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
const run = yield* Effect.forkChild(session.resume(sessionID))
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
yield* bus.publish(SessionEvent.ModelSelected, {
|
|
|
|
|
sessionID,
|
|
|
|
|
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
|
|
|
|
})
|
|
|
|
|
systemBaseline = "Replacement context"
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.release
|
|
|
|
|
yield* Fiber.join(run)
|
|
|
|
|
|
|
|
|
|
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
|
|
|
|
@@ -2462,7 +2364,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
[defaultSystem, "Initial context"],
|
|
|
|
|
])
|
|
|
|
|
expect(systemTexts(requests[1]!)).toContain("Replacement context")
|
|
|
|
|
expect(systemTexts(requests[1])).toContain("Replacement context")
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2471,32 +2373,31 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Think first")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
|
|
|
|
|
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
|
|
|
|
LLMEvent.reasoningEnd({
|
|
|
|
|
id: "reasoning-anthropic",
|
|
|
|
|
providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.reasoningStart({
|
|
|
|
|
id: "reasoning-openai",
|
|
|
|
|
providerMetadata: {
|
|
|
|
|
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
|
|
|
|
anthropic: { ignored: true },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
|
|
|
|
LLMEvent.reasoningEnd({
|
|
|
|
|
id: "reasoning-openai",
|
|
|
|
|
providerMetadata: {
|
|
|
|
|
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
|
|
|
|
anthropic: { ignored: true },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.stop(
|
|
|
|
|
LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
|
|
|
|
|
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
|
|
|
|
LLMEvent.reasoningEnd({
|
|
|
|
|
id: "reasoning-anthropic",
|
|
|
|
|
providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.reasoningStart({
|
|
|
|
|
id: "reasoning-openai",
|
|
|
|
|
providerMetadata: {
|
|
|
|
|
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
|
|
|
|
anthropic: { ignored: true },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
|
|
|
|
LLMEvent.reasoningEnd({
|
|
|
|
|
id: "reasoning-openai",
|
|
|
|
|
providerMetadata: {
|
|
|
|
|
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
|
|
|
|
anthropic: { ignored: true },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
|
|
|
|
@@ -2520,7 +2421,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests[1]?.messages[1]?.content).toEqual([
|
|
|
|
@@ -2543,17 +2444,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Check first")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
|
|
|
|
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
|
|
|
|
LLMEvent.textEnd({
|
|
|
|
|
id: "commentary",
|
|
|
|
|
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.stop(
|
|
|
|
|
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
|
|
|
|
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
|
|
|
|
LLMEvent.textEnd({
|
|
|
|
|
id: "commentary",
|
|
|
|
|
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
|
|
|
|
@@ -2566,7 +2466,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests[1]?.messages[1]?.content).toEqual([
|
|
|
|
@@ -2584,33 +2484,32 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Search first")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({
|
|
|
|
|
id: "hosted-search",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
input: { query: "Effect" },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.toolResult({
|
|
|
|
|
id: "hosted-search",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
result: { type: "json", value: [{ title: "Effect" }] },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.stop(
|
|
|
|
|
LLMEvent.toolCall({
|
|
|
|
|
id: "hosted-search",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
input: { query: "Effect" },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.toolResult({
|
|
|
|
|
id: "hosted-search",
|
|
|
|
|
name: "web_search",
|
|
|
|
|
result: { type: "json", value: [{ title: "Effect" }] },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "user"])
|
|
|
|
|
expect(requests[1]?.messages[1]?.content).toMatchObject([
|
|
|
|
|
{
|
|
|
|
|
type: "tool-call",
|
|
|
|
@@ -2638,8 +2537,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Echo five times")
|
|
|
|
|
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
const tools = yield* blockTools(5)
|
|
|
|
|
const providerGate = yield* Deferred.make<void>()
|
|
|
|
|
const initial = Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
@@ -2651,16 +2549,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
])
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
initial,
|
|
|
|
|
Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.concat(initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final))),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
|
|
|
|
|
expect(executions).toHaveLength(5)
|
|
|
|
|
expect(maxActiveToolExecutions).toBe(5)
|
|
|
|
|
expect(yield* tools.maxActive).toBe(5)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Echo five times" },
|
|
|
|
|
{
|
|
|
|
@@ -2677,13 +2574,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.release
|
|
|
|
|
yield* Fiber.join(run)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
toolExecutionsStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(executions).toHaveLength(5)
|
|
|
|
|
expect(maxActiveToolExecutions).toBe(5)
|
|
|
|
|
expect(yield* tools.maxActive).toBe(5)
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
@@ -2693,17 +2588,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Echo twice")
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
reply.tool("tool_0", "echo", { text: "first" }),
|
|
|
|
|
reply.tool("tool_0", "echo", { text: "second" }),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.tool("tool_0", "echo", { text: "first" }),
|
|
|
|
|
TestLLM.tool("tool_0", "echo", { text: "second" }),
|
|
|
|
|
[],
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(executions).toEqual(["first", "second"])
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
const expected = [
|
|
|
|
|
{ type: "user", text: "Echo twice" },
|
|
|
|
|
{
|
|
|
|
|
type: "assistant",
|
|
|
|
@@ -2725,33 +2618,14 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
]
|
|
|
|
|
expect(executions).toEqual(["first", "second"])
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject(expected)
|
|
|
|
|
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Echo twice" },
|
|
|
|
|
{
|
|
|
|
|
type: "assistant",
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "tool",
|
|
|
|
|
id: "tool_0",
|
|
|
|
|
state: { status: "completed", content: [{ type: "text", text: "first" }] },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: "assistant",
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "tool",
|
|
|
|
|
id: "tool_0",
|
|
|
|
|
state: { status: "completed", content: [{ type: "text", text: "second" }] },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject(expected)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2760,21 +2634,18 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Run once")
|
|
|
|
|
|
|
|
|
|
response = reply.text("Once", "text-once")
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Once", "text-once"))
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
yield* Fiber.join(second)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -2789,22 +2660,19 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Start working")
|
|
|
|
|
|
|
|
|
|
responses = [reply.stop(), reply.stop()]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Change direction" })
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start working", "Change direction"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start working", "Change direction"])
|
|
|
|
|
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
|
|
|
|
"user",
|
|
|
|
|
"assistant",
|
|
|
|
@@ -2819,26 +2687,23 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Start working")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop(), TestLLM.stop())
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({
|
|
|
|
|
sessionID,
|
|
|
|
|
text: "Wait until continuation ends",
|
|
|
|
|
delivery: "queue",
|
|
|
|
|
})
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[2])).toEqual(["Start working", "Wait until continuation ends"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2848,12 +2713,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const { db } = yield* Database.Service
|
|
|
|
|
yield* admit(session, "Interrupt current work")
|
|
|
|
|
|
|
|
|
|
responses = [[], reply.stop()]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push([], TestLLM.stop())
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({
|
|
|
|
|
sessionID,
|
|
|
|
|
text: "Run after interrupt",
|
|
|
|
@@ -2864,15 +2728,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
|
|
|
|
|
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 2) yield* Effect.yieldNow
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(resumed)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Run after interrupt"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Interrupt current work"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Interrupt current work", "Run after interrupt"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2882,12 +2744,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const { db } = yield* Database.Service
|
|
|
|
|
yield* admit(session, "Interrupt current work")
|
|
|
|
|
|
|
|
|
|
responses = [[], reply.stop()]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push([], TestLLM.stop())
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({
|
|
|
|
|
sessionID,
|
|
|
|
|
text: "Steer after interrupt",
|
|
|
|
@@ -2898,15 +2759,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
|
|
|
|
|
|
|
|
|
|
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 2) yield* Effect.yieldNow
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(resumed)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Steer after interrupt"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Interrupt current work"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Interrupt current work", "Steer after interrupt"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2915,23 +2774,20 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Start working")
|
|
|
|
|
|
|
|
|
|
responses = [reply.stop(), reply.stop(), reply.stop()]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
|
|
|
|
|
expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start working", "Queue first"])
|
|
|
|
|
expect(userTexts(requests[2])).toEqual(["Start working", "Queue first", "Queue second"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2946,13 +2802,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
resume: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
responses = [reply.stop(), reply.stop()]
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Start steering"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Start steering"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start steering", "Queue for later"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -2961,39 +2817,36 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Start working")
|
|
|
|
|
|
|
|
|
|
responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()]
|
|
|
|
|
const firstGate = yield* Deferred.make<void>()
|
|
|
|
|
const secondGate = yield* Deferred.make<void>()
|
|
|
|
|
streamGate = firstGate
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
|
|
|
|
|
const firstStream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 1) yield* Effect.yieldNow
|
|
|
|
|
yield* firstStream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
|
|
|
|
|
streamGate = secondGate
|
|
|
|
|
yield* Deferred.succeed(firstGate, undefined)
|
|
|
|
|
while (requests.length < 2) yield* Effect.yieldNow
|
|
|
|
|
const secondStream = yield* TestLLM.gate
|
|
|
|
|
yield* firstStream.release
|
|
|
|
|
yield* secondStream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Steer before next queued input" })
|
|
|
|
|
yield* session.prompt({
|
|
|
|
|
sessionID,
|
|
|
|
|
text: "Also steer before next queued input",
|
|
|
|
|
})
|
|
|
|
|
yield* session.synthetic({ sessionID, text: "Background completion before next queued input" })
|
|
|
|
|
yield* Deferred.succeed(secondGate, undefined)
|
|
|
|
|
yield* secondStream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(4)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
|
|
|
|
|
expect(userTexts(requests[2]!)).toEqual([
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Start working"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start working", "Queue first"])
|
|
|
|
|
expect(userTexts(requests[2])).toEqual([
|
|
|
|
|
"Start working",
|
|
|
|
|
"Queue first",
|
|
|
|
|
"Steer before next queued input",
|
|
|
|
|
"Also steer before next queued input",
|
|
|
|
|
"Background completion before next queued input",
|
|
|
|
|
])
|
|
|
|
|
expect(userTexts(requests[3]!)).toEqual([
|
|
|
|
|
expect(userTexts(requests[3])).toEqual([
|
|
|
|
|
"Start working",
|
|
|
|
|
"Queue first",
|
|
|
|
|
"Steer before next queued input",
|
|
|
|
@@ -3009,22 +2862,19 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Start working")
|
|
|
|
|
|
|
|
|
|
responses = [reply.stop(), reply.stop()]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "First steer" })
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Second steer" })
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start working", "First steer", "Second steer"])
|
|
|
|
|
yield* (yield* SessionExecution.Service).wake(sessionID)
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
@@ -3036,23 +2886,21 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Start working")
|
|
|
|
|
|
|
|
|
|
streamFailure = invalidRequest()
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
const failure = invalidRequest()
|
|
|
|
|
yield* TestLLM.push(Stream.fail(failure))
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Recover with this" })
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
|
|
|
|
|
streamFailure = undefined
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.wait(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
|
|
|
|
|
expect(userTexts(requests[1])).toEqual(["Start working", "Recover with this"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -3089,11 +2937,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
executed: false,
|
|
|
|
|
})
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Recover interrupted tool" },
|
|
|
|
|
{
|
|
|
|
@@ -3147,11 +2995,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
state: { itemId: "call-hosted-interrupted" },
|
|
|
|
|
})
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant"])
|
|
|
|
|
expect(messageRoles(requests[0])).toEqual(["user", "assistant"])
|
|
|
|
|
expect(requests[0]?.messages[1]?.content).toMatchObject([
|
|
|
|
|
{
|
|
|
|
|
type: "tool-call",
|
|
|
|
@@ -3184,11 +3032,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
name: "echo",
|
|
|
|
|
})
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Recover interrupted tool input" },
|
|
|
|
|
{ type: "assistant", content: [{ type: "tool", id: "call-pending-interrupted", state: { status: "error" } }] },
|
|
|
|
@@ -3206,11 +3054,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
resume: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
yield* (yield* SessionExecution.Service).wake(sessionID)
|
|
|
|
|
while (requests.length === 0) yield* Effect.yieldNow
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* stream.release
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Wait in queue"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Wait in queue"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -3226,12 +3076,14 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
|
|
|
|
fail = false
|
|
|
|
|
requests.length = 0
|
|
|
|
|
response = reply.stop()
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop())
|
|
|
|
|
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
yield* (yield* SessionExecution.Service).wake(sessionID)
|
|
|
|
|
while (requests.length === 0) yield* Effect.yieldNow
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* stream.release
|
|
|
|
|
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Recover promoted input"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -3244,21 +3096,17 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
? Effect.die("fail after prompt promotion commits")
|
|
|
|
|
: Effect.void,
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Run committed promotion")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Run committed promotion")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"])
|
|
|
|
|
expect(userTexts(requests[0])).toEqual(["Run committed promotion"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it.effect("adds session correlation headers to model requests", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Run correlated request")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Run correlated request")
|
|
|
|
|
|
|
|
|
|
expect(requests[0]?.http?.headers).toEqual({
|
|
|
|
|
"x-session-affinity": sessionID,
|
|
|
|
@@ -3282,9 +3130,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
.where(eq(SessionTable.id, sessionID))
|
|
|
|
|
.run()
|
|
|
|
|
.pipe(Effect.orDie)
|
|
|
|
|
yield* admit(session, "Run child request")
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Run child request")
|
|
|
|
|
|
|
|
|
|
expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
|
|
|
|
|
}),
|
|
|
|
@@ -3301,25 +3147,21 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
resume: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* stream.started
|
|
|
|
|
const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
|
|
|
|
|
sessionID,
|
|
|
|
|
otherSessionID,
|
|
|
|
|
])
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(first)
|
|
|
|
|
yield* Fiber.join(second)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -3356,23 +3198,20 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Retry after failure")
|
|
|
|
|
|
|
|
|
|
streamFailure = invalidRequest()
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(Stream.fail(invalidRequest()))
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
|
|
|
|
|
expect(secondExit).toEqual(firstExit)
|
|
|
|
|
|
|
|
|
|
streamFailure = undefined
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
}),
|
|
|
|
@@ -3383,7 +3222,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Call missing")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-missing", "missing", {}), TestLLM.text("Recovered", "text-after-error"))
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
@@ -3412,12 +3251,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Call defect")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-defect", "defect", {}), TestLLM.text("Recovered", "text-after-defect"))
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
const context = yield* session.context(sessionID)
|
|
|
|
|
expect(context).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Call defect" },
|
|
|
|
@@ -3437,7 +3276,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
|
|
|
|
])
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.called.1",
|
|
|
|
|
"session.tool.failed.2",
|
|
|
|
@@ -3450,9 +3289,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
blocked: ({
|
|
|
|
|
blocked: {
|
|
|
|
|
name: "blocked",
|
|
|
|
|
description: "Fail because policy blocked execution",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
@@ -3461,13 +3301,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.fail(new Permission.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
|
|
|
|
|
Effect.mapError(() => new Tool.Error({ message: "Permission blocked" })),
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Call blocked")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-blocked", "blocked", {}), TestLLM.stop())
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -3489,21 +3329,22 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
declined: ({
|
|
|
|
|
declined: {
|
|
|
|
|
name: "declined",
|
|
|
|
|
description: "Fail because the user declined approval",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({}),
|
|
|
|
|
execute: () => Effect.die(new Permission.DeclinedError()),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Call declined")
|
|
|
|
|
|
|
|
|
|
response = reply.tool("call-declined", "declined", {})
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-declined", "declined", {}))
|
|
|
|
|
|
|
|
|
|
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
|
|
|
|
|
|
|
|
@@ -3530,9 +3371,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
corrected: ({
|
|
|
|
|
corrected: {
|
|
|
|
|
name: "corrected",
|
|
|
|
|
description: "Fail with user correction feedback",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
@@ -3541,13 +3383,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.fail(new Permission.CorrectedError({ feedback: "Use another tool" })).pipe(
|
|
|
|
|
Effect.mapError(() => new Tool.Error({ message: "Use another tool" })),
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Call corrected")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-corrected", "corrected", {}), TestLLM.stop())
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -3571,10 +3413,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
yield* transformTools(registry, { permissionfail: permissionFail }, { codemode: false })
|
|
|
|
|
yield* admit(session, "Reject permission")
|
|
|
|
|
responses = [
|
|
|
|
|
reply.tool("call-permission", "permissionfail", {}),
|
|
|
|
|
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })],
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-permission", "permissionfail", {}), [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -3607,21 +3449,22 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
const registry = yield* Tool.Service
|
|
|
|
|
yield* transformTools(registry,
|
|
|
|
|
yield* transformTools(
|
|
|
|
|
registry,
|
|
|
|
|
{
|
|
|
|
|
question: ({
|
|
|
|
|
question: {
|
|
|
|
|
name: "question",
|
|
|
|
|
description: "Ask the user",
|
|
|
|
|
input: Schema.Struct({}),
|
|
|
|
|
output: Schema.Struct({}),
|
|
|
|
|
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ codemode: false },
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Ask then stop")
|
|
|
|
|
|
|
|
|
|
responses = [reply.tool("call-question", "question", {}), []]
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-question", "question", {}), [])
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
|
|
|
|
|
const exit = yield* Fiber.join(run)
|
|
|
|
@@ -3650,21 +3493,19 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Settle before failing")
|
|
|
|
|
const failure = providerUnavailable()
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
Stream.fromIterable([
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.failAfter(
|
|
|
|
|
failure,
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-before-failure", name: "echo", input: { text: "settle" } }),
|
|
|
|
|
]),
|
|
|
|
|
Stream.fail(failure),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (executions.length === 0) yield* Effect.yieldNow
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
yield* tools.release
|
|
|
|
|
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
|
|
|
|
|
const context = yield* session.context(sessionID)
|
|
|
|
|
expect(context).toMatchObject([
|
|
|
|
@@ -3681,7 +3522,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.called.1",
|
|
|
|
|
"session.tool.success.2",
|
|
|
|
@@ -3694,19 +3535,17 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Interrupt blocked tool")
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
Stream.fromIterable([
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.hangAfter(
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }),
|
|
|
|
|
]),
|
|
|
|
|
Stream.never,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (executions.length === 0) yield* Effect.yieldNow
|
|
|
|
|
yield* tools.started
|
|
|
|
|
yield* session.interrupt(sessionID)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
|
|
|
|
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
|
|
|
|
yield* session.interrupt(sessionID)
|
|
|
|
@@ -3725,7 +3564,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.called.1",
|
|
|
|
|
"session.tool.failed.2",
|
|
|
|
@@ -3739,10 +3578,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
{ type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
|
|
|
|
|
])
|
|
|
|
|
requests.length = 0
|
|
|
|
|
responseStream = undefined
|
|
|
|
|
response = []
|
|
|
|
|
yield* TestLLM.push([])
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -3750,15 +3588,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Interrupt provider")
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.interrupt(sessionID)
|
|
|
|
|
const exit = yield* Fiber.await(run)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
@@ -3775,16 +3610,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Interrupt tool settlement")
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsReady = 1
|
|
|
|
|
response = reply.tool("call-await-interrupt", "echo", { text: "blocked" })
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" }))
|
|
|
|
|
|
|
|
|
|
const runner = yield* SessionRunner.Service
|
|
|
|
|
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
yield* Fiber.interrupt(run)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
|
|
|
|
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -3819,10 +3651,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Finish at the limit")
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
reply.tool("call-terminal", "echo", { text: "done" }),
|
|
|
|
|
reply.tool("call-forbidden", "echo", { text: "forbidden" }),
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.tool("call-terminal", "echo", { text: "done" }),
|
|
|
|
|
TestLLM.tool("call-forbidden", "echo", { text: "forbidden" }),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
|
|
|
|
@@ -3855,21 +3687,18 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Start work")
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
reply.tool("call-before-steer", "echo", { text: "before" }),
|
|
|
|
|
reply.tool("call-after-steer", "echo", { text: "after" }),
|
|
|
|
|
reply.stop(),
|
|
|
|
|
]
|
|
|
|
|
streamGate = yield* Deferred.make<void>()
|
|
|
|
|
streamStarted = yield* Deferred.make<void>()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.tool("call-before-steer", "echo", { text: "before" }),
|
|
|
|
|
TestLLM.tool("call-after-steer", "echo", { text: "after" }),
|
|
|
|
|
TestLLM.stop(),
|
|
|
|
|
)
|
|
|
|
|
const stream = yield* TestLLM.gate
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(streamStarted)
|
|
|
|
|
yield* stream.started
|
|
|
|
|
yield* session.prompt({ sessionID, text: "Change direction" })
|
|
|
|
|
yield* Deferred.succeed(streamGate, undefined)
|
|
|
|
|
yield* stream.release
|
|
|
|
|
yield* Fiber.join(run)
|
|
|
|
|
streamGate = undefined
|
|
|
|
|
streamStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(3)
|
|
|
|
|
expect(requests[1]?.toolChoice).toBeUndefined()
|
|
|
|
@@ -3882,11 +3711,12 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("projects provider errors as terminal assistant step failures", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail durably")
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.providerError({ message: "Provider unavailable" }),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
|
|
|
|
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
expect((yield* runPrompt(session, "Fail durably").pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -3899,11 +3729,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("projects provider errors emitted before assistant step start", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail before step")
|
|
|
|
|
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable" })])
|
|
|
|
|
|
|
|
|
|
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
|
|
|
|
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
expect((yield* runPrompt(session, "Fail before step").pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -3916,20 +3744,20 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("projects content-filter finishes as visible terminal failures", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Blocked response")
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "partial" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
|
|
|
|
LLMEvent.stepFinish({
|
|
|
|
|
index: 0,
|
|
|
|
|
reason: { normalized: "content-filter" },
|
|
|
|
|
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.complete(
|
|
|
|
|
{
|
|
|
|
|
reason: { normalized: "content-filter" },
|
|
|
|
|
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
|
|
|
|
},
|
|
|
|
|
LLMEvent.textStart({ id: "partial" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
|
|
|
|
expect((yield* runPrompt(session, "Blocked response").pipe(Effect.flip)).message).toBe(
|
|
|
|
|
"Provider blocked the response",
|
|
|
|
|
)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user" },
|
|
|
|
|
{
|
|
|
|
@@ -3953,22 +3781,18 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Tool before blocked response")
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsReady = 1
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
|
|
|
|
]
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.complete(
|
|
|
|
|
{ reason: { normalized: "content-filter" } },
|
|
|
|
|
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
yield* tools.release
|
|
|
|
|
expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
toolExecutionsStarted = undefined
|
|
|
|
|
|
|
|
|
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
|
|
|
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
|
|
|
@@ -3987,16 +3811,14 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("does not recover context overflow after durable assistant output", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail after output")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "text-partial" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
|
|
|
|
LLMEvent.textEnd({ id: "text-partial" }),
|
|
|
|
|
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
|
|
|
]
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
|
|
|
|
])
|
|
|
|
|
expect((yield* runPrompt(session, "Fail after output").pipe(Effect.flip)).message).toBe("prompt too long")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -4014,11 +3836,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("projects raw provider stream failures as terminal assistant step failures", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail raw stream durably")
|
|
|
|
|
const failure = invalidRequest()
|
|
|
|
|
responseStream = Stream.fail(failure)
|
|
|
|
|
yield* TestLLM.push(Stream.fail(failure))
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Fail raw stream durably").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
yield* replaySessionProjection(sessionID)
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Fail raw stream durably" },
|
|
|
|
@@ -4031,11 +3852,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Retry transport")
|
|
|
|
|
responseStream = Stream.fail(providerUnavailable())
|
|
|
|
|
response = reply.text("Recovered", "retry-success")
|
|
|
|
|
yield* TestLLM.push(Stream.fail(providerUnavailable()))
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Recovered", "retry-success"))
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 1) yield* Effect.yieldNow
|
|
|
|
|
yield* TestLLM.wait(1)
|
|
|
|
|
yield* TestClock.adjust("1999 millis")
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
yield* TestClock.adjust("1 millis")
|
|
|
|
@@ -4058,11 +3879,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Retry rate limit")
|
|
|
|
|
responseStream = Stream.fail(rateLimited(5_000))
|
|
|
|
|
response = reply.text("Recovered", "retry-after-success")
|
|
|
|
|
yield* TestLLM.push(Stream.fail(rateLimited(5_000)))
|
|
|
|
|
yield* TestLLM.push(TestLLM.text("Recovered", "retry-after-success"))
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 1) yield* Effect.yieldNow
|
|
|
|
|
yield* TestLLM.wait(1)
|
|
|
|
|
yield* TestClock.adjust("4999 millis")
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
yield* TestClock.adjust("1 millis")
|
|
|
|
@@ -4074,15 +3895,17 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("does not retry eligible failures after observable output", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Do not replay partial output")
|
|
|
|
|
const failure = rateLimited()
|
|
|
|
|
responseStream = Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "partial-rate-limit" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
|
|
|
|
|
]).pipe(Stream.concat(Stream.fail(failure)))
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.failAfter(
|
|
|
|
|
failure,
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "partial-rate-limit" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Do not replay partial output").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
@@ -4101,15 +3924,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Exhaust retries")
|
|
|
|
|
streamFailure = providerUnavailable()
|
|
|
|
|
const failure = providerUnavailable()
|
|
|
|
|
yield* TestLLM.always(Stream.fail(failure))
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 1) yield* Effect.yieldNow
|
|
|
|
|
yield* TestLLM.wait(1)
|
|
|
|
|
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
|
|
|
|
|
yield* TestClock.adjust(delay)
|
|
|
|
|
while (requests.length < index + 2) yield* Effect.yieldNow
|
|
|
|
|
yield* TestLLM.wait(index + 2)
|
|
|
|
|
}
|
|
|
|
|
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure)
|
|
|
|
|
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(requests).toHaveLength(5)
|
|
|
|
|
|
|
|
|
|
const database = (yield* Database.Service).db
|
|
|
|
@@ -4150,11 +3974,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Retry without consuming a step")
|
|
|
|
|
const failure = providerUnavailable()
|
|
|
|
|
responseStream = Stream.fail(failure)
|
|
|
|
|
responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()]
|
|
|
|
|
yield* TestLLM.push(Stream.fail(failure))
|
|
|
|
|
yield* TestLLM.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop())
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
while (requests.length < 1) yield* Effect.yieldNow
|
|
|
|
|
yield* TestLLM.wait(1)
|
|
|
|
|
yield* TestClock.adjust("2 seconds")
|
|
|
|
|
yield* Fiber.join(run)
|
|
|
|
|
|
|
|
|
@@ -4185,11 +4009,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("does not retry non-eligible provider failures", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Do not retry")
|
|
|
|
|
const failure = invalidRequest()
|
|
|
|
|
streamFailure = failure
|
|
|
|
|
yield* TestLLM.push(Stream.fail(failure))
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Do not retry").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
|
|
|
|
}),
|
|
|
|
@@ -4198,24 +4021,25 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("settles malformed streamed tool input before the provider failure", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Call a malformed tool")
|
|
|
|
|
const failure = new LLMError({
|
|
|
|
|
module: "test",
|
|
|
|
|
method: "stream",
|
|
|
|
|
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
|
|
|
|
})
|
|
|
|
|
responseStream = Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
|
|
|
|
]).pipe(Stream.concat(Stream.fail(failure)))
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.failAfter(
|
|
|
|
|
failure,
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Call a malformed tool").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
|
|
|
|
|
|
|
|
response = reply.stop()
|
|
|
|
|
yield* admit(session, "Continue")
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop())
|
|
|
|
|
yield* runPrompt(session, "Continue")
|
|
|
|
|
|
|
|
|
|
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
|
|
|
|
{ type: "session.step.started.1" },
|
|
|
|
@@ -4237,12 +4061,10 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("continues after malformed local tool input without exposing raw arguments", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Recover malformed tool input")
|
|
|
|
|
const marker = "raw-malformed-marker"
|
|
|
|
|
const raw = `{"text":"${marker}`
|
|
|
|
|
responses = [
|
|
|
|
|
[
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.toolCalls(
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: raw }),
|
|
|
|
|
LLMEvent.toolInputEnd({ id: "call-malformed", name: "echo" }),
|
|
|
|
@@ -4251,13 +4073,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw,
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
],
|
|
|
|
|
reply.stop(),
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
TestLLM.stop(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Recover malformed tool input")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(executions).toEqual([])
|
|
|
|
@@ -4313,7 +4133,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
})
|
|
|
|
|
if (!failed) throw new Error("Malformed tool assistant missing")
|
|
|
|
|
expect(failed.error).toBeUndefined()
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, failed.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.failed.2",
|
|
|
|
|
"session.step.ended.1",
|
|
|
|
@@ -4336,31 +4156,24 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Run parallel tools")
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsReady = 1
|
|
|
|
|
responses = [
|
|
|
|
|
[
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.toolCalls(
|
|
|
|
|
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id: "call-malformed",
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
],
|
|
|
|
|
reply.stop(),
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
TestLLM.stop(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.release
|
|
|
|
|
yield* Fiber.join(run)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
toolExecutionsStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(executions).toEqual(["valid"])
|
|
|
|
@@ -4379,23 +4192,20 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Interrupt malformed recovery")
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsReady = 1
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id: "call-malformed",
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
]
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.toolCalls(
|
|
|
|
|
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id: "call-malformed",
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
while (
|
|
|
|
|
!(yield* session.context(sessionID)).some(
|
|
|
|
|
(message) =>
|
|
|
|
@@ -4405,8 +4215,6 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
)
|
|
|
|
|
yield* Effect.yieldNow
|
|
|
|
|
yield* session.interrupt(sessionID)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
toolExecutionsStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
@@ -4427,19 +4235,21 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("records malformed provider-executed input as executed", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail malformed hosted input")
|
|
|
|
|
const failure = new LLMError({
|
|
|
|
|
module: "test",
|
|
|
|
|
method: "stream",
|
|
|
|
|
reason: new InvalidProviderOutputReason({ message: "Invalid hosted tool input" }),
|
|
|
|
|
})
|
|
|
|
|
responseStream = Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }),
|
|
|
|
|
]).pipe(Stream.concat(Stream.fail(failure)))
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.failAfter(
|
|
|
|
|
failure,
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Fail malformed hosted input").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
|
|
|
|
error: { type: "provider.invalid-output", message: "Invalid hosted tool input" },
|
|
|
|
|
content: [
|
|
|
|
@@ -4457,22 +4267,24 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("records a provider failure after malformed input", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail after malformed input")
|
|
|
|
|
const failure = new LLMError({
|
|
|
|
|
module: "test",
|
|
|
|
|
method: "stream",
|
|
|
|
|
reason: new InvalidProviderOutputReason({ message: "Provider failed after malformed input" }),
|
|
|
|
|
})
|
|
|
|
|
responseStream = Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id: "call-malformed",
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
]).pipe(Stream.concat(Stream.fail(failure)))
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.failAfter(
|
|
|
|
|
failure,
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id: "call-malformed",
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Fail after malformed input").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
|
|
|
|
error: { type: "provider.invalid-output", message: "Provider failed after malformed input" },
|
|
|
|
|
content: [
|
|
|
|
@@ -4491,25 +4303,22 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("continues after repeated malformed tool input", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Keep producing malformed tools")
|
|
|
|
|
const malformed = (id: string) => [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id,
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
]
|
|
|
|
|
responses = [
|
|
|
|
|
const malformed = (id: string) =>
|
|
|
|
|
TestLLM.toolCalls(
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id,
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
malformed("call-first"),
|
|
|
|
|
reply.tool("call-valid-between", "echo", { text: "valid" }),
|
|
|
|
|
TestLLM.tool("call-valid-between", "echo", { text: "valid" }),
|
|
|
|
|
malformed("call-second"),
|
|
|
|
|
reply.stop(),
|
|
|
|
|
]
|
|
|
|
|
TestLLM.stop(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Keep producing malformed tools")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(4)
|
|
|
|
|
expect(executions).toEqual(["valid"])
|
|
|
|
@@ -4526,20 +4335,17 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
agent.steps = 2
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
yield* admit(session, "Stop malformed tools at the step limit")
|
|
|
|
|
const malformed = (id: string) => [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id,
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
|
|
|
|
]
|
|
|
|
|
responses = [malformed("call-first"), malformed("call-at-limit")]
|
|
|
|
|
const malformed = (id: string) =>
|
|
|
|
|
TestLLM.toolCalls(
|
|
|
|
|
LLMEvent.toolInputError({
|
|
|
|
|
id,
|
|
|
|
|
name: "echo",
|
|
|
|
|
raw: '{"text":"partial',
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
yield* TestLLM.push(malformed("call-first"), malformed("call-at-limit"))
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Stop malformed tools at the step limit")
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(2)
|
|
|
|
|
expect(requests[0]?.toolChoice).toBeUndefined()
|
|
|
|
@@ -4552,28 +4358,23 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Do not continue failed provider")
|
|
|
|
|
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionsReady = 1
|
|
|
|
|
response = [
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
|
|
|
|
LLMEvent.providerError({ message: "Provider unavailable" }),
|
|
|
|
|
]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(toolExecutionsStarted)
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.started
|
|
|
|
|
yield* tools.release
|
|
|
|
|
expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
toolExecutionsStarted = undefined
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
expect(executions).toEqual(["settled"])
|
|
|
|
|
const context = yield* session.context(sessionID)
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.called.1",
|
|
|
|
|
"session.tool.success.2",
|
|
|
|
@@ -4585,15 +4386,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("durably fails a hosted tool when its provider errors before returning a result", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail hosted tool durably")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
hostedCall("call-hosted-provider-error", "effect"),
|
|
|
|
|
LLMEvent.providerError({ message: "Provider unavailable" }),
|
|
|
|
|
]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
expect((yield* runPrompt(session, "Fail hosted tool durably").pipe(Effect.flip)).message).toBe(
|
|
|
|
|
"Provider unavailable",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
const context = yield* session.context(sessionID)
|
|
|
|
@@ -4605,7 +4406,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
|
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
|
|
|
|
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
|
|
|
|
"session.step.started.1",
|
|
|
|
|
"session.tool.called.1",
|
|
|
|
|
"session.tool.failed.2",
|
|
|
|
@@ -4617,14 +4418,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("preserves a tool defect before provider failure settlement", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Defect while provider fails")
|
|
|
|
|
response = [
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
|
|
|
|
LLMEvent.providerError({ message: "Provider unavailable" }),
|
|
|
|
|
]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
|
|
|
|
expect((yield* runPrompt(session, "Defect while provider fails").pipe(Effect.flip)).message).toBe(
|
|
|
|
|
"Provider unavailable",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const context = yield* session.context(sessionID)
|
|
|
|
|
const assistant = requireAssistant(context)
|
|
|
|
@@ -4643,13 +4445,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Storage fails while provider fails")
|
|
|
|
|
response = [
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-store-provider-error", name: "storefail", input: {} }),
|
|
|
|
|
LLMEvent.providerError({ message: "Provider unavailable" }),
|
|
|
|
|
]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({
|
|
|
|
|
_tag: "Failure",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
|
|
|
|
error: { type: "provider.unknown", message: "Provider unavailable" },
|
|
|
|
@@ -4660,10 +4464,11 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail hosted tool at EOF")
|
|
|
|
|
response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")]
|
|
|
|
|
yield* TestLLM.push([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")])
|
|
|
|
|
|
|
|
|
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result")
|
|
|
|
|
expect((yield* runPrompt(session, "Fail hosted tool at EOF").pipe(Effect.flip)).message).toBe(
|
|
|
|
|
"Provider did not return a tool result",
|
|
|
|
|
)
|
|
|
|
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
|
|
|
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
|
|
|
|
expect(bus.map((event) => event.type)).toEqual([
|
|
|
|
@@ -4692,15 +4497,9 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("fails an unresolved hosted tool before one clean step end", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Settle hosted tool before ending")
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
hostedCall("call-hosted-clean-end", "effect"),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
]
|
|
|
|
|
yield* TestLLM.push(TestLLM.stop(hostedCall("call-hosted-clean-end", "effect")))
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Settle hosted tool before ending")
|
|
|
|
|
|
|
|
|
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
|
|
|
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
|
|
|
@@ -4722,21 +4521,24 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
yield* admit(session, "Fail unresolved tools")
|
|
|
|
|
const failure = invalidRequest()
|
|
|
|
|
const providerFailed = yield* Deferred.make<void>()
|
|
|
|
|
toolExecutionGate = yield* Deferred.make<void>()
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }),
|
|
|
|
|
hostedCall("call-hosted-raw-failure-pair", "effect"),
|
|
|
|
|
]),
|
|
|
|
|
Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))),
|
|
|
|
|
const tools = yield* blockTools()
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.concat(
|
|
|
|
|
Stream.fromIterable([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }),
|
|
|
|
|
hostedCall("call-hosted-raw-failure-pair", "effect"),
|
|
|
|
|
]),
|
|
|
|
|
Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(
|
|
|
|
|
Stream.flatMap(() => Stream.fail(failure)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
|
|
|
|
yield* Deferred.await(providerFailed)
|
|
|
|
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
|
|
|
|
yield* tools.release
|
|
|
|
|
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
toolExecutionGate = undefined
|
|
|
|
|
|
|
|
|
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
|
|
|
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
|
|
|
@@ -4757,14 +4559,15 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Fail hosted tool on raw failure")
|
|
|
|
|
const failure = providerUnavailable()
|
|
|
|
|
responseStream = Stream.concat(
|
|
|
|
|
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
|
|
|
|
|
Stream.fail(failure),
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
Stream.concat(
|
|
|
|
|
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
|
|
|
|
|
Stream.fail(failure),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(yield* runPrompt(session, "Fail hosted tool on raw failure").pipe(Effect.flip)).toBe(failure)
|
|
|
|
|
expect(requests).toHaveLength(1)
|
|
|
|
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
|
|
|
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
|
|
|
@@ -4793,15 +4596,13 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("rejects a second text start before the open fragment ends", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Two blocks")
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
yield* TestLLM.push([
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "text-1" }),
|
|
|
|
|
LLMEvent.textStart({ id: "text-2" }),
|
|
|
|
|
]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
|
|
|
|
const defect = yield* runPrompt(session, "Two blocks").pipe(Effect.catchDefect(Effect.succeed))
|
|
|
|
|
expect(defect).toBeInstanceOf(Error)
|
|
|
|
|
if (!(defect instanceof Error)) return
|
|
|
|
|
expect(defect.message).toBe("text start before end: text-2")
|
|
|
|
@@ -4811,21 +4612,18 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("projects sequential text fragments as separate content parts", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Two blocks")
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.stop(
|
|
|
|
|
LLMEvent.textStart({ id: "text-1" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "text-1", text: "First" }),
|
|
|
|
|
LLMEvent.textEnd({ id: "text-1" }),
|
|
|
|
|
LLMEvent.textStart({ id: "text-2" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
|
|
|
|
LLMEvent.textEnd({ id: "text-2" }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.textStart({ id: "text-1" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "text-1", text: "First" }),
|
|
|
|
|
LLMEvent.textEnd({ id: "text-1" }),
|
|
|
|
|
LLMEvent.textStart({ id: "text-2" }),
|
|
|
|
|
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
|
|
|
|
LLMEvent.textEnd({ id: "text-2" }),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Two blocks")
|
|
|
|
|
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Two blocks" },
|
|
|
|
@@ -4855,7 +4653,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("rejects duplicate streamed text starts", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]
|
|
|
|
|
yield* TestLLM.push([LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })])
|
|
|
|
|
|
|
|
|
|
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
|
|
|
|
expect(defect).toBeInstanceOf(Error)
|
|
|
|
@@ -4867,19 +4665,16 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("transitions streamed raw tool input to parsed called input", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
yield* admit(session, "Call provider tool")
|
|
|
|
|
yield* TestLLM.push(
|
|
|
|
|
TestLLM.stop(
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
|
|
|
|
|
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
|
|
|
|
|
hostedCall("call-parsed", "hello"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = [
|
|
|
|
|
LLMEvent.stepStart({ index: 0 }),
|
|
|
|
|
LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }),
|
|
|
|
|
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
|
|
|
|
|
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
|
|
|
|
|
hostedCall("call-parsed", "hello"),
|
|
|
|
|
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
|
|
|
|
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
|
yield* runPrompt(session, "Call provider tool")
|
|
|
|
|
|
|
|
|
|
expect(yield* session.context(sessionID)).toMatchObject([
|
|
|
|
|
{ type: "user", text: "Call provider tool" },
|
|
|
|
@@ -4894,7 +4689,7 @@ describe("SessionRunnerLLM", () => {
|
|
|
|
|
it.effect("rejects malformed streamed tool input ordering", () =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const session = yield* setup
|
|
|
|
|
response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
|
|
|
|
|
yield* TestLLM.push([LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })])
|
|
|
|
|
|
|
|
|
|
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
|
|
|
|
expect(defect).toBeInstanceOf(Error)
|
|
|
|
|