Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad e4b4b913fc fix(core): resume background completions 2026-07-08 03:44:52 +00:00
17 changed files with 99 additions and 274 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ export const Model = Schema.Struct({
name: Schema.String,
family: Schema.optional(Schema.String),
release_date: Schema.String,
attachment: Schema.optional(Schema.Boolean),
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.optional(Schema.Boolean),
+4 -5
View File
@@ -185,12 +185,11 @@ function applyModel(
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined
draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings
const capabilities = ModelV2.Capabilities.defaults({
draft.capabilities = {
tools: model.tool_call,
input: model.modalities?.input ?? (model.attachment === false ? ["text"] : undefined),
output: model.modalities?.output,
})
draft.capabilities = { ...capabilities, input: [...capabilities.input], output: [...capabilities.output] }
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
mergeVariants(draft, input.variants ?? [])
draft.time.released = released(model.release_date)
draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({
+1 -3
View File
@@ -689,9 +689,7 @@ const layer = Layer.effect(
metadata: input.metadata,
})
if (input.resume === false) return
yield* execution
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
yield* execution.wake(input.sessionID, { force: true })
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
+3 -2
View File
@@ -4,6 +4,7 @@ import { Context, Effect, Layer } from "effect"
import { LayerNode } from "../effect/layer-node"
import { Node } from "../effect/app-node"
import { SessionRunner } from "./runner/index"
import type { SessionRunCoordinator } from "./run-coordinator"
import { SessionSchema } from "./schema"
export interface Interface {
@@ -11,8 +12,8 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Registers newly recorded work. Repeated wakeups may coalesce. Force runs even without pending input. */
readonly wake: (sessionID: SessionSchema.ID, options?: SessionRunCoordinator.WakeOptions) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
+14 -5
View File
@@ -9,13 +9,15 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
readonly wake: (key: Key, options?: WakeOptions) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
export type WakeOptions = { readonly force?: boolean }
/**
* One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
@@ -27,6 +29,7 @@ type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
pendingForce: boolean
stopping: boolean
settling: boolean
interruptionReason?: Reason
@@ -63,8 +66,10 @@ export const make = <Key, E, Reason = never>(options: {
Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
const force = execution.pendingForce
execution.pendingForce = false
// Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, force)))
}),
),
)
@@ -73,6 +78,7 @@ export const make = <Key, E, Reason = never>(options: {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
pendingForce: false,
stopping: false,
settling: false,
}
@@ -100,7 +106,7 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
if (execution.pendingWake) start(key, execution.pendingForce)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
@@ -116,14 +122,16 @@ export const make = <Key, E, Reason = never>(options: {
return restore(Deferred.await(start(key, true).done))
})
const wake = (key: Key) =>
const wake = (key: Key, options?: { readonly force?: boolean }) =>
Effect.sync(() => {
const force = options?.force === true
const execution = executions.get(key)
if (execution !== undefined) {
execution.pendingWake = true
execution.pendingForce ||= force
return
}
start(key, false)
start(key, force)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
@@ -132,6 +140,7 @@ export const make = <Key, E, Reason = never>(options: {
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
execution.stopping = true
execution.pendingWake = false
execution.pendingForce = false
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner)
})
+4 -5
View File
@@ -237,10 +237,9 @@ const layer = Layer.effect(
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization =
isLastStep || !resolved.capabilities.tools
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -252,7 +251,7 @@ const layer = Layer.effect(
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(context, resolved.ref, providerMetadataKey, resolved.capabilities),
...toLLMMessages(context, resolved.ref, providerMetadataKey),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
],
tools: toolMaterialization?.definitions ?? [],
+1 -10
View File
@@ -82,8 +82,6 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Capabilities of the selected catalog model. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
@@ -98,19 +96,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (
model: Model,
variant?: ModelV2.VariantID,
cost: ModelV2.Info["cost"] = [],
capabilities = ModelV2.Capabilities.defaults(),
): Resolved => ({
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
}),
capabilities,
cost,
})
@@ -352,7 +344,6 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
}),
@@ -11,6 +11,8 @@ import type { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
@@ -19,23 +21,6 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const modality = (mime: string) => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
if (mime === "application/pdf") return "pdf"
return undefined
}
const attachment = (file: FileAttachment, capabilities?: ModelV2.Capabilities): ContentPart => {
const type = modality(file.mime)
if (!type || (capabilities?.input ?? ["text", "image"]).includes(type)) return media(file)
return {
type: "text",
text: `ERROR: Cannot read ${file.name ? `"${file.name}"` : type} (this model does not support ${type} input). Inform the user.`,
}
}
const textAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
@@ -183,12 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
]
}
function toLLMMessage(
message: SessionMessage.Info,
model: ModelV2.Ref,
providerMetadataKey: string,
capabilities?: ModelV2.Capabilities,
): Message[] {
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@@ -203,9 +183,7 @@ function toLLMMessage(
role: "user",
content: [
{ type: "text", text: message.text },
...files
.filter((file) => file.mime !== "text/plain" && file.mime !== "application/x-directory")
.map((file) => attachment(file, capabilities)),
...files.filter((file) => imageMimes.has(file.mime)).map(media),
],
metadata: {
...message.metadata,
@@ -258,5 +236,4 @@ export const toLLMMessages = (
messages: readonly SessionMessage.Info[],
model: ModelV2.Ref,
providerMetadataKey: string = model.providerID,
capabilities?: ModelV2.Capabilities,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, capabilities))
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
+2 -10
View File
@@ -7,7 +7,6 @@ import { ConfigMCPV1 } from "./mcp"
import { ConfigPermissionV1 } from "./permission"
import { ConfigProviderV1 } from "./provider"
import { ConfigProviderOptionsV1 } from "./provider-options"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
const keys = new Set([
@@ -246,15 +245,8 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
: []),
]
const capabilities =
info.tool_call !== undefined ||
info.attachment !== undefined ||
info.modalities?.input !== undefined ||
info.modalities?.output !== undefined
? ModelV2.Capabilities.defaults({
tools: info.tool_call,
input: info.modalities?.input ?? (info.attachment === false ? ["text"] : undefined),
output: info.modalities?.output,
})
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
: undefined
return {
modelID: info.id,
-15
View File
@@ -680,17 +680,9 @@ describe("Config", () => {
options: { apiKey: "secret" },
models: {
model: {
attachment: true,
options: { reasoningEffort: "high" },
variants: { fast: { temperature: 0.2 } },
},
text: {
attachment: false,
},
audio: {
attachment: true,
modalities: { input: ["audio"], output: ["audio"] },
},
},
},
openai: {
@@ -766,16 +758,9 @@ describe("Config", () => {
settings: { apiKey: "secret" },
models: {
model: {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
settings: { reasoningEffort: "high" },
variants: [{ id: "fast", settings: { temperature: 0.2 } }],
},
text: {
capabilities: { tools: true, input: ["text"], output: ["text"] },
},
audio: {
capabilities: { tools: true, input: ["audio"], output: ["audio"] },
},
},
})
expect(documents[0]?.info.providers?.openai).toMatchObject({
@@ -237,7 +237,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
const defaultModel = required(yield* catalog.model.get(providerID, ModelV2.ID.make("default")))
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
@@ -253,7 +252,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(defaultModel.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([
-15
View File
@@ -165,21 +165,6 @@ describe("ModelsDev Service", () => {
}),
)
it.effect("allows models.dev entries without legacy attachment metadata", () =>
Effect.sync(() => {
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
id: "no-attachment-model",
name: "No Attachment Model",
release_date: "2026-01-01",
reasoning: false,
tool_call: true,
limit: { context: 128000, output: 8192 },
})
expect(result.attachment).toBeUndefined()
}),
)
it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
@@ -85,24 +85,6 @@ describe("ModelsDevPlugin", () => {
},
},
},
default: {
id: "default",
name: "Default",
release_date: "2026-01-01",
reasoning: false,
tool_call: false,
limit: { context: 128_000, output: 8_192 },
},
explicit: {
id: "explicit",
name: "Explicit",
release_date: "2026-01-01",
attachment: true,
reasoning: false,
tool_call: false,
modalities: { input: ["audio"], output: ["audio"] },
limit: { context: 128_000, output: 8_192 },
},
},
},
} satisfies Record<string, ModelsDev.Provider>),
@@ -119,14 +101,9 @@ describe("ModelsDevPlugin", () => {
const providerID = ProviderV2.ID.make("acme")
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
const defaults = yield* catalog.model.get(providerID, ModelV2.ID.make("default"))
const explicit = yield* catalog.model.get(providerID, ModelV2.ID.make("explicit"))
expect(base?.variants).toEqual([])
expect(base?.body).toEqual({})
expect(base?.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(defaults?.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] })
expect(explicit?.capabilities).toEqual({ tools: false, input: ["audio"], output: ["audio"] })
expect(fast).toMatchObject({
id: "gpt-5.4-fast",
modelID: "gpt-5.4",
@@ -204,9 +181,6 @@ describe("ModelsDevPlugin", () => {
connections: [],
}),
])
expect(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"))).toMatchObject({
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
})
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
@@ -191,6 +191,36 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("preserves a forced wake received during active execution", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const forces: boolean[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, force) =>
Effect.sync(() => forces.push(force)).pipe(
Effect.flatMap(() =>
forces.length === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate)))
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", { force: true })
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(forces).toEqual([false, true])
}),
),
)
it.effect("runs again when woken during the follow-up", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -240,7 +240,7 @@ Recent work
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
})
test("defaults missing model capabilities to text and image input", () => {
test("uses materialized image data as provider media and drops unsupported attachments", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
@@ -266,113 +266,6 @@ Recent work
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{
type: "text",
text: 'ERROR: Cannot read "document.pdf" (this model does not support pdf input). Inform the user.',
},
])
})
test("uses explicit model input capabilities instead of attachment defaults", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-unsupported-pdf"),
type: "user",
text: "Inspect these files",
files: [
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }),
FileAttachment.make({
data: Base64.make("JVBERg=="),
mime: "application/pdf",
source: { type: "inline" },
name: "document.pdf",
}),
],
time: { created },
}),
],
model,
model.providerID,
{ tools: true, input: ["text", "pdf"], output: ["text"] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect these files" },
{
type: "text",
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
},
{ type: "media", mediaType: "application/pdf", data: "JVBERg==", filename: "document.pdf" },
])
})
test("treats explicit empty input capabilities as authoritative", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-empty-capabilities"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data: Base64.make("AAECAw=="),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
model.providerID,
{ tools: false, input: [], output: [] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{
type: "text",
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
},
])
})
test("classifies audio and video MIME families through explicit capabilities", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-media-capabilities"),
type: "user",
text: "Inspect this media",
files: [
FileAttachment.make({
data: Base64.make("AAECAw=="),
mime: "audio/mpeg",
source: { type: "inline" },
name: "audio.mp3",
}),
FileAttachment.make({
data: Base64.make("AAECAw=="),
mime: "video/webm",
source: { type: "inline" },
name: "video.webm",
}),
],
time: { created },
}),
],
model,
model.providerID,
{ tools: false, input: ["text", "audio", "video"], output: ["text"] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this media" },
{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==", filename: "audio.mp3" },
{ type: "media", mediaType: "video/webm", data: "AAECAw==", filename: "video.webm" },
])
})
+31 -20
View File
@@ -71,6 +71,7 @@ const requests: LLMRequest[] = []
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
@@ -85,6 +86,7 @@ const client = Layer.succeed(
prepare: () => Effect.die("unused"),
stream: ((request: LLMRequest) => {
requests.push(request)
if (responseStreams) return responseStreams.shift() ?? Stream.empty
if (responseStream) {
const stream = responseStream
responseStream = undefined
@@ -235,15 +237,12 @@ const echo = Layer.effectDiscard(
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void
let currentModel = model
let modelCapabilities = ModelV2.Capabilities.defaults()
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
[],
modelCapabilities,
),
),
),
@@ -415,11 +414,11 @@ const setup = Effect.gen(function* () {
systemLoadHook = Effect.void
modelResolveHook = Effect.void
currentModel = model
modelCapabilities = ModelV2.Capabilities.defaults()
skillBaselines.clear()
responses = undefined
streamFailure = undefined
responseStream = undefined
responseStreams = undefined
streamGate = undefined
streamStarted = undefined
toolExecutionGate = undefined
@@ -772,6 +771,34 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs a follow-up when synthetic context arrives during an active continuation", () =>
Effect.gen(function* () {
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" })),
Stream.unwrap(
Deferred.succeed(secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseSecond)),
Effect.as(Stream.fromIterable(reply.stop())),
),
),
Stream.fromIterable(reply.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)
yield* session.synthetic({ sessionID, text: "Background work completed" })
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(running)
expect(requests).toHaveLength(3)
expect(userTexts(requests[2])).toContain("Background work completed")
}),
)
it.effect("streams one request with registry definitions from chronological V2 user history", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -791,22 +818,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not advertise tools to a model without tool capability", () =>
Effect.gen(function* () {
yield* setup
modelCapabilities = ModelV2.Capabilities.defaults({ tools: false })
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "No tools" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.tools).toEqual([])
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
const session = yield* setup
+2 -19
View File
@@ -26,24 +26,7 @@ export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
input: Schema.Array(Schema.String),
output: Schema.Array(Schema.String),
})
.annotate({ identifier: "Model.Capabilities" })
.pipe(
statics((schema) => ({
defaults: (
input: {
readonly tools?: boolean
readonly input?: ReadonlyArray<string>
readonly output?: ReadonlyArray<string>
} = {},
) =>
schema.make({
tools: input.tools ?? true,
input: input.input === undefined ? ["text", "image"] : [...input.input],
output: input.output === undefined ? ["text"] : [...input.output],
}),
})),
)
}).annotate({ identifier: "Model.Capabilities" })
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
export const Cost = Schema.Struct({
@@ -97,7 +80,7 @@ export const Info = Schema.Struct({
modelID: id,
providerID,
name: id,
capabilities: Capabilities.defaults(),
capabilities: { tools: false, input: [], output: [] },
variants: [],
time: { released: 0 },
cost: [],