Compare commits

...
Author SHA1 Message Date
Kit Langton 917dcd7004 fix(core): decouple model catalog refresh from persistence 2026-08-26 14:38:41 -04:00
James Long 018b4c40f3 fix(pty): upgrade opencode-pty to 0.1.10 (#45352) 2026-08-26 14:15:15 -04:00
Brendan Allan 0772b67b7a fix(app): prevent stale service worker startup failures (#45344) 2026-08-27 02:06:21 +08:00
Aiden Cline a841d6d046 fix(ai): accept empty Responses stream IDs and null items (#45330) 2026-08-26 13:04:24 -05:00
opencode-agent[bot]andBrendonovich ab6a01d135 fix(ui): remove solid menu group label line height (#45331)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 00:58:57 +08:00
Kit Langton 962a6ca0e7 fix(sdk): identify workspace dependencies by version specifier (#45309) 2026-08-26 12:55:06 -04:00
Kit Langton 2602dcd0a7 refactor(core): encapsulate physical attempt execution (#45294)
Extract physical-attempt streaming, tool execution, and durable settlement from the Session runner. Preserve logical-Step retry and recovery policy, use tagged drain outcomes, and fix multi-click selection during auto-copy.
2026-08-26 12:41:45 -04:00
Aiden Cline cf98ca55c9 fix(core): allow non-letter tool name prefixes (#45317) 2026-08-26 11:21:14 -05:00
Kit Langton fedf017e25 fix(tui): clarify code mode tool call rendering 2026-08-26 11:27:17 -04:00
Aiden Cline f4a9b93013 feat(ai): add native Groq provider with provider-specific options (#45288) 2026-08-26 10:12:52 -05:00
Dax Raad cbef698861 fix(core): suppress state updates during location teardown 2026-08-26 10:48:44 -04:00
James Long ab2d251155 fix(server): unblock persistent terminal input in Bun builds (#45287) 2026-08-26 10:47:16 -04:00
Kit Langton 3d7ba38965 feat(tui): investigate errors in a new session 2026-08-26 10:43:40 -04:00
Aiden Cline 37a6ba893e chore(ai): format package with prettier (#45280) 2026-08-26 09:24:13 -05:00
Brendan Allan 6c6871fd2a fix(app): include files in mention search (#45281) 2026-08-26 22:18:34 +08:00
opencode-agent[bot] 1e864dd8c6 chore: update nix node_modules hashes 2026-08-26 13:49:52 +00:00
James Long 21980a4448 fix(tui): restore terminal settings and move pane toggle to sessions (#45271) 2026-08-26 09:46:33 -04:00
James Long 9cca8dd6e0 feat(tui): add persistent session terminals (#44971) 2026-08-26 09:30:03 -04:00
Dax 91028a690b feat(tui): show LLM token throughput 2026-08-26 09:23:53 -04:00
Shoubhit Dash e82aa92e64 fix(session): support assistant message content updates (#45015) 2026-08-26 18:31:59 +05:30
opencode-agent[bot]andBrendonovich 874538d702 fix(desktop): restrict macOS app entitlements (#45258)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 12:53:00 +00:00
143 changed files with 5422 additions and 1287 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/core": patch
"@opencode-ai/server": patch
---
Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
+9 -10
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -350,7 +350,6 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -365,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -2157,19 +2156,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.8", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.8", "@opencode-ai/pty-darwin-x64": "0.1.8", "@opencode-ai/pty-linux-arm64-gnu": "0.1.8", "@opencode-ai/pty-linux-arm64-musl": "0.1.8", "@opencode-ai/pty-linux-x64-gnu": "0.1.8", "@opencode-ai/pty-linux-x64-musl": "0.1.8" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-+OHAANB6xgtO4zVMK5Q3l50Iot2fYcHL003rNpvxxCXLkpdO90UnnRCL9jrll1SWkWoDZKaUDlR/Scw6TUTN4A=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DqVcCfa9P5zpM0QpvXD/QqZXFdXvtIoaBHVHjYnTt8oqlnmMWqG4OVowzPNQg07NudW6apwX1Jop1wuERgWGw=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-keEDxfpy0mFJYmsTqea9IUbpLKpbe9yL4ISU8tYA0daWiKuR8ZlfLTlLGltaFNO7l7q6mURMeI4cZzwySOX/Kw=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-NVJvlVtPbYRyjABwLkm1iboijpSZMXv4lfg4qda7UCzAgiDZ0cWauXeW5wtd9bbnUbHw4rdNVU6YweuwASgZXw=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-GAhnCs7b7B8Hqa7AEN7k5ra4IqybFYePgbCSOHn3Y4mofp/5NjDlkOG/VFM7rHAy05iEmRi5WtOPRv0Mkca8zA=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-vSbty1L0BuOQlK7DmukuhdxNKv2vzqyxOjHmh+TULptMDbDZwE1EybASoXB7dYog9as85adFCz8ZTF7Boyfxjg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-2GhmJLl+Ly/JjzMS2Eqvk7SgeuHIB0cQ4Wrmnj4WKhr2V7lS+pdUEApN6S0SG/FK0TYJlWeHeOIpNZgDqzEPPg=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-6Fc26RulNnJZtMUxeryWYqCt/7xIs1E+cOQSddAC9LA=",
"aarch64-linux": "sha256-p7cyVH12IGAo6HyaoSgO1fBx5fNTCZGTpvbqzIpe+A8=",
"aarch64-darwin": "sha256-U90pWWqe7DRiUpY5KUUp4exQpa/ddb/bQ5Ldxe46LfQ=",
"x86_64-darwin": "sha256-Ak6omOq2BBRLQEMxPydiQY9ajaJGQR61P6az4+xxZVM="
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
}
}
+6 -1
View File
@@ -36,7 +36,12 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"google-vertex-messages",
"bedrock-converse",
"openrouter",
])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
+35 -23
View File
@@ -69,14 +69,22 @@ export interface OptionsInput {
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ProviderOptionsInput = OptionsInput
@@ -259,7 +267,11 @@ const AnthropicToolChoice = Schema.Union([
type: Schema.Literals(["auto", "any", "none"]),
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
Schema.Struct({
type: Schema.tag("tool"),
name: Schema.String,
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
])
const AnthropicThinking = Schema.Union([
@@ -506,7 +518,11 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
content: payload,
} satisfies AnthropicServerToolResultBlock
})
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
@@ -554,9 +570,7 @@ const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string |
return undefined
}
const citationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicDocumentBlock["citations"] | undefined => {
const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocumentBlock["citations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const raw = ProviderShared.isRecord(metadata.anthropic)
? (metadata.anthropic.citations ?? metadata.citations)
@@ -706,8 +720,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
if (request.model.compatibility?.requireSignature !== undefined) return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
@@ -900,21 +913,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) &&
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
@@ -965,8 +981,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
const budget =
@@ -1418,9 +1433,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`))
}
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
}
@@ -1473,10 +1486,9 @@ export const route = Route.make({
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
{ baseURL: DEFAULT_BASE_URL },
),
endpoint: Endpoint.path((input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH), {
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
@@ -652,7 +652,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
method: "stream",
reason: classifyProviderFailure({
message:
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
event.exception.details.message ??
event.exception.details.originalMessage ??
"Bedrock Converse stream error",
code: event.exception.type,
}),
})
@@ -82,7 +82,9 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
out.push(
messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed },
)
}
return [cursor, out] as const
})
+9 -3
View File
@@ -570,7 +570,12 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
"text-0",
googleMetadata({ thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -675,8 +680,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
hasToolCalls = true
+23 -21
View File
@@ -346,7 +346,8 @@ export const Event = Schema.StructWithRest(
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
response: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
@@ -812,7 +813,7 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -891,7 +892,7 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id) {
if (item?.type === "message" && item.id !== undefined) {
const phase = messagePhase(item.phase)
return [
{
@@ -922,7 +923,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -941,7 +942,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
@@ -988,7 +989,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
return [
@@ -1013,7 +1014,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
if (event.item_id === undefined) return [state, NO_EVENTS] satisfies StepResult
const tool = state.tools[event.item_id]
if (!tool) return [state, NO_EVENTS] satisfies StepResult
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
@@ -1044,7 +1045,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
if (item.type === "message" && item.id !== undefined) {
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
@@ -1075,7 +1076,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const result =
item.arguments === undefined
@@ -1136,7 +1137,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
!id ||
id === undefined ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
@@ -1219,12 +1220,13 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id && outputItemID(state, input) !== input.item_id
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
@@ -1233,7 +1235,7 @@ export const step = (state: ParserState, input: Event) => {
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string")
if (event.item_id === undefined || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
@@ -1242,7 +1244,7 @@ export const step = (state: ParserState, input: Event) => {
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
@@ -1250,24 +1252,24 @@ export const step = (state: ParserState, input: Event) => {
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return event.item_id
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
@@ -1275,11 +1277,11 @@ export const step = (state: ParserState, input: Event) => {
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
+31 -25
View File
@@ -496,7 +496,11 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (mistral)
return id
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, 9)
.padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
@@ -505,7 +509,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
@@ -557,7 +562,10 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
if (
message.role === "assistant" &&
message.content.every((part) => part.type === "text" && part.text.trim() === "")
)
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, lowering)
@@ -588,7 +596,10 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const detectMaxTokensField = (
provider: string,
baseURL: string | undefined,
): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
@@ -638,7 +649,8 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isVercelAiGateway =
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
@@ -670,11 +682,7 @@ const detectSupportsStrictMode = (provider: string, baseURL: string | undefined)
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const detectZaiToolStream = (provider: string, baseURL: string | undefined, modelID: string): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
@@ -724,10 +732,10 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const supportsStrictMode =
request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
@@ -816,11 +824,10 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached =
(usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cached = (usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
@@ -936,13 +943,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason
? {
normalized: yield* mapFinishReason(event, rawFinishReason),
raw: choice?.native_finish_reason ?? rawFinishReason,
}
: state.finishReason
const finishReason = rawFinishReason
? {
normalized: yield* mapFinishReason(event, rawFinishReason),
raw: choice?.native_finish_reason ?? rawFinishReason,
}
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
@@ -184,7 +184,7 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
@@ -29,10 +29,9 @@ export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
export const ServiceTier = Schema.declare<ServiceTier>(
(value): value is ServiceTier => typeof value === "string",
{ title: "ServiceTier" },
)
export const ServiceTier = Schema.declare<ServiceTier>((value): value is ServiceTier => typeof value === "string", {
title: "ServiceTier",
})
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
@@ -34,37 +34,35 @@ export const onDone: (
state: OpenResponses.ParserState,
item: Item,
tools: Definitions,
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
state,
item,
tools,
) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: tool.result
? yield* tool.result(item)
: item.error !== undefined && item.error !== null
? { type: "error", value: item.error }
: { type: "json", value: item },
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
function* (state, item, tools) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: tool.result
? yield* tool.result(item)
: item.error !== undefined && item.error !== null
? { type: "error", value: item.error }
: { type: "json", value: item },
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
},
)
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
+116
View File
@@ -0,0 +1,116 @@
import { Effect, Schema } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { ProviderShared } from "../protocols/shared.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("groq")
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
readonly includeReasoning?: boolean
readonly parallelToolCalls?: boolean
readonly serviceTier?: "on_demand" | "flex" | "auto" | "performance" | (string & {})
readonly user?: string
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
const Options = Schema.Struct({
includeReasoning: Schema.optional(Schema.Boolean),
parallelToolCalls: Schema.optional(Schema.Boolean),
serviceTier: Schema.optional(Schema.String),
user: Schema.optional(Schema.String),
})
export const protocol = Protocol.make({
id: "groq-chat",
body: {
schema: Schema.Struct({
...OpenAIChat.bodyFields,
reasoning_format: Schema.optional(Schema.Literal("parsed")),
include_reasoning: Schema.optional(Schema.Boolean),
parallel_tool_calls: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(Schema.String),
user: Schema.optional(Schema.String),
}),
from: Effect.fn("Groq.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(
request.providerOptions ?? {},
)
const gptOSS = request.model.id.startsWith("openai/gpt-oss-")
return {
...(yield* OpenAIChat.fromRequest(request)),
reasoning_format: gptOSS ? undefined : ("parsed" as const),
include_reasoning: gptOSS ? options.includeReasoning : undefined,
parallel_tool_calls: options.parallelToolCalls,
service_tier: options.serviceTier,
user: options.user,
}
}),
},
stream: OpenAIChat.protocol.stream,
})
export const route = Route.make({
id: "groq-chat",
provider: id,
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
framing: Framing.sse,
})
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<ProviderOptions>({
id: modelID,
compatibility: {
maxTokensField: "max_completion_tokens",
reasoningField: "reasoning",
requireReasoning: false,
supportsStore: false,
supportsStrictMode: false,
},
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Groq from "./groq.js"
+1
View File
@@ -12,6 +12,7 @@ export * as GoogleVertex from "./google-vertex.js"
export * as GoogleVertexChat from "./google-vertex-chat.js"
export * as GoogleVertexMessages from "./google-vertex-messages.js"
export * as GoogleVertexResponses from "./google-vertex-responses.js"
export * as Groq from "./groq.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
+1 -3
View File
@@ -339,9 +339,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return onHalt
? parsed.pipe(
Stream.concat(
Stream.suspend(() =>
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
),
Stream.suspend(() => Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable)))),
),
)
: parsed
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/chat-streams-text",
"recordedAt": "2026-08-23T17:21:53.198Z"
},
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/responses-calls-a-tool",
"recordedAt": "2026-08-23T17:21:55.170Z"
},
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/responses-continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:56.397Z"
},
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/responses-streams-text",
"recordedAt": "2026-08-23T17:21:54.158Z"
},
@@ -2,11 +2,7 @@
"version": 1,
"metadata": {
"model": "openai.gpt-oss-120b",
"tags": [
"prefix:bedrock-mantle",
"provider:amazon-bedrock",
"protocol:openai-responses"
],
"tags": ["prefix:bedrock-mantle", "provider:amazon-bedrock", "protocol:openai-responses"],
"name": "bedrock-mantle/streams-text",
"recordedAt": "2026-08-25T03:29:02.968Z"
},
@@ -7,13 +7,7 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
},
"interactions": [
{
@@ -35,4 +29,4 @@
}
}
]
}
}
@@ -1,11 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/calls-a-tool",
"recordedAt": "2026-08-23T17:21:51.036Z"
},
@@ -1,11 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:51.853Z"
},
@@ -1,11 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/streams-text",
"recordedAt": "2026-08-23T17:21:50.112Z"
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "openai/gpt-oss-20b",
"tags": ["prefix:groq-chat", "provider:groq", "protocol:groq-chat", "text", "usage"],
"name": "groq-chat/streams-text-with-usage",
"recordedAt": "2026-08-26T14:40:09.833Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.groq.com/openai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-oss-20b\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":512,\"include_reasoning\":false,\"service_tier\":\"on_demand\",\"user\":\"recorded-test\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"seed\":94036161}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}},\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[],\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,12 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:openai-responses-cache",
"provider:openai",
"protocol:openai-responses",
"cache"
],
"tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"],
"name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call",
"recordedAt": "2026-08-25T03:29:25.124Z"
},
@@ -5,14 +5,7 @@
"route": "openai-responses",
"transport": "http",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses",
"provider:openai",
"flagship",
"tool",
"tool-loop",
"golden"
],
"tags": ["prefix:openai-responses", "provider:openai", "flagship", "tool", "tool-loop", "golden"],
"name": "openai-responses/openai-responses-gpt-5-5-tool-loop",
"recordedAt": "2026-08-20T06:30:22.262Z"
},
@@ -1,14 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
"name": "pdf/openai-tool-result",
"recordedAt": "2026-08-25T03:29:08.297Z"
},
@@ -1,13 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"user-input"
],
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
"name": "pdf/openai-user-input",
"recordedAt": "2026-08-25T03:29:05.645Z"
},
@@ -1,14 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:xai-responses",
"tool",
"tool-result"
],
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "tool", "tool-result"],
"name": "pdf/xai-tool-result",
"recordedAt": "2026-08-25T03:29:11.774Z"
},
@@ -1,13 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:xai-responses",
"user-input"
],
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "user-input"],
"name": "pdf/xai-user-input",
"recordedAt": "2026-08-25T03:29:10.612Z"
},
@@ -52,4 +52,4 @@
}
}
]
}
}
@@ -2,12 +2,7 @@
"version": 1,
"metadata": {
"model": "anthropic/claude-sonnet-4.6",
"tags": [
"prefix:openai-compatible-chat",
"provider:vercel-ai-gateway",
"protocol:openai-chat",
"reasoning"
],
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
"name": "vercel-ai-gateway-reasoning",
"recordedAt": "2026-07-18T11:28:42.077Z"
},
@@ -31,4 +26,4 @@
}
}
]
}
}
+1 -3
View File
@@ -89,9 +89,7 @@ describe("provider error classification", () => {
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
@@ -29,6 +29,7 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/togetherai"),
import("@opencode-ai/ai/providers/cerebras"),
import("@opencode-ai/ai/providers/deepinfra"),
import("@opencode-ai/ai/providers/groq"),
])
for (const module of modules) expect(module.model).toBeFunction()
+9 -9
View File
@@ -515,7 +515,10 @@ describe("Gemini route", () => {
{
role: "model",
parts: [
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
{
functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } },
thoughtSignature: "sig_1",
},
],
},
{
@@ -606,10 +609,7 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
parts: [{ functionCall: { name: "shot", args: {} } }, { functionCall: { name: "shot", args: {} } }],
},
{
role: "user",
@@ -1071,7 +1071,9 @@ describe("Gemini route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
messages: [
Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }]),
],
}),
)
expect(prepared.body.contents).toEqual([
@@ -1572,9 +1574,7 @@ describe("Gemini route", () => {
{ candidates: [{ content: { role: "model", parts: null } }] },
{ candidates: [{ content: null, finishReason: null }] },
{
candidates: [
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
],
candidates: [{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const }],
},
),
),
@@ -26,9 +26,7 @@ const recorded = recordedTests({
describe("Google Vertex Gemini recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Reply with exactly one word: hello" }))
expect(response.text.toLowerCase()).toContain("hello")
}),
@@ -0,0 +1,185 @@
import { configure } from "@opencode-ai/ai/providers/groq"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.GROQ_API_KEY ?? "fixture"
const recorded = recordedTests({
prefix: "groq-chat",
provider: "groq",
protocol: "groq-chat",
requires: ["GROQ_API_KEY"],
})
const weather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris", "London"] } },
required: ["city"],
additionalProperties: false,
},
})
describe("Groq recorded", () => {
recorded.effect.with(
"streams text with usage",
{ tags: ["text", "usage"], metadata: { model: "openai/gpt-oss-20b" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: configure({
apiKey,
providerOptions: {
includeReasoning: false,
reasoningEffort: "low",
serviceTier: "on_demand",
user: "recorded-test",
},
}).model("openai/gpt-oss-20b"),
prompt: "Reply with exactly one word: hello",
generation: { maxTokens: 512 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body).toMatchObject({
max_completion_tokens: 512,
stream_options: { include_usage: true },
include_reasoning: false,
service_tier: "on_demand",
user: "recorded-test",
})
expect(compiled.body.max_tokens).toBeUndefined()
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.reasoning_format).toBeUndefined()
const response = yield* LLMClient.generate(request)
expect(response.text.toLowerCase().trim()).toBe("hello")
expect(response.reasoning).toBe("")
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
expectUsage(response)
}),
60_000,
)
for (const item of [
{
name: "continues Qwen parallel tool calls",
model: configure({ apiKey, providerOptions: { parallelToolCalls: true, reasoningEffort: "none" } }).model(
"qwen/qwen3.6-27b",
),
cities: ["Paris", "London"],
reasoning: false,
},
{
name: "replays GPT OSS reasoning through a tool loop",
model: configure({ apiKey, providerOptions: { includeReasoning: true, reasoningEffort: "low" } }).model(
"openai/gpt-oss-20b",
),
cities: ["Paris"],
reasoning: true,
},
]) {
recorded.effect.with(
item.name,
{
tags: ["tool", "tool-loop", "usage", item.reasoning ? "reasoning" : "parallel"],
metadata: { model: item.model.id },
},
() =>
Effect.gen(function* () {
const request = LLM.request({
model: item.model,
prompt: `Look up the current weather in ${item.cities.join(" and ")}. Call lookup_weather once for each city in the same response before answering. After receiving all results, report each city's weather in one short sentence.`,
tools: [weather],
toolChoice: "required",
generation: { maxTokens: 1536 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.stream_options).toEqual({ include_usage: true })
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
expect(compiled.body.tools[0].function.strict).toBeUndefined()
if (!item.reasoning) expect(compiled.body.parallel_tool_calls).toBe(true)
const first = yield* LLMClient.generate(request)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toHaveLength(item.cities.length)
expect(new Set(first.toolCalls.map((call) => call.id)).size).toBe(item.cities.length)
expect(first.toolCalls.map((call) => call.input)).toEqual(
expect.arrayContaining(item.cities.map((city) => ({ city }))),
)
expect(first.toolCalls.every((call) => call.name === "lookup_weather")).toBe(true)
expectUsage(first)
if (item.reasoning) {
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
}
const followUp = LLMRequest.update(request, {
toolChoice: ToolChoice.make("none"),
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
),
],
})
const replay = yield* compileRequest(followUp)
if (item.reasoning) {
expect(replay.body.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning: first.reasoning })]),
)
}
expect(replay.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
const second = yield* LLMClient.generate(followUp)
expect(second.finishReason.normalized).toBe("stop")
expect(second.toolCalls).toHaveLength(0)
expect(second.text.toLowerCase()).toContain("sunny")
item.cities.forEach((city) => expect(second.text).toContain(city))
expectUsage(second)
}),
60_000,
)
}
recorded.effect.with(
"streams Qwen parsed reasoning",
{ tags: ["reasoning", "usage"], metadata: { model: "qwen/qwen3.6-27b" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: configure({
apiKey,
providerOptions: { reasoningEffort: "default" },
}).model("qwen/qwen3.6-27b"),
prompt:
"What is 173 multiplied by 219? Think through the arithmetic, then reply with only the final integer.",
generation: { maxTokens: 2048 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body).toMatchObject({ reasoning_format: "parsed", reasoning_effort: "default" })
expect(compiled.body.include_reasoning).toBeUndefined()
const response = yield* LLMClient.generate(request)
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.text).not.toContain("<think>")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expectUsage(response)
}),
60_000,
)
})
function expectUsage(response: LLMResponse) {
expect(response.usage).toBeDefined()
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}
+112
View File
@@ -0,0 +1,112 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LanguageModel, LLM, Message } from "../../src/index.js"
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
import { Groq } from "../../src/providers/groq.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { weatherTool } from "../recorded-scenarios.js"
it.effect("Groq reuses Chat streaming and defaults to parsed reasoning", () =>
Effect.gen(function* () {
expect(Groq.protocol.stream).toBe(OpenAIChat.protocol.stream)
const model = Groq.configure({ apiKey: "fixture" }).model("llama-3.3-70b-versatile")
expect(model.route.endpoint.baseURL).toBe("https://api.groq.com/openai/v1")
const compiled = yield* compileRequest(
LLM.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }),
)
expect(compiled.body).toMatchObject({
max_completion_tokens: 64,
stream_options: { include_usage: true },
reasoning_format: "parsed",
})
for (const key of ["store", "max_tokens", "include_reasoning", "parallel_tool_calls", "service_tier", "user"])
expect(compiled.body[key]).toBeUndefined()
expect(compiled.body.tools?.[0]?.function).not.toHaveProperty("strict")
}),
)
it.effect("Groq lowers its own options for custom catalog identities and endpoints", () =>
Effect.gen(function* () {
const model = LanguageModel.update(
Groq.model("qwen/qwen3.6-27b", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
providerOptions: {
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
},
}),
{ provider: "custom-groq" },
)
const compiled = yield* compileRequest(
LLM.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: false, includeReasoning: false } }),
)
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
expect(model.route.defaults.http?.body).toEqual({ custom: "value" })
expect(compiled.body).toMatchObject({
reasoning_effort: "default",
reasoning_format: "parsed",
parallel_tool_calls: false,
service_tier: "flex",
user: "test-user",
})
expect(compiled.body.include_reasoning).toBeUndefined()
for (const key of ["reasoningFormat", "reasoningEffort", "parallelToolCalls", "serviceTier"])
expect(compiled.body).not.toHaveProperty(key)
}),
)
it.effect("Groq replays reasoning only when present and preserves explicit reasoning exclusion", () =>
Effect.gen(function* () {
const compiled = yield* compileRequest(
LLM.request({
model: Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b"),
messages: [
Message.user("Think"),
Message.assistant([
{ type: "reasoning", text: "Thinking" },
{ type: "text", text: "Answer" },
]),
Message.user("Again"),
Message.assistant("Answer only"),
Message.user("Continue"),
],
providerOptions: { reasoningEffort: "low", includeReasoning: false },
}),
)
expect(compiled.body).toMatchObject({ reasoning_effort: "low", include_reasoning: false })
expect(compiled.body.reasoning_format).toBeUndefined()
expect(compiled.body.messages[1]).toMatchObject({ reasoning: "Thinking", content: "Answer" })
expect(compiled.body.messages[1]).not.toHaveProperty("reasoning_content")
expect(compiled.body.messages[3]).not.toHaveProperty("reasoning")
}),
)
it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () =>
Effect.gen(function* () {
for (const id of ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b"]) {
const compiled = yield* compileRequest(
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }),
)
expect(compiled.body.reasoning_format).toBeUndefined()
expect(compiled.body.include_reasoning).toBeUndefined()
}
}),
)
it.effect("Groq validates option types", () =>
Effect.gen(function* () {
for (const providerOptions of [{ includeReasoning: "false" }, { parallelToolCalls: "false" }]) {
const error = yield* compileRequest(
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model("qwen"), prompt: "Hello", providerOptions }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}
}),
)
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
@@ -155,6 +155,12 @@ describe("native OpenAI-compatible providers", () => {
token: "deepinfra-secret",
url: "https://api.deepinfra.com/v1/openai/chat/completions",
},
{
model: Groq.configure().model("llama"),
env: { GROQ_API_KEY: "groq-secret" },
token: "groq-secret",
url: "https://api.groq.com/openai/v1/chat/completions",
},
]
yield* Effect.forEach(scenarios, (scenario) =>
@@ -307,6 +307,287 @@ describe("Open Responses-compatible route", () => {
}),
)
describe("stream validation", () => {
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const fixtures = [
{
item: { type: "message" },
events: [
{ type: "response.output_text.delta", delta: "Preserved" },
{ type: "response.output_text.done", text: "Preserved" },
{ type: "response.refusal.delta", delta: "Preserved" },
{ type: "response.refusal.done", refusal: "Preserved" },
],
},
{
item: { type: "reasoning", encrypted_content: "encrypted-state" },
events: [
{ type: "response.reasoning.delta", delta: "Preserved" },
{ type: "response.reasoning.done", text: "Preserved" },
{ type: "response.reasoning_summary_text.delta", delta: "Preserved" },
{ type: "response.reasoning_summary_text.done", text: "Preserved" },
{ type: "response.reasoning_text.done", text: "Preserved" },
],
},
{
item: { type: "function_call", call_id: "call_1", name: "lookup" },
events: [
{ type: "response.function_call_arguments.delta", delta: '{"query":"Preserved"}' },
{ type: "response.function_call_arguments.done", arguments: '{"query":"Preserved"}' },
],
},
]
const routings = [
{ name: "empty item and event IDs", id: "", item_id: "" },
{ name: "empty event ID with registered index", id: "item_1", item_id: "", output_index: 2 },
{ name: "empty stored ID with registered index", id: "", item_id: "wrong_item", output_index: 2 },
{ name: "empty item and event IDs with registered index", id: "", item_id: "", output_index: 2 },
]
fixtures.forEach((fixture) => {
fixture.events.forEach((event) => {
routings.forEach((routing) => {
it.effect(`${event.type} preserves content with ${routing.name}`, () =>
Effect.gen(function* () {
const item = { ...fixture.item, id: routing.id }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: routing.output_index, item },
{ ...event, item_id: routing.item_id, output_index: routing.output_index },
{ type: "response.output_item.done", output_index: routing.output_index, item },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
const metadata = { openresponses: { itemId: routing.id } }
if (fixture.item.type === "function_call") {
expect(response.toolCalls).toEqual([
expect.objectContaining({
id: "call_1",
name: "lookup",
input: { query: "Preserved" },
providerMetadata: metadata,
}),
])
return
}
if (fixture.item.type === "reasoning") {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Preserved",
providerMetadata: {
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
return
}
expect(response.message.content).toEqual([
{ type: "text", text: "Preserved", providerMetadata: metadata },
])
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
expect.objectContaining({ id: routing.id, providerMetadata: metadata }),
])
}),
)
})
})
})
routings.forEach((routing) => {
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
Effect.gen(function* () {
const address = { item_id: routing.item_id, output_index: routing.output_index }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: routing.output_index,
item: { type: "reasoning", id: routing.id },
},
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", ...address, summary_index: 0, delta: "First." },
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 0, text: "First." },
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 0 },
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 1 },
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 1, text: "Second." },
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 1 },
{
type: "response.completed",
response: { output: [{ type: "reasoning", id: routing.id, encrypted_content: "final-state" }] },
},
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.",
providerMetadata: { openresponses: { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
expect.objectContaining({
id: `${routing.id}:0`,
providerMetadata: { openresponses: { itemId: routing.id } },
}),
expect.objectContaining({
id: `${routing.id}:1`,
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
}),
])
}),
)
})
it.effect("reconciles pending empty-ID function arguments from completed output", () =>
Effect.gen(function* () {
const item = { type: "function_call", id: "", call_id: "call_1", name: "lookup" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.function_call_arguments.delta", item_id: "", delta: '{"query":"partial' },
{
type: "response.completed",
response: { output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.toolCalls).toEqual([
expect.objectContaining({
id: "call_1",
name: "lookup",
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "" } },
}),
])
}),
)
it.effect("treats null output items as no-ops without disturbing registered items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: null },
{ type: "response.output_item.done", output_index: 0, item: null },
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "Before " },
{ type: "response.output_item.added", output_index: 0, item: null },
{ type: "response.output_item.done", output_index: 0, item: null },
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "after" },
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
expect(response.events.map((event) => event.type)).toEqual([
"step-start",
"text-start",
"text-delta",
"text-delta",
"text-end",
"step-finish",
"finish",
])
}),
)
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
Effect.gen(function* () {
yield* Effect.forEach(
[
...fixtures.flatMap((fixture) => fixture.events.map((event) => ({ item: fixture.item, event }))),
...["response.reasoning_summary_part.added", "response.reasoning_summary_part.done"].map((type) => ({
item: { type: "reasoning" },
event: { type, summary_index: 0 },
})),
],
(fixture) =>
Effect.forEach([undefined, null, 0, false, {}, []], (item_id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { ...fixture.item, id: "item_1" },
},
{ ...fixture.event, output_index: 0, item_id },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
)
}),
)
it.effect("keeps malformed output item IDs invalid", () =>
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
}),
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -2229,6 +2229,35 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("accepts empty IDs for native reasoning text deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "", delta: "Raw" },
{
type: "response.output_item.done",
output_index: 1,
item: { type: "reasoning", id: "", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Raw",
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
+3 -1
View File
@@ -80,7 +80,9 @@ describe("Z.ai Images", () => {
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image(
"model",
),
prompt: "A red circle \uD800 on a white background \u{1F600}",
}).pipe(
Effect.provide(
@@ -0,0 +1,137 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { once } from "node:events"
const legacy = `
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache =>
cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"])
)
))
self.addEventListener("fetch", event => {
if (event.request.mode === "navigate") {
event.respondWith(caches.match("/index.html"))
return
}
event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)))
})
`
const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({
site: async ({}, use) => {
const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8")
const state = { version: "old", repaired: false }
const server = createServer((request, response) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname
const prefix = state.version === "old" ? "/assets" : "/_assets"
response.setHeader("cache-control", "no-store")
if (pathname === "/sw.js") {
response.setHeader("content-type", "text/javascript")
response.end(state.version === "old" ? legacy : worker)
return
}
if (pathname === `${prefix}/app-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`import "${prefix}/startup-${state.version}.js"`)
return
}
if (pathname === `${prefix}/startup-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`
document.getElementById("root").innerHTML = '<h1>${state.version}</h1><label>Draft<input></label><button>Load older chunk</button><output></output>'
document.querySelector("button").onclick = () => import("/assets/lazy-old.js")
`)
return
}
if (
(pathname === "/assets/lazy-old.js" && state.version === "old") ||
(pathname === "/_assets/retry.js" && state.repaired)
) {
response.setHeader("content-type", "text/javascript")
response.end('document.querySelector("output").textContent = "Older chunk loaded"')
return
}
// Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself.
response.setHeader("content-type", "text/html")
response.end(`<div id="root"></div><script type="module" src="${prefix}/app-${state.version}.js"></script>`)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
try {
await use({
url: `http://127.0.0.1:${address.port}`,
upgrade: () => (state.version = "new"),
repair: () => (state.repaired = true),
})
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
},
})
fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => {
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByLabel("Draft").fill("Keep this unsent prompt")
site.upgrade()
await page.evaluate(async () => {
const cache = await caches.open("opencode-assets")
await cache.put(
"/_assets/startup-new.js",
new Response("<html>stale fallback</html>", {
headers: { "content-type": "text/html" },
}),
)
const changed = new Promise<void>((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }),
)
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) throw new Error("Missing legacy worker")
await registration.update()
await changed
})
await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.getByRole("button", { name: "Load older chunk" }).click()
await expect(page.getByRole("status")).toHaveText("Older chunk loaded")
await page.goto(`${site.url}/workspace/example`)
await expect(page.getByRole("heading")).toHaveText("new")
await expect
.poll(() =>
page.evaluate(async () =>
(await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"),
),
)
.toBe("text/javascript")
})
fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => {
site.upgrade()
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/html",
)
site.repair()
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/javascript",
)
})
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/service-worker",
timeout: 30_000,
use: { browserName: "chromium" },
})
+1
View File
@@ -28,6 +28,7 @@
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
+4 -1
View File
@@ -9,7 +9,10 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
testIgnore: [
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
+3 -3
View File
@@ -1,10 +1,10 @@
/assets/*.js
/_assets/*.js
Content-Type: application/javascript
/assets/*.mjs
/_assets/*.mjs
Content-Type: application/javascript
/assets/*.css
/_assets/*.css
Content-Type: text/css
/*.js
+1 -1
View File
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
{
location: { directory: sdk().directory },
query,
type: dirs === "true" ? "directory" : "file",
type: dirs === "true" ? undefined : "file",
limit: options?.limit,
},
{ signal: options?.signal },
+32 -20
View File
@@ -29,33 +29,44 @@ export default defineConfig({
injectRegister: false,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: false,
skipWaiting: false,
skipWaiting: true,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/],
globPatterns: [
"index.html",
"site.webmanifest",
"favicon*",
"apple-touch-icon*",
"web-app-manifest*",
"assets/index-*.{js,css}",
"assets/session-*.js",
"assets/IBMPlexMono-Text-*.woff2",
"assets/Inter.ttf",
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
],
// Always fetch the current HTML. Precaching a partial build can strand it without its chunks after an upgrade.
navigateFallback: null,
globPatterns: [],
runtimeCaching: [
{
urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith("/assets/"),
urlPattern: ({ url }) =>
url.origin === self.location.origin &&
(url.pathname.startsWith("/_assets/") || url.pathname.startsWith("/assets/")),
handler: "CacheFirst",
options: {
cacheName: "opencode-assets",
cacheableResponse: {
statuses: [200],
},
plugins: [
{
cachedResponseWillBeUsed: async ({ request, cachedResponse }) => {
if (
cachedResponse?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(cachedResponse.headers.get("content-type") ?? "")
)
return cachedResponse
// Keep old tabs' precached chunks usable without retaining their stale HTML navigation handler.
const response = await caches.match(request, {
cacheName: `workbox-precache-v2-${self.location.origin}/`,
})
return response?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null
},
cacheWillUpdate: async ({ response }) =>
response.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null,
},
],
expiration: {
maxEntries: 1000,
},
@@ -72,6 +83,7 @@ export default defineConfig({
port: 3000,
},
build: {
assetsDir: "_assets",
target: "esnext",
sourcemap: true,
},
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+2
View File
@@ -26,6 +26,8 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
if (key.startsWith("_assets/") && assets[key] === undefined)
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
+19 -2
View File
@@ -19,7 +19,7 @@ describe("web UI", () => {
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"_assets/app.js": await Bun.file(asset).text(),
"sw.js": "service worker",
"registerSW.js": "registration",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
@@ -53,7 +53,16 @@ describe("web UI", () => {
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
yield* Effect.forEach(["/_assets/old.js", "/_assets/old.css", "/_assets/missing"], (pathname) =>
Effect.gen(function* () {
const missing = yield* Effect.promise(() => fetch(`${origin}${pathname}`))
expect(missing.status).toBe(404)
expect(missing.headers.get("cache-control")).toBe("no-store")
expect(yield* Effect.promise(() => missing.text())).toBe("")
}),
)
const script = yield* Effect.promise(() => fetch(`${origin}/_assets/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
expect(script.headers.get("content-type")).toContain("javascript")
expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable")
@@ -74,6 +83,14 @@ describe("web UI", () => {
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
expect(dotted.status).toBe(200)
expect(yield* Effect.promise(() => dotted.text())).toContain("embedded")
const legacy = yield* Effect.promise(() => fetch(`${origin}/assets/missing.js`))
expect(legacy.status).toBe(200)
expect(yield* Effect.promise(() => legacy.text())).toContain("embedded")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
+35
View File
@@ -663,6 +663,15 @@ export type SessionLogOutput =
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.streamed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly assistantMessageID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: number
@@ -974,6 +983,19 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly to: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.message.content.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
}
}
| {
readonly id: Event.ID
readonly created: number
@@ -1013,6 +1035,18 @@ export type SessionMessageInput = { readonly sessionID: Session.ID; readonly mes
export type SessionMessageOutput = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: SessionMessageInput) => Effect.Effect<SessionMessageOutput, E>
export type SessionMessageUpdateInput = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<
SessionMessage.AssistantText | SessionMessage.AssistantReasoning | SessionMessage.AssistantTool
>
}
export type SessionMessageUpdateOutput = SessionMessage.Assistant
export type SessionMessageUpdateOperation<E = never> = (
input: SessionMessageUpdateInput,
) => Effect.Effect<SessionMessageUpdateOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1071,6 +1105,7 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly messageUpdate: SessionMessageUpdateOperation<E>
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -86,6 +86,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionMessageUpdateInput,
SessionMessageUpdateOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -651,6 +653,17 @@ const EndpointSessionMessage = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionMessageUpdate = (raw: RawClient["server.session"]) => (input: SessionMessageUpdateInput) =>
preserveEffect<SessionMessageUpdateOutput>()(
raw["session.messageUpdate"]({
params: { sessionID: input["sessionID"], messageID: input["messageID"] },
payload: { content: input["content"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -711,6 +724,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: EndpointSessionMessage(raw),
messageUpdate: EndpointSessionMessageUpdate(raw),
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -80,6 +80,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionMessageUpdateInput,
SessionMessageUpdateOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -929,6 +931,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
messageUpdate: (input: SessionMessageUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionMessageUpdateOutput }>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
body: { content: input["content"] },
successStatus: 200,
declaredStatuses: [404, 400, 409, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
+199 -40
View File
@@ -174,6 +174,12 @@ export type SessionMessageProviderState1 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
@@ -714,6 +720,16 @@ export type SessionStepStarted = {
data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string }
}
export type SessionStepStreamed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.step.streamed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string }
}
export type SessionTextStarted = {
id: string
created: number
@@ -1315,6 +1331,15 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = {
@@ -1743,6 +1768,21 @@ export type SessionToolFailed = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type ModelInfo = {
id: string
modelID: string
@@ -2009,6 +2049,21 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
name: string
executed?: boolean
providerState?: SessionMessageProviderState1
providerResultState?: SessionMessageProviderState1
state:
| SessionMessageToolStateStreaming
| SessionMessageToolStateRunning1
| SessionMessageToolStateCompleted1
| SessionMessageToolStateError1
time: { created: number; ran?: number; completed?: number }
}
export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
@@ -2028,7 +2083,7 @@ export type SessionInboxEnqueued = {
export type SessionMessageAssistant = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number; completed?: number }
time: { created: number; streamed?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
@@ -2043,6 +2098,11 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
| SessionMessageAssistantTool1
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
@@ -2051,6 +2111,50 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 }
export type SessionMessageInfo =
| SessionMessageAgentSelected
| SessionMessageModelSelected
| SessionMessageLocationSwitched
| SessionMessageUser
| SessionMessageSynthetic
| SessionMessageSystem
| SessionMessageSkill
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionMessageContentUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.message.content.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; messageID: string; content: Array<SessionMessageAssistantContentEncoded> }
}
export type IntegrationMethod =
| IntegrationOAuthMethod
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
export type FormCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "form.created"
location?: LocationRef
data: { form: FormInfo1 }
}
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null }
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
@@ -2074,6 +2178,7 @@ export type SessionEventDurable =
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepStreamed
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
@@ -2092,44 +2197,9 @@ export type SessionEventDurable =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionMessageContentUpdated
| SessionUsageRecorded
export type SessionMessageInfo =
| SessionMessageAgentSelected
| SessionMessageModelSelected
| SessionMessageLocationSwitched
| SessionMessageUser
| SessionMessageSynthetic
| SessionMessageSystem
| SessionMessageSkill
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
export type IntegrationMethod =
| IntegrationOAuthMethod
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
export type FormCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "form.created"
location?: LocationRef
data: { form: FormInfo1 }
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null }
}
export type IntegrationInfo = {
id: string
name: string
@@ -2168,6 +2238,7 @@ export type V2Event =
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepStreamed
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
@@ -2191,6 +2262,7 @@ export type V2Event =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionMessageContentUpdated
| FilesystemChanged
| ReferenceUpdated
| PermissionAsked
@@ -2229,6 +2301,8 @@ export type V2Event =
| McpResourcesChanged
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -2789,7 +2863,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3065,7 +3139,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3341,7 +3415,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -4036,6 +4110,91 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionMessageUpdateInput = {
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
readonly content: {
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly metadata: { readonly [x: string]: JsonValue }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
}
>
}["content"]
}
export type SessionMessageUpdateOutput = { data: SessionMessageAssistant }["data"]
export type SessionEnvironmentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
+22
View File
@@ -167,6 +167,10 @@ function createSync() {
has(key: string) {
return state.has(key)
},
pending(key: string) {
const active = state.get(key)
return active !== undefined && active !== true
},
invalidate(key?: string) {
if (key) {
const active = state.get(key)
@@ -723,6 +727,17 @@ export function createData(config: CreateDataInput) {
match.time.completed = event.created
})
return
case "session.message.content.updated": {
if (store.session.message[event.data.sessionID])
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.messageID)
if (assistant) assistant.content = [...event.data.content]
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
void result.session.message.sync(event.data.sessionID)
return
}
case "session.step.started":
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.assistantMessageID)
@@ -735,6 +750,7 @@ export function createData(config: CreateDataInput) {
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.streamed = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -756,6 +772,12 @@ export function createData(config: CreateDataInput) {
})
})
return
case "session.step.streamed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (currentAssistant) currentAssistant.time.streamed = event.created
})
return
case "session.step.ended": {
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
+30 -1
View File
@@ -1,4 +1,4 @@
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
import type { ExperimentalPersistentPtyConnectTokenInput, OpenCodeClient, PtyConnectTokenInput } from "../promise"
export type PtyClientOptions = {
readonly url: string
@@ -11,6 +11,13 @@ export type PtyConnectInput = {
readonly cursor?: number
}
export type PersistentPtyConnectInput = {
readonly ptyID: ExperimentalPersistentPtyConnectTokenInput["ptyID"]
readonly cursor: number
readonly attachmentID: string
readonly takeover?: boolean
}
export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PtyConnectInput) {
@@ -32,3 +39,25 @@ export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions)
},
}
}
export function createPersistentPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PersistentPtyConnectInput) {
const token = await api.experimental.persistentPty.connectToken({
ptyID: input.ptyID,
"x-opencode-ticket": "1",
})
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
url.searchParams.set("ticket", token.ticket)
url.searchParams.set("cursor", String(input.cursor))
url.searchParams.set("attachment_id", input.attachmentID)
url.searchParams.set("takeover", String(input.takeover ?? false))
url.searchParams.set("input_protocol", "1")
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
const socket = options.openSocket?.(url) ?? new WebSocket(url)
socket.binaryType = "arraybuffer"
return socket
},
}
}
+71
View File
@@ -414,6 +414,77 @@ test("loads bounded message pages", async () => {
}
})
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
let requests = 0
const content = [
{ type: "text" as const, text: "replacement" },
{ type: "reasoning" as const, text: "reasoning", time: { created: 3 } },
]
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
const current = ++requests
if (current === 2) await release.promise
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: current === 3 ? content : [{ type: "text", text: "original" }],
time: { created: 1, completed: 2 },
},
],
cursor: {},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
setup.data.session.message.invalidate("ses_refresh")
const stale = setup.data.session.message.sync("ses_refresh")
await wait(() => requests === 2)
const updated: OpenCodeEvent = {
id: "evt_message_updated",
created: 3,
type: "session.message.content.updated",
durable: { aggregateID: "ses_refresh", seq: 3, version: 1 },
data: {
sessionID: "ses_refresh",
messageID: "msg_assistant",
content,
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
release.resolve()
await stale
await wait(() => requests === 3)
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
+34 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "../src/promise"
import { createPtyClient } from "../src/solid"
import { createPersistentPtyClient, createPtyClient } from "../src/solid"
describe("createPtyClient", () => {
test("mints an authenticated ticket before opening the terminal socket", async () => {
@@ -66,3 +66,36 @@ describe("createPtyClient", () => {
expect(opened).toBe(false)
})
})
describe("createPersistentPtyClient", () => {
test("mints an authenticated ticket before opening the persistent terminal socket", async () => {
let request: Request | undefined
let socketURL: URL | undefined
const socket = { binaryType: "blob" } as unknown as WebSocket
const api = OpenCode.make({
baseUrl: "https://server.example/base",
headers: { Authorization: "Basic credential" },
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: { ticket: "persistent-ticket", expires_in: 60 } })
},
})
const pty = createPersistentPtyClient(api, {
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return socket
},
})
expect(await pty.connect({ ptyID: "pty_1", cursor: 42, attachmentID: "attachment_1", takeover: true })).toBe(socket)
expect(request?.method).toBe("POST")
expect(request?.url).toBe("https://server.example/api/experimental/persistent-pty/pty_1/connect-token")
expect(request?.headers.get("authorization")).toBe("Basic credential")
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
expect(socketURL?.toString()).toBe(
"wss://server.example/api/experimental/persistent-pty/pty_1/connect?ticket=persistent-ticket&cursor=42&attachment_id=attachment_1&takeover=true&input_protocol=1",
)
expect(socket.binaryType).toBe("arraybuffer")
})
})
+1 -2
View File
@@ -105,7 +105,6 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -119,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+1
View File
@@ -55,6 +55,7 @@ export function map(input: MapInput): Mapping | undefined {
}
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/groq":
case "@ai-sdk/togetherai":
return {
package: `@opencode-ai/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
+2
View File
@@ -337,6 +337,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name === "@ai-sdk/deepinfra" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/groq" ||
name === "@ai-sdk/togetherai" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
@@ -349,6 +350,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name === "@opencode-ai/ai/providers/deepinfra" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/groq" ||
name === "@opencode-ai/ai/providers/togetherai" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
+70 -91
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
import { KV } from "./kv.js"
import { ModelsDevCache } from "./models-dev/cache.js"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
@@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
// Digest of the raw body, persisted so refresh() can skip republishing a
// byte-identical catalog. Optional for entries written before it existed.
digest: Schema.optional(Schema.String),
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
@@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
return bundledCache
}),
),
)
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export function bodyDigest(text: string) {
return Hash.sha256(text)
}
@@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const kv = yield* KV.Service
const cache = yield* ModelsDevCache.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
digest: cached.value.digest,
}
if (value !== undefined) yield* kv.remove(key)
})
const state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
@@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
)
})
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.orElseSucceed(() => undefined),
// Persistence only seeds a runtime. Refresh never reloads this seed over
// a catalog that was successfully fetched but could not be saved.
// The service owns initialization so cancelling a reader cannot cancel it.
const initialized = yield* Effect.forkScoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const stored = options?.file
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
: yield* cache.read(source)
if (!stored) return
const data = normalize(yield* decodeCatalog(stored.body))
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
),
)
: Effect.undefined
if (state.data) return
if (options?.snapshot !== false) {
Object.assign(state, yield* bundledSnapshot)
return
}
if (!fetch) state.data = []
}).pipe(Effect.orDie),
)
// The bundled snapshot is the boot-time floor for the catalog; the
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
const update = Effect.fn("ModelsDev.update")(function* (force = false) {
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
const digest = bodyDigest(text)
if (!force && state.data && state.digest === digest) {
state.checkedAt = Date.now()
return state.data
}
const data = normalize(yield* decodeCatalog(text))
Object.assign(state, { data, digest, checkedAt: Date.now() })
yield* bus.publish(ModelsDev.Event.Refreshed, {})
// Adopt and publish before attempting persistence. A missing or broken
// cache must not prevent live updates, including in filesystem-less runtimes.
if (!options?.file)
yield* cache.write(source, text).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return data
})
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
yield* writeCache(text)
return catalog
const get = Effect.fn("ModelsDev.get")(function* () {
yield* Fiber.join(initialized)
if (state.data) return state.data
return yield* lock
.withPermit(
Effect.gen(function* () {
return state.data ?? (yield* update())
}),
)
.pipe(Effect.orDie)
})
const populate = Effect.gen(function* () {
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return bundled
if (!fetch) return []
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
yield* lock
.withPermit(
Effect.gen(function* () {
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === bodyDigest(text)) return
yield* decodeCatalog(text)
yield* writeCache(text)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
yield* Fiber.join(initialized)
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
yield* update(force)
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
Effect.orDie,
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
),
)
})
@@ -710,7 +689,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
})
}
+60
View File
@@ -0,0 +1,60 @@
export * as ModelsDevCache from "./cache.js"
import path from "path"
import { Context, Effect, FileSystem, Layer, Option } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
export interface Entry {
readonly body: string
readonly updatedAt: number
}
export interface Interface {
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDevCache") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")
const read = Effect.fn("ModelsDevCache.read")(
function* (source: string) {
const file = path.join(directory, `${Hash.fast(source)}.json`)
const body = yield* fs.readFileString(file)
const info = yield* fs.stat(file)
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
},
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
)
const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
yield* fs.makeDirectory(directory, { recursive: true })
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
yield* fs.writeFileString(temporary, body)
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
}, Effect.scoped)
return Service.of({ read, write })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [LayerNodePlatform.filesystem, Global.node],
})
export const disabledLayer = Layer.succeed(
Service,
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
)
+1 -1
View File
@@ -161,7 +161,7 @@ const layer = Layer.effect(
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
active.clear()
yield* State.batch(Scope.close(scope, exit))
yield* State.batch(Scope.close(scope, exit), { flush: false })
}),
)
-2
View File
@@ -11,7 +11,6 @@ import { GatewayPlugin } from "./provider/gateway.js"
import { GithubCopilotPlugin } from "./provider/github-copilot.js"
import { GitLabPlugin } from "./provider/gitlab.js"
import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { GroqPlugin } from "./provider/groq.js"
import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
@@ -45,7 +44,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GithubCopilotPlugin,
GitLabPlugin,
GoogleVertexPlugin,
GroqPlugin,
KiloPlugin,
LLMGatewayPlugin,
LMStudioPlugin,
-10
View File
@@ -1,10 +0,0 @@
import { createProviderPlugin } from "./factory.js"
export const GroqPlugin = createProviderPlugin({
id: "opencode.provider.groq",
package: "@ai-sdk/groq",
load: async (options) => {
const { createGroq } = await import("@ai-sdk/groq")
return createGroq(options)
},
})
+1
View File
@@ -62,6 +62,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
"@opencode-ai/ai/providers/google-vertex/messages",
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
],
["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
+57
View File
@@ -139,6 +139,27 @@ export class CompactionConflictError extends Schema.TaggedError<CompactionConfli
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
"Session.MessageNotAssistantError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
"Session.MessageIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
"Session.MessageToolIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
@@ -193,6 +214,19 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Info | undefined>
readonly updateMessage: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
readonly content: readonly SessionMessage.AssistantContent[]
}) => Effect.Effect<
SessionMessage.Assistant,
| NotFoundError
| MessageNotFoundError
| BusyError
| MessageNotAssistantError
| MessageIncompleteError
| MessageToolIncompleteError
>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
@@ -560,6 +594,29 @@ const layer = Layer.effect(
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
updateMessage: Effect.fn("Session.updateMessage")(function* (input) {
const ref = { sessionID: input.sessionID, messageID: input.messageID }
yield* result.get(ref.sessionID)
if ((yield* execution.active).has(ref.sessionID)) return yield* new BusyError({ sessionID: ref.sessionID })
const message = yield* result.message(ref)
if (!message) return yield* new MessageNotFoundError(ref)
if (message.type !== "assistant") return yield* new MessageNotAssistantError(ref)
if (!message.time.completed) return yield* new MessageIncompleteError(ref)
if (
input.content.some(
(content) =>
content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running"),
)
)
return yield* new MessageToolIncompleteError(ref)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
...ref,
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
})
const updated = yield* result.message(ref)
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
return updated
}),
context: Effect.fn("Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
+1 -1
View File
@@ -94,7 +94,7 @@ export const layer = Layer.effect(
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
if (result.type === "complete") return
if (result._tag === "Complete") return
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
+13 -1
View File
@@ -1,5 +1,5 @@
import { castDraft, produce, type WritableDraft } from "immer"
import { DateTime, Effect, Match, pipe } from "effect"
import { DateTime, Effect, Match, pipe, Schema } from "effect"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
@@ -71,6 +71,12 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void,
"session.viewed": () => Effect.void,
"session.message.content.updated": (event) =>
updateOwnedAssistant(event.data.messageID, (draft) => {
draft.content = castDraft(
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(event.data.content),
)
}),
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) =>
Effect.gen(function* () {
@@ -204,6 +210,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.time.streamed = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -232,6 +239,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}),
)
}),
"session.step.streamed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.streamed = created
})
},
"session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
+1 -1
View File
@@ -45,7 +45,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
return decline ? Result.succeed(decline) : Result.fail(cause)
}
interface Prepared {
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/**
+2
View File
@@ -581,6 +581,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
})
yield* bus.project(SessionEvent.MessageContentUpdated, (event) => run(db, event))
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
@@ -659,6 +660,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* bus.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Step.Streamed, (event) => run(db, event))
yield* bus.project(SessionEvent.Step.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
+6 -4
View File
@@ -1,7 +1,7 @@
export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai"
import { Context, Effect } from "effect"
import { Context, Data, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
@@ -19,9 +19,11 @@ export type RunError =
export type Continuation = { readonly step: number }
export type DrainResult =
| { readonly type: "complete" }
| { readonly type: "moved"; readonly continuation?: Continuation }
export type DrainResult = Data.TaggedEnum<{
Complete: {}
Moved: { readonly continuation?: Continuation }
}>
export const DrainResult = Data.taggedEnum<DrainResult>()
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
+93 -498
View File
@@ -1,20 +1,9 @@
export * as SessionRunnerLLM from "./llm.js"
import {
LLMClient,
AIError,
InvalidProviderOutputReason,
LLMEvent,
Message,
isContextOverflowFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Message } from "@opencode-ai/ai"
import { Cause, Config, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
@@ -26,100 +15,18 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { Service, type Continuation } from "./index.js"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
import { StepFailedError } from "../error.js"
import { toSessionError } from "../to-session-error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js"
import { SessionStep } from "./step.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { Tool } from "../../tool.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean; readonly step: number }
Retry: { readonly step: number }
Continue: {
readonly cause: AIError
readonly error: SessionRunnerRetry.RetryableFailure["error"]
readonly step: number
}
RecoverFull: { readonly step: number }
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
}>
const CallOutcome = Data.taggedEnum<CallOutcome>()
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
const isInterruptedStream = (failure: AIError) => {
if (failure.reason._tag === "InvalidProviderOutput")
return failure.reason.classification === "incomplete-stream"
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
return false
}
/**
* Classifies how the owned tool fibers ended. Interrupts abort the step; a user decline
* settles its own call and then aborts the step; a defect from a tool implementation
* becomes a failed tool call the model can read; a typed infrastructure failure must
* fail the assistant and then the drain.
*/
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>, never>,
calls: ReadonlyArray<ToolCall>,
) => {
// Exits align with calls by construction: one owned fiber per accepted local call.
const exits = settled._tag === "Success" ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
// The first non-interrupt, non-decline failure, rebuilt without decline reasons so the
// drain's error channel never carries a decline.
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason)
? isDecline(reason.error)
? []
: // A typed failure here broke the ExecuteError contract (the per-fiber
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
// keeps it from being dropped, which would leave its call unsettled forever.
[Cause.makeDieReason(reason.error)]
: [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
.at(0)
return {
interrupted: causes.some(Cause.hasInterrupts),
declines,
failure,
}
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
@@ -127,17 +34,15 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
const steps = yield* SessionStep.make
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
Config.withDefault(false),
Effect.orDie,
@@ -166,10 +71,7 @@ const layer = Layer.effect(
})
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
* Execution lifecycle is published per busy period by SessionExecution, not here.
*/
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
@@ -179,30 +81,25 @@ const layer = Layer.effect(
let force = input.force
let continuation = input.continuation
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable)))
return { type: "complete" as const }
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
// Between-turn control items run under any drain scope: scope gates which user
// input may promote, not whether admitted housekeeping runs. Steered control
// items go ahead of any queued input; only a queue-delivered control item
// parked behind a queued prompt is not the next eligible item.
// Scope gates input promotion, not a between-step control that is next in line.
if (yield* runPendingCompaction(input.sessionID, "input")) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
return DrainResult.Complete()
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result
if (result._tag === "Moved") return result
force = false
continuation = undefined
}
})
/** Work this drain may perform: scoped input, or a between-turn control item next in line. */
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
if (promotable === "input") return false
@@ -210,31 +107,20 @@ const layer = Layer.effect(
return next?.type === "compaction" || next?.type === "move"
})
/**
* Runs logical steps until no tool result or newly admitted steer requires another
* model call. Queued inputs remain pending until the current model work reaches idle.
*/
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) {
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1
let next = continuation
// The drain admitted this work, so the first step always runs — even after a
// control item consumed at this boundary (unlike drain's one-shot force).
let first = true
// Every boundary has the same shape: control items first, then one exit decision,
// then the model. The turn continues only while the first step, a continuation, or
// steer input is owed. Deciding after control items means consuming the last
// steered compaction ends the turn instead of issuing an input-free model call.
while (true) {
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer")))
return { type: "complete" as const }
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
const result = yield* runStep(sessionID, promotable, step)
first = false
promotable = "steer"
@@ -243,390 +129,100 @@ const layer = Layer.effect(
}
})
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* (
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
step: number,
) {
// Minting message identity before any attempt lets retries resume the same durable
// message. A compaction restart re-mints: the old message is stranded behind the new
// compaction boundary, so the rebuilt step needs identity inside the new epoch.
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(
SessionRunnerRetry.schedule(bus, sessionID, () => assistantMessageID),
)
/**
* Consumes one retry allowance: sleeps the scheduled backoff, or publishes
* Step.Failed and fails once attempts are exhausted. The step loop performs
* the retry itself on the next iteration.
*/
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
retry(failure).pipe(
Effect.as(CallOutcome.Retry({ step: failure.step })),
Pull.catchDone(() =>
bus
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID,
error: failure.error,
})
.pipe(Effect.andThen(Effect.fail(failure.cause))),
),
)
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
let recoverContinuation = true
while (true) {
const outcome = yield* callModel(
sessionID,
currentPromotable,
currentStep,
recoverOverflow,
recoverContinuation,
assistantMessageID,
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
if (outcome._tag === "Continue") {
yield* retry(
new SessionRunnerRetry.RetryableFailure({
cause: outcome.cause,
error: outcome.error,
step: outcome.step,
}),
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
const selected = yield* context.select(sessionID)
// A blocked initial instruction baseline must leave admitted input pending.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = currentPromotable
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
: 0
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
assistantMessageID = SessionMessage.ID.create()
}
if (outcome._tag === "Restart") {
if (outcome.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
}
if (outcome._tag === "RecoverFull") recoverContinuation = false
// Neither a retry nor a compaction restart re-promotes input.
currentStep = promoted > 0 ? 1 : currentStep
currentPromotable = undefined
currentStep = outcome.step
const loaded = yield* context.load(selected)
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
yield* diagnosePromptCache(sessionID, prepared.request)
const outcome = yield* steps.attempt({
sessionID,
assistantMessageID,
agent: loaded.agent.id,
model: loaded.model,
prepared,
toolsDisabled: stepLimitReached,
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
Effect.gen(function* () {
if (outcome._tag === "Retry")
yield* bus.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
return yield* outcome.cause
}),
),
)
if (outcome._tag === "Continue") {
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}
continue
}
if (outcome._tag === "Compacted") {
recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
continue
}
recoverContinuation = false
}
})
/**
* Prepares and runs at most one model call, executes its local tools, and durably
* settles the step. Compaction may instead request that the logical step restart.
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable | undefined,
step: number,
recoverOverflow: boolean,
recoverContinuation: boolean,
assistantMessageID: SessionMessage.ID,
) {
const selected = yield* context.select(sessionID)
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
const { session, agent } = loaded
const resolved = loaded.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, resolved }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
return yield* new StepFailedError({ error: compacted.error })
}
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: agent.info,
model: resolved,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
yield* diagnosePromptCache(session.id, prepared.request)
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return prepared.executeTool(input)
}
// Every local tool call forked here is owned until it reaches one durable settlement.
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(bus, {
sessionID: session.id,
agent: agent.id,
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
providerMetadataKey: transcript.providerMetadataKey,
snapshot: startSnapshot,
assistantMessageID,
})
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
return { snapshot, files }
})
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
rawFinish: finish.rawFinish,
providerState: finish.providerState,
...stepUsage(finish),
...end,
})
})
// Concurrent writers, no lock: the provider loop and each tool fiber publish
// durable events unserialized. This is safe because every publisher method commits
// its state marks synchronously before its first await (see publish-llm-event.ts),
// every required event order is per-source (each source is one sequential fiber),
// and a fiber's events are causally after its own Tool.Called: the fork happens
// below that publish. Cross-source order is unconstrained; either interleaving is
// a truthful history of concurrent work.
//
// The stream is defined here but runs inside the settlement mask below: publish each
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (
LLMEvent.is.providerError(event) &&
isContextOverflowFailure(event) &&
!publisher.record().outputStarted
) {
overflowFailure = event
return
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(
executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
call: event,
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
).pipe(
// The fiber owns its call: it publishes its own completion, masked so a
// finished execution always reaches its durable settlement.
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(publisher.flush()),
)
// Settle: only the stream and the fiber joins are interruptible (restore); every
// other line is protected so a started call always reaches one durable outcome.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
// Join every owned tool run first: await all exits, not just the first failure.
// Afterwards no fiber is alive, settlement is the only writer, and the record
// is final. A failed join means the waiting itself was interrupted, so the runs
// we abandoned are interrupted before settlement closes them out.
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
// A context overflow before any assistant output is recoverable: compact and
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
compaction.enabled() &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(compaction.compact(compactionInput))).status === "completed"
)
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
// An unrecovered held-back overflow becomes the step's durable provider error.
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const unknownFinish =
stream._tag === "Success" && publisher.record().finish?.finish === "unknown"
? new AIError({
module: "session",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (
recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!publisher.record().outputStarted
)
return CallOutcome.RecoverFull({ step: currentStep })
if (
llmFailure &&
llmError &&
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.record().outputStarted
) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* publisher.startAssistant()
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
error: llmError,
step: currentStep,
})
}
if (llmError) yield* publisher.failAssistant(llmError)
// Close every unsettled call with the reason it could not settle truthfully,
// and fail the assistant when the step itself cannot complete. A declined call
// settles with its own reason before the generic sweeps.
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
yield* publisher.failAssistant(STEP_INTERRUPTED)
}
if (tools.failure !== undefined) {
const error = toSessionError(Cause.squash(tools.failure))
yield* publisher.failUnsettledTools(error)
}
// Local calls have joined, so the remaining sweeps only close hosted calls the
// provider promised but never resolved.
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
// A clean stream that still left hosted calls unresolved fails the step itself.
if (stream._tag === "Success" && !publisher.record().providerFailed) {
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
}
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
const record = publisher.record()
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
if (record.failure) {
const end = yield* captureStepEnd()
yield* publisher.publishStepFailure({
...(record.finish ? stepUsage(record.finish) : {}),
...end,
})
}
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return CallOutcome.Continue({
cause: llmFailure,
error: llmError,
step: currentStep,
})
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return CallOutcome.Completed({
// A local call or malformed tool input requires another model step, unless
// this step already exhausted the agent's allowance.
needsContinuation:
!stepLimitReached && record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
step: currentStep,
})
}),
)
}, Effect.scoped)
/** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
@@ -698,7 +294,6 @@ const layer = Layer.effect(
)
})
/** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Clock, Effect } from "effect"
import { Clock, Effect, Iterable } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -39,13 +39,7 @@ export interface StepRecord {
readonly providerState?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
readonly id: string
readonly name: string
readonly called: boolean
readonly settled: boolean
readonly providerExecuted: boolean
}>
readonly needsContinuation: boolean
}
/** Derives canonical model content from a provider-hosted tool result. */
@@ -85,7 +79,6 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
type ToolState = {
readonly assistantMessageID: SessionMessage.ID
readonly name: string
called: boolean
settled: boolean
@@ -103,6 +96,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
let stepFailed = false
let providerFailed = false
let outputStarted = false
let stepStreamed = false
let stepFailure: SessionError.Error | undefined
let stepSettlement: StepRecord["finish"]
@@ -120,6 +114,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
const currentAssistantMessageID = () =>
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
const streamed = Effect.fnUntraced(function* () {
if (stepStreamed) return
stepStreamed = true
yield* bus.publish(SessionEvent.Step.Streamed, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
})
})
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
const fragments = (
name: string,
@@ -241,7 +243,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id,
text: value,
})
@@ -260,9 +262,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
readonly providerExecuted?: boolean
}) {
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
const assistantMessageID = yield* startAssistant()
yield* startAssistant()
const tool: ToolState = {
assistantMessageID,
name: event.name,
called: false,
settled: false,
@@ -305,7 +306,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id: event.id,
error: {
type: "tool.input-json",
@@ -324,7 +325,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id,
error,
...failureSnapshot(tool, metadata),
@@ -374,11 +375,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
)
const assistantMessageIDForTool = (id: string) => {
const tool = tools.get(id)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
@@ -446,7 +442,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.providerExecuted = event.providerExecuted === true
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
@@ -472,7 +468,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
if (event.result.type === "error") {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id: event.id,
error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool),
@@ -483,7 +479,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id: event.id,
content: hostedContent(event.result),
executed,
@@ -500,7 +496,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id: event.id,
error:
event.message === `Unknown tool: ${event.name}`
@@ -542,7 +538,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id,
metadata: update,
})
@@ -565,7 +561,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
assistantMessageID,
id,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
@@ -583,21 +579,19 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
publishStepFailure,
failUnsettledTools,
hasProviderError: () => providerFailed,
hasStarted: () => stepStarted,
/** Immutable snapshot of everything recorded for this step so far. */
record: (): StepRecord => ({
outputStarted,
providerFailed,
failure: stepFailure,
finish: stepSettlement,
calls: Array.from(tools, ([id, tool]) => ({
id,
name: tool.name,
called: tool.called,
settled: tool.settled,
providerExecuted: tool.providerExecuted,
})),
needsContinuation: Iterable.some(
tools.values(),
(tool) => !tool.providerExecuted && (tool.called || tool.settled),
),
}),
startAssistant,
assistantMessageID: assistantMessageIDForTool,
streamed,
}
}
+12 -16
View File
@@ -2,17 +2,17 @@ export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { Duration, Effect, Schedule } from "effect"
import { Bus } from "../../bus.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
export interface Input {
readonly cause: AIError
readonly error: SessionError.Error
readonly step: number
}> {}
readonly assistantMessageID: SessionMessage.ID
}
export function isRetryable(error: AIError) {
const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined
@@ -40,29 +40,25 @@ export function isRetryable(error: AIError) {
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
const retryAfter = (input: Input) => {
if (input.cause.reason._tag === "RateLimit" || input.cause.reason._tag === "ProviderInternal")
return input.cause.reason.retryAfterMs
return undefined
}
export const schedule = (
bus: Bus.Interface,
sessionID: SessionSchema.ID,
assistantMessageID: () => SessionMessage.ID,
) =>
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure)
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: assistantMessageID(),
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
+293
View File
@@ -0,0 +1,293 @@
export * as SessionStep from "./step.js"
import {
AIError,
InvalidProviderOutputReason,
LLMClient,
LLMEvent,
isContextOverflowFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Agent } from "../../agent.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionSchema } from "../schema.js"
import { toSessionError } from "../to-session-error.js"
import { SessionUsage } from "../usage.js"
import { SessionRunnerModel } from "./model.js"
import { createLLMEventPublisher } from "./publish-llm-event.js"
import { SessionRunnerRetry } from "./retry.js"
export type Outcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean }
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
RecoverFull: {}
Compacted: {}
}>
const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly toolsDisabled: boolean
readonly recoverContinuation: boolean
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
readonly recoverOverflow: Effect.Effect<boolean>
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
/** Captures Location-scoped dependencies without introducing another service or execution loop. */
export const make = Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const snapshots = yield* Snapshot.Service
const toolOutput = yield* ToolOutput.Service
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(bus, {
sessionID: input.sessionID,
assistantMessageID: input.assistantMessageID,
agent: input.agent,
model: input.model.ref,
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
snapshot: startSnapshot,
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
if (input.toolsDisabled) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
})
}
// Provider and tool fibers retain per-source order without a shared writer queue.
// A local execution starts only after its Tool.Called publication completes.
let overflowFailure: ProviderErrorEvent | undefined
// Read to the end, not just the finish event, so the next request can reuse this response.
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (
LLMEvent.is.providerError(event) &&
isContextOverflowFailure(event) &&
!publisher.record().outputStarted
) {
overflowFailure = event
return
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(executeTool(event)).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(publisher.flush()),
)
// Keep the final tool and Step events uninterruptible, even when the work itself is cancelled.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
if (
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(input.recoverOverflow))
)
return Outcome.Compacted()
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
stream._tag === "Success" && recorded.finish?.finish === "unknown"
? new AIError({
module: "session",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
// Retry state projects onto the existing assistant, even before it has produced output.
yield* publisher.startAssistant()
return Outcome.Retry({ cause: llmFailure, error: llmError })
}
if (llmError) yield* publisher.failAssistant(llmError)
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
? toSessionError(Cause.squash(tools.failure))
: recorded.providerFailed
? TOOLS_INTERRUPTED
: undefined
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
// All local fibers have joined; only provider-hosted results can still be missing.
if (llmError || (stream._tag === "Success" && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const record = publisher.record()
if (record.finish || record.failure) {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
...usage,
snapshot,
files,
})
}
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: !input.toolsDisabled && record.needsContinuation,
})
}),
)
}, Effect.scoped)
return { attempt }
})
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
const isInterruptedStream = (failure: AIError) => {
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
return false
}
/** Keep every joined exit associated with its call; a decline is not an infrastructure failure. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
calls: ReadonlyArray<ToolCall>,
) => {
const exits = settled._tag === "Success" ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeDieReason(reason.error)]) : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
.at(0)
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
}
+15 -5
View File
@@ -32,6 +32,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly reloads: Set<Reload>
}
@@ -40,14 +41,15 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active) return yield* effect
const batch: Batch = { active: true, reloads: new Set() }
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
}
@@ -81,6 +83,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let generation = 0
let requestedAt = 0
let running = false
let closed = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
@@ -90,6 +93,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
@@ -122,6 +126,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
@@ -131,7 +136,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
return yield* Deferred.await(done)
yield* Deferred.await(done)
})
return {
@@ -152,6 +157,11 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
+2 -2
View File
@@ -271,12 +271,12 @@ function schemaMakeError(error: unknown) {
}
const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
/^[A-Za-z0-9_-]{1,64}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({
+2 -2
View File
@@ -61,8 +61,8 @@ describe("AISDKNative", () => {
})
})
test("maps Cerebras, DeepInfra, and Together AI settings, headers, and reasoning options to native providers", () => {
for (const name of ["cerebras", "deepinfra", "togetherai"]) {
test("maps Cerebras, DeepInfra, Groq, and Together AI settings, headers, and reasoning options to native providers", () => {
for (const name of ["cerebras", "deepinfra", "groq", "togetherai"]) {
expect(
map(`@ai-sdk/${name}`, {
apiKey: "secret",
+16
View File
@@ -915,6 +915,12 @@ describe("ModelResolver", () => {
{ reasoning: { effort: "high" } },
{ reasoning: { effort: "high" } },
],
[
"@ai-sdk/groq",
"@opencode-ai/ai/providers/groq",
{ reasoningEffort: "high", parallelToolCalls: false },
{ reasoningEffort: "high", parallelToolCalls: false },
],
[
"@ai-sdk/togetherai",
"@opencode-ai/ai/providers/togetherai",
@@ -973,6 +979,7 @@ describe("ModelResolver", () => {
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
@@ -1102,6 +1109,11 @@ describe("ModelResolver", () => {
const togetherai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/togetherai"), { settings: { reasoningEffort: "high" } }),
)
const groq = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/groq"), {
settings: { reasoningEffort: "high", parallelToolCalls: false },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
@@ -1132,6 +1144,10 @@ describe("ModelResolver", () => {
expect(togetherai.route.id).toBe("togetherai-chat")
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
expect(String(togetherai.provider)).toBe("test-provider")
expect(groq.route.id).toBe("groq-chat")
expect(groq.route.protocol).toBe("groq-chat")
expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false })
expect(String(groq.provider)).toBe("test-provider")
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
reasoningEffort: "high",
+111
View File
@@ -0,0 +1,111 @@
import path from "path"
import { expect } from "bun:test"
import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
const source = "https://models.opencode.ai"
const it = testEffect(
LayerNode.compile(LayerNode.group([ModelsDevCache.node, LayerNodePlatform.filesystem, Global.node]), [
[Global.node, tempGlobalLayer],
]),
)
it.live("returns undefined for a missing catalog", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
expect(yield* cache.read(source)).toBeUndefined()
}),
)
it.live("persists raw catalog bodies larger than 2 MB with the file mtime", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const body = ` {\n "payload": "${"x".repeat(2 * 1024 * 1024)}"\n}\n`
const file = path.join(global.cache, "models-dev", `${Hash.fast(source)}.json`)
const modified = new Date("2026-01-01T00:00:00Z")
yield* cache.write(source, body)
expect(yield* fs.readFileString(file)).toBe(body)
yield* fs.utimes(file, modified, modified)
expect(yield* cache.read(source)).toEqual({ body, updatedAt: modified.getTime() })
}),
)
it.live("isolates catalogs by source including the default source", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const custom = "https://models.example.com"
yield* cache.write(source, "default catalog")
expect(yield* cache.read(custom)).toBeUndefined()
yield* cache.write(custom, "custom catalog")
expect((yield* cache.read(source))?.body).toBe("default catalog")
expect((yield* cache.read(custom))?.body).toBe("custom catalog")
}),
)
it.live("replaces an existing catalog without leaving temporary files", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
yield* cache.write(source, "old catalog")
yield* cache.write(source, "new catalog")
expect((yield* cache.read(source))?.body).toBe("new catalog")
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
}),
)
it.live("cleans up temporary files and preserves platform errors when replacement fails", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")
const file = path.join(directory, `${Hash.fast(source)}.json`)
yield* fs.makeDirectory(file, { recursive: true })
const error = yield* cache.write(source, "new catalog").pipe(Effect.flip)
expect(error._tag).toBe("PlatformError")
expect(yield* fs.readDirectory(directory)).toEqual([`${Hash.fast(source)}.json`])
expect((yield* fs.stat(file)).type).toBe("Directory")
expect((yield* cache.read(source).pipe(Effect.flip))._tag).toBe("PlatformError")
}),
)
it.live("keeps the old catalog readable and cleans up an interrupted replacement", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const staged = yield* Deferred.make<string>()
yield* cache.write(source, "old catalog")
// Pause only the commit; staging and cleanup still use the real filesystem.
const writer = yield* ModelsDevCache.Service.pipe(
Effect.flatMap((service) => service.write(source, "new catalog")),
Effect.provide(Layer.fresh(ModelsDevCache.layer)),
Effect.provideService(FileSystem.FileSystem, {
...fs,
rename: (file) => Deferred.succeed(staged, file).pipe(Effect.andThen(Effect.never)),
}),
Effect.forkScoped,
)
const temporary = yield* Deferred.await(staged)
expect(yield* fs.readFileString(temporary)).toBe("new catalog")
expect((yield* cache.read(source))?.body).toBe("old catalog")
yield* Fiber.interrupt(writer)
expect((yield* cache.read(source))?.body).toBe("old catalog")
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
}),
)
+285 -61
View File
@@ -1,18 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import path from "path"
import { Deferred, Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { Provider } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const cacheKey = "models-dev:catalog"
const source = "https://models.opencode.ai"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -166,41 +168,40 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
)
interface MockCache {
readonly values: Map<string, KV.Value>
readonly values: Map<string, ModelsDevCache.Entry>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
const makeMockCache = (cache: MockCache) =>
Layer.succeed(ModelsDevCache.Service, {
read: (source) => Effect.sync(() => cache.values.get(source)),
write: (source, body) =>
Effect.sync(() => cache.values.set(source, { updatedAt: Date.now(), body })).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
const buildLayer = (
state: Ref.Ref<MockState>,
cache: MockCache,
options: ModelsDev.Options = { fetch: false },
persistence = makeMockCache(cache),
) =>
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
[ModelsDevCache.node, persistence],
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
const makeFailingWriteCache = (cache: MockCache) =>
Layer.succeed(ModelsDevCache.Service, {
read: (source) => Effect.sync(() => cache.values.get(source)),
write: () => Effect.die(new Error("Cache write failed")),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
cache.values.set(source, { updatedAt, body: text })
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
@@ -218,7 +219,7 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
it.live("get() returns normalized snapshots from the persisted cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
@@ -259,7 +260,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
it.live("get() returns empty catalog when the cache, fetch, and bundled snapshot are unavailable", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -272,7 +273,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
it.live("get() falls back to the bundled snapshot when the cache is empty and fetch is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -289,7 +290,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted cache by fetching a fresh catalog", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCacheText(cache, "{")
@@ -297,31 +298,247 @@ describe("ModelsDev Service", () => {
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
it.live("get() still populates the catalog when persistence fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const layer = buildLayer(state, cache, { fetch: true, snapshot: false }, makeFailingWriteCache(cache))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
expect(cache.values.has(source)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
for (const seeded of [false, true]) {
it.live(`refresh adopts and publishes the fetched catalog when persistence fails (seeded=${seeded})`, () =>
Effect.gen(function* () {
const cache = makeCache()
if (seeded) writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const bus = yield* Bus.Service
expect(yield* models.get()).not.toEqual(fixture2Snapshot)
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runDrain,
Effect.andThen(() => models.get()),
Effect.forkScoped({ startImmediately: true }),
)
yield* models.refresh(true)
expect(yield* Fiber.join(event)).toEqual(fixture2Snapshot)
expect(yield* models.get()).toEqual(fixture2Snapshot)
yield* models.refresh()
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: false }, makeFailingWriteCache(cache))))
expect(cache.values.get(source)?.body).toBe(seeded ? JSON.stringify(fixture) : undefined)
}),
)
}
it.live("a failed cache read falls back to the bundled snapshot without blocking refresh", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
expect((yield* models.get()).length).toBeGreaterThan(0)
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixtureSnapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
cache,
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () => Effect.die(new Error("Cache read failed")),
write: () => Effect.void,
}),
),
),
)
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}),
)
it.live("refresh publishes the live catalog while its cache write is still pending", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const writing = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const bus = yield* Bus.Service
expect(yield* models.get()).toEqual(fixtureSnapshot)
const event = yield* bus
.subscribe(ModelsDev.Event.Refreshed)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
const refresh = yield* models.refresh(true).pipe(Effect.forkScoped)
yield* Deferred.await(writing)
yield* Fiber.join(event).pipe(Effect.timeout("1 second"))
expect(yield* models.get()).toEqual(fixture2Snapshot)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(refresh)
}).pipe(
Effect.provide(
buildLayer(
state,
cache,
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () => Effect.succeed(cache.values.get(source)),
write: () => Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
}),
),
),
)
}),
)
it.live("get() can use the bundled snapshot while the initial background fetch is pending", () =>
Effect.gen(function* () {
const reading = yield* Deferred.make<void>()
const releaseRead = yield* Deferred.make<void>()
const fetching = yield* Deferred.make<void>()
const releaseFetch = yield* Deferred.make<void>()
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[
ModelsDevCache.node,
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Deferred.succeed(reading, undefined).pipe(
Effect.andThen(Deferred.await(releaseRead)),
Effect.as(undefined),
),
write: () => Effect.void,
}),
],
[
LayerNodePlatform.httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Deferred.succeed(fetching, undefined).pipe(
Effect.andThen(Deferred.await(releaseFetch)),
Effect.as(HttpClientResponse.fromWeb(request, new Response(JSON.stringify(fixture)))),
),
),
),
],
]),
)
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
yield* Deferred.await(reading)
const get = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.succeed(releaseRead, undefined)
yield* Deferred.await(fetching)
expect((yield* Fiber.join(get).pipe(Effect.timeout("1 second"))).length).toBeGreaterThan(0)
yield* Deferred.succeed(releaseFetch, undefined)
}).pipe(Effect.provide(layer))
}),
)
it.live("cancelling a reader during initialization does not poison later reads or refreshes", () =>
Effect.gen(function* () {
const reading = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const first = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(reading)
yield* Fiber.interrupt(first)
yield* Deferred.succeed(release, undefined)
expect(yield* models.get()).toEqual(fixtureSnapshot)
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixture2Snapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
makeCache(),
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Deferred.succeed(reading, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({ body: JSON.stringify(fixture), updatedAt: Date.now() }),
),
write: () => Effect.void,
}),
),
),
)
}),
)
it.live("custom source URLs do not read or overwrite the default source cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* ModelsDev.Service.use((models) => models.get()).pipe(
Effect.provide(buildLayer(state, cache, { url: "https://catalog.example", fetch: true, snapshot: false })),
)
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
expect(cache.values.get("https://catalog.example")?.body).toBe(JSON.stringify(fixture2))
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://catalog.example/api.json")
}),
)
it.live("an explicit file remains authoritative and refresh rereads it without HTTP or cache access", () =>
Effect.gen(function* () {
const dir = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
const file = path.join(dir.path, "catalog.json")
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture)))
const state = yield* Ref.make(initialState)
const cacheCalls: string[] = []
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
expect(yield* models.get()).toEqual(fixtureSnapshot)
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture2)))
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixture2Snapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
makeCache(),
{ file, fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Effect.sync(() => {
cacheCalls.push("read")
return undefined
}),
write: () => Effect.sync(() => void cacheCalls.push("write")),
}),
),
),
)
expect((yield* Ref.get(state)).calls).toEqual([])
expect(cacheCalls).toEqual([])
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
@@ -348,7 +565,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
it.live("get() retains the live catalog instead of rereading persistence", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
@@ -387,7 +604,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -395,7 +612,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
it.live("refresh(false) skips fetch when the persisted catalog is fresh", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
@@ -410,7 +627,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when the KV entry is stale", () =>
it.live("refresh(false) fetches when the persisted catalog is stale", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
@@ -447,7 +664,7 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const seeded = structuredClone(cache.values.get(cacheKey))
const seeded = structuredClone(cache.values.get(source))
// The server serves a byte-identical body, so the refresh still hits
// the network but must not rewrite the cache or publish Refreshed.
const state = yield* Ref.make(initialState)
@@ -474,38 +691,24 @@ describe("ModelsDev Service", () => {
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(cache.values.get(cacheKey)).toEqual(seeded)
expect(cache.values.get(source)).toEqual(seeded)
}),
)
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
it.live("concurrent refreshes share the freshness check even when the body is unchanged", () =>
Effect.gen(function* () {
const cache = makeCache()
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
yield* Effect.all([svc.refresh(), svc.refresh(), svc.refresh()], { concurrency: "unbounded" })
}),
)
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}),
)
@@ -529,4 +732,25 @@ describe("ModelsDev Service", () => {
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
for (const body of ["{", JSON.stringify({ broken: {} })]) {
it.live(`refresh preserves the live and persisted catalog when the response is invalid: ${body}`, () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body })
yield* provided(
state,
cache,
Effect.gen(function* () {
const models = yield* ModelsDev.Service
const before = yield* models.get()
yield* models.refresh(true)
expect(yield* models.get()).toBe(before)
}),
)
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
}),
)
}
})
+33
View File
@@ -184,6 +184,39 @@ describe("Plugin", () => {
}),
)
it.effect("emits rebuilt state when disabling one plugin while another remains enabled", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const definitions = ["first", "second"].map((id) =>
versioned(
EffectPlugin.define({
id,
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
}),
),
)
yield* plugins.activate(definitions)
const observed: string[][] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Agent.Event.Updated.type
? agents.list().pipe(
Effect.flatMap((items) => Effect.sync(() => observed.push(items.map((item) => item.id)))),
Effect.asVoid,
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* plugins.activate(definitions.slice(1))
expect(yield* agents.get(Agent.ID.make("first"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("second"))).toBeDefined()
expect(observed).toEqual([["second"]])
}),
)
it.effect("rejects duplicate IDs before replacing active plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -7,7 +7,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
@@ -21,7 +20,6 @@ const providers = [
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
+1
View File
@@ -12,6 +12,7 @@ describe("Provider", () => {
"@opencode-ai/ai/providers/google-vertex/chat",
"@opencode-ai/ai/providers/google-vertex/responses",
"@opencode-ai/ai/providers/google-vertex/messages",
"@opencode-ai/ai/providers/groq",
"@opencode-ai/ai/providers/togetherai",
]
+3 -1
View File
@@ -538,7 +538,9 @@ function buildExecution(
const store = yield* SessionStore.Service
const runner = Layer.succeed(
SessionRunner.Service,
SessionRunner.Service.of({ drain: (input) => drain(input).pipe(Effect.as({ type: "complete" as const })) }),
SessionRunner.Service.of({
drain: (input) => drain(input).pipe(Effect.as(SessionRunner.DrainResult.Complete())),
}),
)
const locations = Layer.effect(
LocationServiceMap.Service,
@@ -0,0 +1,246 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const active = new Set<Session.ID>()
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.sync(() => active),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
}),
),
],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
const start = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionMessage.ID) =>
bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: messageID,
agent: Agent.defaultID,
model,
})
const complete = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionMessage.ID) =>
bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: messageID,
finish: "stop",
cost: Money.USD.make(0),
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
describe("Session.updateMessage", () => {
it.effect("replaces assistant content through a durable projected event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
const content = [
SessionMessage.AssistantText.make({ type: "text", text: "replacement" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "updated reasoning",
time: { created: created.time.created },
}),
]
const updated = yield* session.updateMessage({ sessionID: created.id, messageID, content })
expect(updated.content).toEqual(content)
expect(yield* session.message({ sessionID: created.id, messageID })).toMatchObject({ content })
expect((yield* session.messages({ sessionID: created.id }))[0]).toMatchObject({ id: messageID, content })
const events = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
expect(events.at(-2)).toMatchObject({
type: "session.message.content.updated",
data: {
sessionID: created.id,
messageID,
content: [
{ type: "text", text: "replacement" },
{ type: "reasoning", text: "updated reasoning", time: { created: expect.any(Number) } },
],
},
})
expect(
yield* db
.select()
.from(EventTable)
.where(eq(EventTable.type, Bus.versionedType(SessionEvent.MessageContentUpdated.type, 1)))
.get(),
).toMatchObject({ aggregate_id: created.id, data: { messageID } })
expect((yield* session.updateMessage({ sessionID: created.id, messageID, content: [] })).content).toEqual([])
}),
)
it.effect("replays updated assistant content into a fresh projection", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
const content = [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "replayed reasoning",
time: { created: created.time.created },
}),
]
yield* session.updateMessage({ sessionID: created.id, messageID, content })
const serialized = (yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const target = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
yield* Effect.gen(function* () {
const database = (yield* Database.Service).db
const replay = yield* Bus.Service
const store = yield* SessionStore.Service
yield* database
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach(serialized, (event) => replay.replay(event), { discard: true })
expect((yield* store.message(messageID))?.message).toMatchObject({ content })
}).pipe(Effect.provide(Layer.fresh(target)))
}),
)
it.effect("rejects missing and cross-session messages", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const other = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
expect(yield* Effect.flip(session.updateMessage({ sessionID: other.id, messageID, content: [] }))).toEqual(
new Session.MessageNotFoundError({ sessionID: other.id, messageID }),
)
const missing = Session.ID.create()
expect(yield* Effect.flip(session.updateMessage({ sessionID: missing, messageID, content: [] }))).toEqual(
new Session.NotFoundError({ sessionID: missing }),
)
}),
)
it.effect("rejects non-assistant messages, incomplete assistants, and unfinished tools", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const synthetic = yield* bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: "synthetic" })
const syntheticID = SessionMessage.ID.fromEvent(synthetic.id)
expect(
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID: syntheticID, content: [] })),
).toEqual(new Session.MessageNotAssistantError({ sessionID: created.id, messageID: syntheticID }))
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
expect(yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [] }))).toEqual(
new Session.MessageIncompleteError({ sessionID: created.id, messageID }),
)
yield* complete(bus, created.id, messageID)
const unfinished = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_unfinished",
name: "read",
state: { status: "streaming", input: "" },
time: { created: created.time.created },
})
expect(
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [unfinished] })),
).toEqual(new Session.MessageToolIncompleteError({ sessionID: created.id, messageID }))
}),
)
it.effect("rejects a completed assistant while its session is active", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
active.add(created.id)
const failure = yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [] }))
active.delete(created.id)
expect(failure).toEqual(new Session.BusyError({ sessionID: created.id }))
}),
)
})
+5 -1
View File
@@ -784,6 +784,10 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const service = yield* Bus.Service
yield* service.publish(SessionEvent.Step.Streamed, {
sessionID,
assistantMessageID: endedID,
})
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: endedID,
@@ -824,7 +828,7 @@ describe("SessionProjector", () => {
cost: Money.USD.make(1),
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
time: { completed: created },
time: { streamed: created, completed: created },
})
expect(messages[1]).toMatchObject({
type: "assistant",
@@ -241,6 +241,7 @@ describe("SessionRunnerLLM recorded", () => {
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
"session.step.streamed.1",
"session.step.ended.1",
])
}),
@@ -342,7 +342,7 @@ test("step finish records settlement without publishing step ended", async () =>
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
})
@@ -85,8 +85,10 @@ describe("Tool", () => {
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe("Invalid tool name: 123")
for (const name of ["", "x".repeat(65)]) {
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
}
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
Effect.flip,
@@ -96,6 +98,67 @@ describe("Tool", () => {
}),
)
it.effect("executes native tools without requiring letter-leading names or namespace segments", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(
service,
{ "2d_get_scene": make(), "123": make(), _lookup: make(), "-lookup": make() },
{ codemode: false },
)
yield* transform(service, { "2d_get_scene": make() }, { namespace: "123._private.-tools", codemode: false })
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual([
"-lookup",
"123",
"123__private_-tools_2d_get_scene",
"2d_get_scene",
"_lookup",
"execute",
])
for (const name of ["2d_get_scene", "123", "_lookup", "-lookup", "123__private_-tools_2d_get_scene"]) {
expect((yield* snapshot.execute(call(name))).output).toEqual({ text: name })
}
}),
)
it.effect("executes Code Mode tools without requiring letter-leading names or namespace segments", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { "2d_get_scene": make(), "123": make(), _lookup: make(), "-lookup": make() })
yield* transform(service, { "2d_get_scene": make() }, { namespace: "123._private.-tools", codemode: true })
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([
"-lookup",
"123",
"123._private.-tools.2d_get_scene",
"2d_get_scene",
"_lookup",
])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "call-nonletter-names",
name: "execute",
input: {
code: `const results = await Promise.all([
tools["2d_get_scene"]({ text: "digit" }),
tools["123"]({ text: "numeric" }),
tools._lookup({ text: "underscore" }),
tools["-lookup"]({ text: "hyphen" }),
tools["123"]._private["-tools"]["2d_get_scene"]({ text: "namespaced" }),
]); return results.map(result => result.text).join(",");`,
},
},
})
expect(result.content).toEqual([{ type: "text", text: "digit,numeric,underscore,hyphen,namespaced" }])
}),
)
it.effect("validates a registration batch before installing any tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+127 -4
View File
@@ -76,7 +76,7 @@ import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq } from "drizzle-orm"
@@ -419,7 +419,7 @@ const execution = Layer.effect(
.drain({ sessionID, force, continuation })
.pipe(
Effect.flatMap((result) =>
result.type === "complete" ? Effect.void : drain(sessionID, false, result.continuation),
result._tag === "Complete" ? Effect.void : drain(sessionID, false, result.continuation),
),
)
}
@@ -2926,6 +2926,55 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("consumes the full provider stream before recording its boundary and settling local tools", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Echo this")
const tail = yield* Deferred.make<void>()
const complete = yield* Deferred.make<void>()
const finished = yield* Deferred.make<void>()
yield* TestLLM.push(
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
Stream.concat(
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
Stream.drain,
),
),
Stream.onEnd(Deferred.succeed(finished, undefined)),
),
TestLLM.stop(),
)
const tools = yield* blockTools()
const streamed = yield* bus.subscribe(SessionEvent.Step.Streamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* Effect.forkChild(session.resume(sessionID))
yield* tools.started
yield* Deferred.await(tail)
expect(requests).toHaveLength(1)
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.streamed.1")
expect(requireAssistant(yield* session.context(sessionID)).time.completed).toBeUndefined()
yield* Deferred.succeed(complete, undefined)
yield* Fiber.join(streamed)
expect(yield* Deferred.isDone(finished)).toBe(true)
const assistant = requireAssistant(yield* session.context(sessionID))
expect(assistant.time.streamed).toBeDefined()
expect(assistant.time.completed).toBeUndefined()
expect(assistant.content).toMatchObject([{ type: "tool", state: { status: "running" } }])
yield* tools.release
yield* Fiber.join(run)
const events = yield* recordedEventTypes(sessionID)
expect(events.indexOf("session.step.streamed.1")).toBeLessThan(events.indexOf("session.tool.success.2"))
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.ended.1"))
expect(events.filter((type) => type === "session.step.streamed.1")).toHaveLength(2)
}),
)
it.effect("restores durable reasoning provider metadata in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -4236,16 +4285,24 @@ describe("SessionRunnerLLM", () => {
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Interrupt tool settlement")
const tools = yield* blockTools()
yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" }))
const streamed = yield* bus.subscribe(SessionEvent.Step.Streamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const runner = yield* SessionRunner.Service
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* tools.started
yield* Fiber.join(streamed)
yield* Fiber.interrupt(run)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt tool settlement" },
{
@@ -4262,8 +4319,11 @@ describe("SessionRunnerLLM", () => {
},
])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.step.failed.1")
expect(eventTypes.filter((type) => type === "session.tool.failed.2")).toHaveLength(1)
expect(eventTypes.filter((type) => type === "session.step.failed.1")).toHaveLength(1)
expect(eventTypes).not.toContain("session.step.ended.1")
expect(eventTypes).not.toContain("session.retry.scheduled.1")
expect(requests).toHaveLength(1)
}),
)
@@ -4543,6 +4603,30 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not start another physical attempt after interruption during retry backoff", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Interrupt retry backoff")
yield* TestLLM.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
const scheduled = yield* bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Fiber.join(scheduled)
yield* session.interrupt(sessionID)
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
yield* TestClock.adjust("1 minute")
expect(requests).toHaveLength(1)
const events = yield* recordedEventTypes(sessionID)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
expect(events).not.toContain("session.synthetic.1")
}),
)
it.effect("immediately rebuilds once after explicit continuation rejection", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -4892,6 +4976,45 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("shares retry accounting and assistant identity across transparent retries and partial continuations", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const scheduled = yield* Queue.unbounded<SessionMessage.ID>()
yield* bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runForEach((event) => Queue.offer(scheduled, event.data.assistantMessageID)),
Effect.forkScoped({ startImmediately: true }),
)
yield* admit(session, "Mix retry paths")
const failure = incompleteStream()
const partial = TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "mixed-partial" }),
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
)
yield* TestLLM.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
const identities: SessionMessage.ID[] = []
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
identities.push(yield* Queue.take(scheduled))
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(requests).toHaveLength(5)
expect(identities[0]).toBe(identities[1])
expect(identities[2]).toBe(identities[3])
expect(identities[0]).not.toBe(identities[2])
const messages = yield* session.context(sessionID)
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
const events = yield* recordedEventTypes(sessionID)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
}),
)
it.effect("stops incomplete stream continuations after five total attempts", () =>
Effect.gen(function* () {
const session = yield* setup

Some files were not shown because too many files have changed in this diff Show More