Compare commits

..
Author SHA1 Message Date
Kit Langton 3369c08ffa docs(plugin): update current API examples 2026-08-27 15:00:32 -04:00
353 changed files with 10714 additions and 16419 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Propagate fatal Git ignore-check errors during index refresh so best-effort snapshot capture returns unavailable instead of treating the check as having no matches.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Rename the write, patch, and question tool formatting helpers from `toModelOutput` to `toModelContent` to match the result field they populate. Direct imports of these helpers must use the new name; generated content and declared machine output are unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": minor
---
Rename ModelResolver.supported to ModelResolver.hasPackage. Consumers of the old export must update the name; the predicate remains Boolean(model.package), checking only whether a catalog model declares a provider package, not whether it can be loaded. Default-model selection behavior is unchanged.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Remove the unused question request API and use session forms for question tool interactions.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
+9
View File
@@ -0,0 +1,9 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
+10
View File
@@ -0,0 +1,10 @@
---
"@opencode-ai/core": patch
---
Make the experimental portable shell scanner authoritative, with no Tree-sitter
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
expressions, quoting, and substitutions natively. Preserve existing redirect and
declaration permission matching, and make PowerShell saved approvals cover the
original command spelling. Parser failures remain visible without changing the
permission engine. The default Tree-sitter path is unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+6
View File
@@ -41,5 +41,11 @@
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+342 -167
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+1 -2
View File
@@ -63,8 +63,7 @@ new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
command: "bun turbo build",
output: "./dist",
},
})
+9 -7
View File
@@ -4,7 +4,7 @@
bun,
nodejs,
darwin,
callPackage,
electron_41,
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
@@ -13,7 +13,7 @@
opencode,
}:
let
electron = callPackage ./electron.nix { };
electron = electron_41;
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
@@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: {
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron derives X11 WM_CLASS from app.name.
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
@@ -67,10 +67,12 @@ stdenv.mkDerivation (finalAttrs: {
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
substituteInPlace \
packages/desktop/src/main/windows/appearance.ts \
packages/desktop/src/main/service/desktop-cli.ts \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = ''
-13
View File
@@ -1,13 +0,0 @@
{ callPackage, path }:
let
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
in
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
# fetchzip hashes the unpacked headers, not the release tarball.
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
"x86_64-linux": "sha256-agxF1Ab8P84tijHvw7jaG+9UZm/dJZhHw8CGNlQQqYc=",
"aarch64-linux": "sha256-Kg6CkhoEEsb+ZOGPN/rnNoirLdGSyQl6HU/Fy8t9Mqs=",
"aarch64-darwin": "sha256-SnxBpmcfPL2p3OYmSuG5Y/aFW8PeEOLhfQrxAs/53fs=",
"x86_64-darwin": "sha256-Dnxl1EcfiE7/rlHmPkko0BKc6hHVk6rQ/2h/PWuLfNo="
}
}
+23 -27
View File
@@ -39,24 +39,24 @@
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.112",
"@effect/platform-node": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.13",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -67,16 +67,13 @@
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/event-listener": "2.4.6",
"@solid-primitives/media": "2.3.6",
"@solid-primitives/resize-observer": "2.2.0",
"@solid-primitives/storage": "4.4.0",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.4",
"dompurify": "3.4.14",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -84,9 +81,9 @@
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "18.0.11",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
"semver": "7.7.4",
"typescript": "5.8.2",
@@ -95,17 +92,17 @@
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.3.6",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "1.0.0",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.71.0",
"@sentry/vite-plugin": "5.4.0",
"solid-js": "1.9.15",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
@@ -161,8 +158,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"effect": "catalog:"
},
"patchedDependencies": {
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
@@ -170,7 +166,7 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
+1 -11
View File
@@ -374,12 +374,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
@@ -388,7 +382,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
if (content.length > 0) messages.push({ role: "assistant", content })
messages.push({ role: "assistant", content })
continue
}
@@ -496,7 +490,6 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
@@ -575,7 +568,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (event.contentBlockDelta?.delta?.toolUse) {
const index = event.contentBlockDelta.contentBlockIndex
if (state.finishedTools.has(index)) return [state, []] as const
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -614,7 +606,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
state.hasToolCalls,
lifecycle,
tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
@@ -706,7 +697,6 @@ export const protocol = Protocol.make({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
+85 -158
View File
@@ -391,19 +391,17 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
@@ -828,16 +826,16 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta || state.message?.id !== id) return [state, NO_EVENTS]
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.message.phase
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.message?.id === id) {
if (state.messageItems.has(id)) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
@@ -848,62 +846,18 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
},
},
},
events,
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events: LLMEvent[] = [...emitted]
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
},
},
events,
@@ -915,7 +869,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
@@ -924,48 +878,32 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// Responses APIs stream reasoning items in a stable order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
const phase = messagePhase(item.phase)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
events,
id,
providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }),
)
}, state.lifecycle)
return [
{
...state,
lifecycle,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
},
events,
NO_EVENTS,
]
}
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
@@ -974,7 +912,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
@@ -986,8 +923,6 @@ 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
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1008,14 +943,55 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
[event.summary_index]: "active",
},
},
},
},
events,
]
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
return [
{
...state,
@@ -1071,8 +1047,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "message" && item.id !== undefined) {
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
return [
{
...state,
@@ -1082,7 +1061,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
),
message: state.message?.id === item.id ? undefined : state.message,
messageItems,
messagePhases,
},
events,
] satisfies StepResult
@@ -1090,36 +1070,20 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const id = item.id ?? item.call_id
const tools = state.tools[id]
? state.tools
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1130,7 +1094,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
@@ -1141,49 +1104,20 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
events,
] satisfies StepResult
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
@@ -1205,7 +1139,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
if (
id === undefined ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
@@ -1322,13 +1256,6 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
@@ -1378,10 +1305,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
})
+30 -16
View File
@@ -3,7 +3,6 @@ import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
@@ -246,8 +245,6 @@ export const OpenAIChatEvent = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
const DONE = "[DONE]" as const
const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)])
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
@@ -1000,12 +997,33 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
// Reasoning is one response-wide channel: it stays open alongside text and
// refusal output so late reasoning deltas and details join the same block,
// and `finishEvents` closes it once with the complete metadata.
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
@@ -1111,12 +1129,10 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
// Snapshot details at publish time so the emitted event never observes later
// mutation of the accumulated `reasoningDetails` array.
const metadata = reasoningMetadata(
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
@@ -1150,7 +1166,7 @@ export const protocol = Protocol.make({
from: fromRequest,
},
stream: {
event: OpenAIChatStreamEvent,
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
@@ -1164,14 +1180,12 @@ export const protocol = Protocol.make({
nextToolIndex: 0,
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
}),
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
terminal: (event) => event === DONE,
step,
onHalt: finishEvents,
},
})
export const framing = Framing.sseWithDone
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>().with({ framing })
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
export const route = Route.make({
id: ADAPTER,
@@ -1,5 +1,6 @@
import { Route, type RouteRoutedLanguageModelInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import * as OpenAIChat from "./openai-chat.js"
const ADAPTER = "openai-compatible-chat"
@@ -18,7 +19,7 @@ export const route = Route.make({
providerMetadataKey: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
framing: Framing.sse,
})
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
+4 -5
View File
@@ -207,16 +207,15 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty events. `[DONE]`
* is dropped by default or retained for protocols that use it as their stream
* boundary. Retry control events are ignored without interrupting the stream.
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
includeDone = false,
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
@@ -241,7 +240,7 @@ export const sseFraming = (
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
)
+6 -15
View File
@@ -62,31 +62,22 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, text, providerMetadata }))
const open = new Set(stepped.text)
open.delete(id)
return { ...stepped, text: open }
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
}
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
+31 -23
View File
@@ -37,7 +37,6 @@ const patterns = [
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/range of input length should be/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
@@ -60,7 +59,6 @@ export const isContextOverflowFailure = (failure: unknown) =>
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -76,6 +74,7 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
@@ -91,12 +90,8 @@ export interface ProviderFailure {
readonly rateLimit?: HttpRateLimitDetails | undefined
}
// Classification records affirmative evidence about a failure. Deterministic
// failures need positive identification (a 4xx status, quota/auth/policy
// signals); anything unrecognized stays UnknownProvider, which the session
// retry policy treats as retry-eligible because transient failures arrive in
// unpredictable shapes while deterministic rejections almost always carry a
// status or known code.
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
const body = input.rawBody ?? ""
@@ -121,33 +116,46 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
if (codes.includes("permission_error"))
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (
input.status === 429 ||
codes.some(
(code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception",
) ||
RATE_LIMIT_TEXT.test(text)
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
)
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
if (RATE_LIMIT_TEXT.test(text))
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
})
if (input.status === 429) {
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
// Any remaining 4xx is a deterministic rejection of this request.
if (input.status !== undefined && input.status >= 400 && input.status < 500) return new InvalidRequestError(details)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestError(details)
return new UnknownProviderError(details)
}
+2 -1
View File
@@ -5,6 +5,7 @@ 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"
@@ -74,7 +75,7 @@ export const route = Route.make({
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
framing: OpenAIChat.framing,
framing: Framing.sse,
})
export const configure = (input: LanguageModelOptions = {}) => {
+2 -1
View File
@@ -1,6 +1,7 @@
import { Effect, Schema } from "effect"
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 { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
@@ -166,7 +167,7 @@ export const route = Route.make({
providerMetadataKey: "openrouter",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: OpenAIChat.framing,
framing: Framing.sse,
})
export const routes = [route]
+1 -1
View File
@@ -139,7 +139,7 @@ const toAIError = (error: AuthError): AIError => {
return new AIError({
reason:
error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error })
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
})
}
+2 -8
View File
@@ -8,8 +8,8 @@ import type { AIError } from "../schema/index.js"
* `Framing` is the byte-stream-shaped seam between transport and protocol:
*
* - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder,
* and emit the `data:` payload of each non-empty event. The default drops
* `[DONE]`; protocols that use it as a terminal select `sseWithDone`.
* drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
* payload of one event.
* - AWS event stream — length-prefixed binary frames with CRC checksums.
* Each emitted frame is one parsed binary event record.
*
@@ -26,12 +26,6 @@ export interface Definition<Frame> {
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
export const sseWithDone: Definition<string> = {
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
}
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
+4 -1
View File
@@ -44,7 +44,10 @@ export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoR
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
"Authentication",
ReasonFields,
{
...ReasonFields,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
},
) {}
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
+14 -35
View File
@@ -112,8 +112,6 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -136,8 +134,6 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -332,32 +328,17 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
) => {
const order: string[] = []
const parts = new Map<string, string>()
for (const event of events) {
if (isDelta(event)) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, (parts.get(event.id) ?? "") + event.text)
}
if (isEnd(event) && event.text !== undefined) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, event.text)
}
}
return order.map((id) => parts.get(id)).join("")
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd)
events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd)
events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
events.reduce<Usage | undefined>(
@@ -464,11 +445,10 @@ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState
const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
}
}
@@ -498,11 +478,10 @@ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): Resp
const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
const current = state.reasoningParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
}
}
@@ -600,12 +579,12 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
usage: Schema.optional(Usage),
finishReason: FinishReasonDetails,
}) {
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
/** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() {
return responseText(this.events)
}
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
get reasoning() {
return responseReasoning(this.events)
}
+9 -7
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
@@ -148,13 +148,15 @@ describe("llm route", () => {
}),
)
test("builds models from configured routes", () => {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
it.effect("builds models from configured routes", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
})
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
}),
)
it.effect("does not register duplicate route ids globally", () =>
Effect.gen(function* () {
+167 -114
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,6 +18,47 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
@@ -66,17 +107,19 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(cause)
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(cause)
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
),
),
]),
),
)
@@ -105,14 +148,15 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
@@ -132,14 +176,15 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
@@ -155,7 +200,7 @@ describe("RequestExecutor", () => {
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("reports the request sent by middleware", () =>
@@ -204,9 +249,11 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
),
),
)
@@ -222,7 +269,7 @@ describe("RequestExecutor", () => {
classification: "payload-too-large",
})
expect(error.reason.http?.status).toBe(413)
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
@@ -238,9 +285,11 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(413)
}).pipe(
Effect.provide(
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
),
),
)
@@ -254,7 +303,7 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -268,13 +317,15 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toContain(largeProviderMessage)
}).pipe(
Effect.provide(
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
),
),
)
@@ -289,7 +340,7 @@ describe("RequestExecutor", () => {
_tag: "InvalidRequest",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -301,7 +352,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -318,7 +369,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -348,10 +399,12 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("rate limited")
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
),
),
)
@@ -364,7 +417,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -384,18 +437,20 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
),
),
)
@@ -415,18 +470,20 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
),
),
)
@@ -439,14 +496,10 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
),
)
@@ -469,10 +522,12 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(status)
}).pipe(
Effect.provide(
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
),
)
@@ -483,28 +538,20 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
expect(yield* Ref.get(attempts)).toBe(1)
}),
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
)
it.effect("preserves response body fields", () =>
@@ -516,9 +563,11 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
}).pipe(
Effect.provide(
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
),
),
)
@@ -532,7 +581,9 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
),
),
)
@@ -540,7 +591,9 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -571,7 +624,7 @@ describe("RequestExecutor", () => {
})
describe("WebSocket channel execution", () => {
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
+15 -17
View File
@@ -44,23 +44,21 @@ describe("Tool.make (dynamic JSON Schema)", () => {
expect(definition?.inputSchema).toEqual(jsonSchema)
})
it.effect("execute receives the raw input untouched", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = yield* tool.execute({ hello: "world" })
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
}),
)
test("execute receives the raw input untouched", async () => {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = await Effect.runPromise(tool.execute({ hello: "world" }))
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
})
})
describe("LLM.generateObject", () => {
+4 -9
View File
@@ -11,7 +11,6 @@ describe("provider error classification", () => {
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Range of input length should be [1, 129024]",
"Too many tokens",
"Token limit exceeded",
]
@@ -88,12 +87,10 @@ describe("provider error classification", () => {
])
})
test("classifies any remaining 4xx status as an invalid request", () => {
test("classifies network error text as provider internal", () => {
expect(
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -106,12 +103,10 @@ describe("provider error classification", () => {
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
})
test("leaves unrecognized failures unclassified for the retry default", () => {
test("keeps unknown and malformed provider payloads non-retryable", () => {
expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
})
})
@@ -491,59 +491,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores late tool deltas after contentBlockStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"weather"}' } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"late":true}' } } }],
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
])
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
])
}),
)
it.effect("rejects tool deltas without contentBlockStart", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse tool delta is missing its tool call",
})
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -584,104 +531,6 @@ describe("Bedrock Converse route", () => {
}),
)
for (const signature of [undefined, "", " "]) {
for (const cache of ["none", "auto"] as const) {
it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{
type: "reasoning",
text: "Partial thought",
providerMetadata: signature === undefined ? undefined : { bedrock: { signature } },
cache: new CacheHint({ type: "ephemeral" }),
},
]),
Message.user("Continue"),
],
cache,
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "assistant",
content: [{ text: "Partial thought" }, { cachePoint: { type: "default" } }],
})
}),
)
}
}
it.effect("omits empty unsigned reasoning without leaving an empty or cache-only assistant", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{ type: "reasoning", text: "", cache },
{ type: "reasoning", text: " ", providerMetadata: { bedrock: { signature: "" } }, cache },
]),
Message.user([{ type: "text", text: "Continue", cache }]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ text: "Think" }, { text: "Continue" }, { cachePoint: { type: "default" } }],
},
])
}),
)
it.effect("demotes foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "Foreign thought", providerMetadata: { anthropic: { signature: "old" } } },
{
type: "reasoning",
text: "Signed thought",
providerMetadata: { bedrock: { signature: "sig_1" } },
cache,
},
{ type: "reasoning", text: "", encrypted: "sig_2", cache },
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "cmVkYWN0ZWQ=" } }, cache },
{ type: "text", text: "Checking" },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ text: "Foreign thought" },
{ reasoningContent: { reasoningText: { text: "Signed thought", signature: "sig_1" } } },
{ reasoningContent: { reasoningText: { text: "", signature: "sig_2" } } },
{ reasoningContent: { redactedContent: "cmVkYWN0ZWQ=" } },
{ text: "Checking" },
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
],
},
])
}),
)
it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+12 -10
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
@@ -136,15 +136,17 @@ describe("Cloudflare", () => {
}),
)
test("defaults AI Gateway id to default when omitted or blank", () => {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
})
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
}),
)
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
Effect.gen(function* () {
@@ -52,7 +52,7 @@ describe("provider error retention", () => {
Effect.flip,
)
expect(error.message).toContain("Slow down")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
@@ -376,9 +376,11 @@ describe("Google Vertex providers", () => {
}),
)
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
})
it.effect("rejects tuned Gemini models in express mode", () =>
Effect.sync(() => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
}),
)
})
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
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"
@@ -31,71 +31,82 @@ import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
test("assigns provider-owned metadata namespaces across native routes", () => {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"), "custom"],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
})
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
test("preserves native Together AI and Cerebras provider and route identities", () => {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
})
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
test("preserves native DeepInfra provider and route identity", () => {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
})
it.effect("preserves native DeepInfra provider and route identity", () =>
Effect.gen(function* () {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
}),
)
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
@@ -150,35 +161,39 @@ describe("native OpenAI-compatible providers", () => {
}),
)
test("normalizes DeepInfra API roots without duplicating the OpenAI path", () => {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
Effect.gen(function* () {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
)
}
})
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
)
}
}),
)
test("maps package settings onto native executable models", () => {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
@@ -1,467 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const completed = { type: "response.completed", response: { id: "resp_1" } }
const collect = (...input: OpenResponses.Event[]) =>
Effect.gen(function* () {
const events = yield* LLMClient.stream(request).pipe(
Stream.runCollect,
Effect.provide(fixedResponse(sseEvents(...input))),
)
expectLifecycle(
events,
input.some((event) => event.type === "response.completed"),
)
return events
})
// Deliberately local to these basic-item fixtures, not a general stream validator.
function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
const active = { text: new Set<string>(), reasoning: new Set<string>() }
const tools = new Map<string, "started" | "ended" | "called">()
events.forEach((event) => {
if (event.type === "text-start" || event.type === "reasoning-start") {
const blocks = event.type === "text-start" ? active.text : active.reasoning
expect(blocks.size).toBe(0)
blocks.add(event.id)
}
if (event.type === "text-delta" || event.type === "reasoning-delta") {
expect((event.type === "text-delta" ? active.text : active.reasoning).has(event.id)).toBe(true)
}
if (event.type === "text-end" || event.type === "reasoning-end") {
expect((event.type === "text-end" ? active.text : active.reasoning).delete(event.id)).toBe(true)
}
if (event.type === "tool-input-start") {
expect(tools.has(event.id)).toBe(false)
tools.set(event.id, "started")
}
if (event.type === "tool-input-delta") expect(tools.get(event.id)).toBe("started")
if (event.type === "tool-input-end") {
expect(tools.get(event.id)).toBe("started")
tools.set(event.id, "ended")
}
if (event.type === "tool-call") {
expect(tools.get(event.id)).toBe("ended")
tools.set(event.id, "called")
}
// Incomplete responses may leave pending tool inputs without a call.
if (event.type === "finish" && completed) {
expect(active.text.size).toBe(0)
expect(active.reasoning.size).toBe(0)
expect([...tools.values()].every((status) => status === "called")).toBe(true)
}
})
expect(events.filter(LLMEvent.is.stepStart)).toHaveLength(1)
expect(events[0]?.type).toBe("step-start")
expect(events.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.slice(-2).map((event) => event.type)).toEqual(["step-finish", "finish"])
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
// The third part omits both explicit summary boundaries.
{
type: "response.reasoning_summary_text.delta",
output_index: 0,
item_id: "wrong",
summary_index: 2,
delta: "Third",
},
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Not streamed" }],
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
completed,
// Route termination must also prevent events after response completion.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("forgets never-streamed messages at implicit boundaries and preserves refusal phases", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_empty" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.done", item_id: "msg_1", text: "Checking" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Duplicate" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2", phase: null } },
{ type: "response.output_text.delta", item_id: "msg_empty", delta: "stale" },
{ type: "response.output_text.done", item_id: "msg_empty", text: "stale final" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "late" },
{ type: "response.refusal.delta", item_id: "msg_2", delta: "Cannot help." },
{ type: "response.refusal.done", item_id: "msg_2", refusal: "Cannot help." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2", phase: "final_answer" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_3", phase: null } },
{ type: "response.refusal.done", item_id: "msg_3", refusal: "Done-only refusal." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_3" } },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Checking" },
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{
type: "text-start",
id: "msg_2",
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: null } },
},
{ type: "text-delta", id: "msg_2", text: "Cannot help." },
{
type: "text-end",
id: "msg_2",
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: "final_answer" } },
},
{
type: "text-start",
id: "msg_3",
providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } },
},
{ type: "text-delta", id: "msg_3", text: "Done-only refusal." },
{ type: "text-end", id: "msg_3", providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } } },
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"draft"}' },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: '{"query":"final"}' },
{ type: "function_call", id: "fc_unseen", call_id: "call_unseen", name: "lookup", arguments: "{}" },
],
},
},
)
expect(events.slice(5, -2)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "final" },
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
])
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
{ type: "response.output_item.added", item: { type: "message", id: "" } },
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
{ type: "response.reasoning_summary_text.delta", item_id: "", delta: "Thinking" },
{ type: "response.output_text.delta", item_id: "", delta: "Answer" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "", encrypted_content: "state" } },
{ type: "response.output_item.done", item: { type: "message", id: "" } },
completed,
)
expect(events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual(["Thinking"])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Answer"])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: ":0",
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("does not recover a completed tool from a tracked message with the same id", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "item_1" } },
{ type: "response.output_text.delta", item_id: "item_1", delta: "Answer" },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "{}" }],
},
},
)
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.finish).map((event) => event.reason.normalized)).toEqual(["stop"])
}),
)
it.effect("flushes pending calls and open text when completed output is absent", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "final_answer" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
)
it.effect("does not reconcile pending calls or terminal reasoning metadata on incomplete responses", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Partial" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":' },
{
type: "response.incomplete",
response: {
id: "resp_1",
incomplete_details: { reason: "max_output_tokens" },
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "not-reconciled" },
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"not-reconciled"}',
},
],
},
},
)
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "length", raw: "max_output_tokens" },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
})
+8 -71
View File
@@ -7,7 +7,6 @@ import {
AIError,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
LanguageModel,
ToolCallPart,
@@ -808,28 +807,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("finishes at the done sentinel without waiting for response EOF", () =>
Effect.gen(function* () {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "stop"))),
)
},
})
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(stream, {
headers: { "content-type": "text/event-stream" },
}),
),
)
expect(response.text).toBe("Hello")
expect(response.events.at(-1)?.type).toBe("finish")
}),
)
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1150,7 +1127,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
it.effect("preserves scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
@@ -1167,35 +1144,8 @@ describe("OpenAI Chat route", () => {
)
expect(response.reasoning).toBe("detailscalar")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
@@ -1241,18 +1191,7 @@ describe("OpenAI Chat route", () => {
index: 0,
},
]
// Snapshot reasoning-end metadata as each event is published so the
// assertion cannot pass through later mutation of a shared array.
const publishedEndMetadata: unknown[] = []
const response = yield* LLMClient.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (LLMEvent.is.reasoningEnd(event))
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
}),
),
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.map((state) => LLMResponse.complete(state)!),
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
@@ -1273,12 +1212,10 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
// Reasoning stays open alongside text and closes once during finalization.
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
@@ -91,38 +91,40 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
test("provides model helpers for compatible provider families", () => {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
@@ -405,19 +407,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("ignores events after the done sentinel", () =>
Effect.gen(function* () {
const body = `${sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
)}data: ${JSON.stringify(deltaChunk({ content: " late" }))}\n\n`
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
}),
)
it.effect("accepts nullable usage and preserves provider fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2750,227 +2750,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("concludes reasoning at implicit summary boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
// The next part is enough to conclude the previous one even when
// its done event is delayed.
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
// Some compatible providers begin the next part with its first delta.
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecondThird")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_2" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("started reasoning before the previous item ended")
}),
)
it.effect("concludes text at implicit message boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
// An item that never streams text is untracked at the boundary too.
{ type: "response.output_item.added", item: { type: "message", id: "msg_0" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
// The previous message's done event is missing; the next message
// item is the boundary for its open text.
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
// Late deltas for concluded or untracked messages must stay no-ops.
{ type: "response.output_text.delta", item_id: "msg_1", delta: " late" },
{ type: "response.output_text.delta", item_id: "msg_0", delta: " stale" },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("FirstSecond")
expect(response.events.filter((event) => event.type.startsWith("text-"))).toMatchObject([
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
])
}),
)
it.effect("opens the tool lifecycle for a done-only function call", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
// No output_item.added: the call arrives only as a completed item.
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type.startsWith("tool-"))).toMatchObject([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("ignores duplicate item boundary events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
// Duplicate added for a known item is not overlap and must no-op.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Think")
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "tool-input-start")).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
])
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
-54
View File
@@ -104,60 +104,6 @@ describe("LLMResponse reducer", () => {
])
})
test("authoritative text-end value replaces accumulated deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
expect(response?.text).toBe("Hello!")
})
test("text-end without value keeps joined deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
LLMEvent.textDelta({ id: "t1", text: "lo" }),
LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response?.text).toBe("Hello")
})
test("authoritative reasoning-end value replaces only its own fragment", () => {
const response = LLMResponse.fromEvents([
LLMEvent.reasoningStart({ id: "r1:0" }),
LLMEvent.reasoningDelta({ id: "r1:0", text: "First summ" }),
LLMEvent.reasoningEnd({ id: "r1:0", text: "First summary." }),
LLMEvent.reasoningStart({ id: "r1:1" }),
LLMEvent.reasoningDelta({ id: "r1:1", text: "Second summary." }),
LLMEvent.reasoningEnd({ id: "r1:1" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{ type: "reasoning", text: "First summary." },
{ type: "reasoning", text: "Second summary." },
])
expect(response?.reasoning).toBe("First summary.Second summary.")
})
test("end value recovers a fragment that streamed no deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
expect(response?.text).toBe("Hello!")
})
test("clears malformed tool input without appending an executable call", () => {
const state = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
+39 -39
View File
@@ -26,7 +26,6 @@ import {
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
import { it } from "./lib/effect.js"
const model = new LanguageModel({
id: ModelID.make("fake-model"),
@@ -102,48 +101,49 @@ describe("AI.Usage", () => {
expect(ProviderShared.sumTokens()).toBeUndefined()
})
it.effect("sseFraming maps decoder failures to AI errors", () =>
Effect.gen(function* () {
const error = yield* ProviderShared.sseFraming(
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
).pipe(Stream.runCollect, Effect.flip)
test("sseFraming maps decoder failures to AI errors", async () => {
const error = await Effect.runPromise(
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
Stream.runCollect,
Effect.flip,
),
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
it.effect("sseFraming ignores retry directives without ending the stream", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect)
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
}),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
it.effect("sseFraming preserves event data around retry directives", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect)
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
}),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
@@ -153,18 +153,18 @@ describe("AI.Usage", () => {
})
})
it.effect("AI errors expose the shared runtime tag", () =>
Effect.gen(function* () {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(yield* Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))).toBe("caught")
}),
)
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
test("transport errors serialize execution facts", () => {
const reason = new TransportError({
@@ -250,7 +250,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
provider: model.provider,
model: model.id,
}),
new AuthenticationError({ message: "Missing credentials" }),
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
new RateLimitError({ message: "Rate limited" }),
new QuotaExceededError({ message: "Quota exceeded" }),
new ContentPolicyError({ message: "Content blocked" }),
+73 -67
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
@@ -277,61 +277,65 @@ describe("LLMClient tools", () => {
}),
)
test("models canonical tool files with URIs", () => {
const decode = Schema.decodeUnknownSync(Content)
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
})
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
}),
)
test("preserves canonical tool file URIs", () => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
it.effect("preserves canonical tool file URIs", () =>
Effect.sync(() => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
})
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
}),
)
it.effect("settles projected URL files as canonical tool results", () =>
Effect.gen(function* () {
@@ -360,22 +364,24 @@ describe("LLMClient tools", () => {
}),
)
test("derives typed output schemas and preserves dynamic output schemas", () => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
Effect.sync(() => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
}),
)
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
+43 -37
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { AIError } from "../src/schema/index.js"
import { ToolStream } from "../src/protocols/utils/tool-stream.js"
@@ -38,40 +38,44 @@ describe("ToolStream", () => {
}),
)
test("exposes cumulative partial string values", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) throw result
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
})
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) throw result
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
})
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
@@ -100,12 +104,14 @@ describe("ToolStream", () => {
}),
)
test("fails appendExisting when the provider skipped the tool start", () => {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
})
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
}),
)
it.effect("uses final input override without losing accumulated deltas", () =>
Effect.gen(function* () {
@@ -25,7 +25,6 @@ for (const viewport of [
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await testInfo.attach("creating-worktree", {
@@ -185,7 +184,6 @@ test("restores the draft after closing and revisiting a pending session that fai
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
@@ -218,10 +216,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) {
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
@@ -279,7 +274,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
return { worktree, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(thinking).toHaveCount(0)
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/^Used\s*2 Read, Grep$/,
/Used\s*Read, Grep/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
])
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
const trigger = page.getByRole("button", { name: "Used Shell" })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
@@ -93,8 +93,7 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
await expect(summary).toHaveAccessibleName("Used 1 Patch")
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
@@ -110,8 +109,7 @@ test.describe("regression: session timeline local row state", () => {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -28,7 +28,7 @@ for (const expanded of [false, true]) {
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
: page.getByRole("button", { name: "Used Shell" })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -141,7 +141,7 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
const used = group.getByRole("button", { name: "Used Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
@@ -150,7 +150,7 @@ for (const open of [false, true]) {
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
@@ -16,9 +16,8 @@ for (const locale of ["de", "ar"] as const) {
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -105,10 +105,10 @@ for (const summaries of [false, true]) {
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toContainText("UsedSkill")
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
@@ -45,11 +45,10 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
@@ -75,8 +74,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
@@ -114,7 +113,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
@@ -129,10 +128,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
),
),
)
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
@@ -162,8 +158,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -209,29 +209,6 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await expect(
page
.locator('[data-slot="titlebar-tabs"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(layout).toContainText("Vertical")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
@@ -198,10 +198,6 @@ fixture(
fixture(
"precaches public files of every type and size, excluding deployment metadata and source maps",
async ({ page, site, builds, context }) => {
for (const output of Object.values(builds)) {
expect(output["/sw.js.map"]).toBeUndefined()
expect(Object.keys(output).some((path) => path.startsWith("/_assets/") && path.endsWith(".map"))).toBe(true)
}
await install(page, site.url)
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
await context.setOffline(true)
+13 -5
View File
@@ -42,18 +42,21 @@
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"vite": "8.2.2",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -68,23 +71,28 @@
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "catalog:",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "catalog:",
"@solid-primitives/resize-observer": "catalog:",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
}
@@ -1,4 +1,5 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Session } from "@opencode-ai/schema/session"
import { startTransition } from "solid-js"
@@ -12,7 +13,6 @@ import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { createWorktree } from "@/workspaces/create"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
@@ -192,17 +192,25 @@ async function resolveSessionDirectory(input: {
if (input.worktree === "main") return input.projectDirectory
if (input.worktree !== "create") return input.worktree
return createWorktree({
api: input.serverSDK.api,
directory: input.projectDirectory,
project: input.data.location.info({ directory: input.projectDirectory })?.project,
branch: input.branch,
}).catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
return input.serverSDK.api.worktree
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
})
.then(async (created) => {
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
})
.catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
})
})
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
@@ -644,7 +644,7 @@ export function persisted<T>(
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted<T, typeof store>(store, {
const [state, setState, init] = makePersisted(store, {
name: config.key,
storage,
sync: channel ? messageSync(channel) : undefined,
@@ -1,135 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer } from "./api"
import { readLocalImage } from "./image"
function setup(
respond: (init?: RequestInit) => Response | Promise<Response> = () => new Response(new Uint8Array([0, 127, 255])),
) {
const requests: Array<{ url: URL; init?: RequestInit }> = []
const api = createApiForServer({
server: { url: "https://server.example:4096", username: "image-user", password: "secret" },
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: new URL(input instanceof Request ? input.url : input), init })
return respond(init)
}) as typeof fetch,
})
return { api, requests }
}
describe("readLocalImage", () => {
test.each([
["images/screen #1 + 50% ?.PNG", "/workspace/project", "images/screen%20%231%20%2B%2050%25%20%3F.PNG"],
["C:/tmp/opencode/screen #1.png", "C:/tmp/opencode/", "screen%20%231.png"],
["d:/screen.png", "d:/", "screen.png"],
["D:/charts/screen.png", "D:/charts/", "screen.png"],
["Z:/charts/screen.png", "Z:/charts/", "screen.png"],
["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"],
["/screen.png", "/", "screen.png"],
])("reads %s with the correct location and authentication", async (path, directory, encoded) => {
const { api, requests } = setup()
const signal = new AbortController().signal
const blob = await readLocalImage(api, "/workspace/project", path, signal)
expect(blob).toBeInstanceOf(Blob)
expect(blob?.type).toBe("image/png")
expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array([0, 127, 255]))
expect(requests).toHaveLength(1)
expect(requests[0].url.origin).toBe("https://server.example:4096")
expect(requests[0].url.pathname).toBe(`/api/fs/read/${encoded}`)
expect([...requests[0].url.searchParams]).toEqual([["location[directory]", directory]])
expect(requests[0].init?.method).toBe("GET")
expect(new Headers(requests[0].init?.headers).get("authorization")).toBe(`Basic ${btoa("image-user:secret")}`)
expect(requests[0].init?.signal).toBe(signal)
})
test("preserves literal percent escapes and encodes the supplied relative location", async () => {
const { api, requests } = setup()
await readLocalImage(api, "C:/project #1 + 50%", "images/literal%20name.png", new AbortController().signal)
expect(requests[0].url.href).toBe(
"https://server.example:4096/api/fs/read/images/literal%2520name.png?location%5Bdirectory%5D=C%3A%2Fproject+%231+%2B+50%25",
)
})
test.each([
["png", "image/png"],
["jpeg", "image/jpeg"],
["JPG", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["SVG", "image/svg+xml"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
])("uses the MIME type for .%s rather than the response header", async (extension, type) => {
const { api } = setup(
() => new Response("image bytes", { headers: { "content-type": "application/octet-stream" } }),
)
const blob = await readLocalImage(api, "/repo", `image.${extension}`, new AbortController().signal)
expect(blob?.type).toBe(type)
expect(await blob?.text()).toBe("image bytes")
})
test.each([
"https://example.com/image.png",
"HTTP://example.com/image.png",
"file:///tmp/image.png",
"data:image/png;base64,image.png",
"blob:https://example.com/image.png",
"custom+scheme:image.png",
"C:image.png",
"//server/share/image.png",
"\\\\server\\share\\image.png",
"//?/C:/image.png",
"///server/share/image.png",
"image.txt",
"image.html",
"image.constructor",
"image.png.txt",
"image",
"images.png/image",
"image.png/",
"",
])("does not request unsupported input %s", async (path) => {
const { api, requests } = setup()
expect(await readLocalImage(api, "/repo", path, new AbortController().signal)).toBeUndefined()
expect(requests).toHaveLength(0)
})
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
const error = { _tag: "RequestError", message: "Cannot read image" }
const { api } = setup(() => Response.json(error, { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
})
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
const { api } = setup(() => new Response("Not an image", { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status },
})
})
test("propagates transport failures", async () => {
const error = new Error("Connection lost")
const { api } = setup(() => Promise.reject(error))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "Transport",
cause: error,
})
})
test("forwards cancellation to an in-flight fetch", async () => {
const controller = new AbortController()
const { api, requests } = setup(
(init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
}),
)
const result = readLocalImage(api, "/repo", "image.png", controller.signal)
expect(requests[0].init?.signal).toBe(controller.signal)
controller.abort()
await expect(result).rejects.toMatchObject({ reason: "Transport", cause: controller.signal.reason })
})
})
-36
View File
@@ -1,36 +0,0 @@
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
const types = new Map([
["png", "image/png"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["svg", "image/svg+xml"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
])
export async function readLocalImage(
api: Pick<OpenCodeClient, "file">,
directory: string,
path: string,
signal: AbortSignal,
): Promise<Blob | undefined> {
const drive = /^[a-z]:\//i.test(path)
if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return
const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")
if (!type) return
const absolute = drive || path.startsWith("/")
// Scope absolute images to their parent, including files outside the project.
const bytes = await api.file.read(
{
path: absolute ? getFilename(path) : path,
location: { directory: absolute ? getDirectory(path) : directory },
},
{ signal },
)
return new Blob([new Uint8Array(bytes)], { type })
}
@@ -1,6 +1,6 @@
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { getFilename } from "@opencode-ai/util/path"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
@@ -11,7 +11,6 @@ import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
import { createWorktree } from "@/workspaces/create"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
@@ -56,14 +55,7 @@ export function SessionWorkspaceMenu(props: {
setStore("selected", selection)
try {
const destination =
selection === "create"
? await createWorktree({
api: sdk.api,
directory: props.directory,
project: data.location.info({ directory: props.directory })?.project,
})
: selection
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
@@ -132,3 +124,13 @@ export function SessionWorkspaceMenu(props: {
</Menu>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
+4 -10
View File
@@ -1,5 +1,5 @@
import { TextField } from "@opencode-ai/ui/text-field"
import type { captureException } from "@sentry/solid"
import { captureException, isEnabled } from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, onMount, Show } from "solid-js"
@@ -227,7 +227,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
actionError: undefined as string | undefined,
captureException: undefined as typeof captureException | undefined,
})
function ensureFatalErrorRecorded() {
@@ -244,11 +243,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
onMount(() => {
void ensureFatalErrorRecorded().catch(() => undefined)
void import("@sentry/solid")
.then(({ captureException, isEnabled }) => {
if (isEnabled()) setStore("captureException", () => captureException)
})
.catch(() => undefined)
})
async function checkForUpdates() {
@@ -317,15 +311,15 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
{language.t("error.page.action.exportLogs")}
</Button>
</Show>
<Show when={store.captureException}>
{(capture) => {
<Show when={isEnabled}>
{(_) => {
const [reported, setReported] = createSignal(false)
return (
<Button
size="large"
disabled={reported()}
onClick={() => {
capture()(props.error)
captureException(props.error)
setReported(true)
}}
>
@@ -1,5 +1,4 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode-ai/session-ui/context/markdown"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
@@ -9,7 +8,6 @@ import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
export function SessionUIProvider(
props: ParentProps<{
@@ -23,10 +21,6 @@ export function SessionUIProvider(
const serverSDK = useServerSDK()
const tabs = useTabs()
const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => {
const dir = directory()
return (path, signal) => readLocalImage(serverSDK.api, dir, path, signal)
})
const href = (sessionID: string) => sessionHref(props.server, sessionID)
const navigateToSession = async (sessionID: string) => {
const tab = tabs.store.find(
@@ -66,9 +60,7 @@ export function SessionUIProvider(
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
<MarkdownProvider readImage={readImage()}>
<LocalProvider>{props.children}</LocalProvider>
</MarkdownProvider>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
}
@@ -1,90 +0,0 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "POST") return Response.json({ directory: "/created" })
return Response.json({ directory: new URL(request.url).searchParams.get("location[directory]"), project })
},
{ preconnect() {} },
),
})
expect(
await createWorktree({
api,
directory: input.directory,
project: input.cached ? project : undefined,
branch: "clone-only",
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
.filter((request) => request.method === "GET")
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
})
test("does not fall back to a shared project when location lookup fails", async () => {
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(new Request(input, init))
return Response.json({ message: "unavailable" }, { status: 503 })
},
{ preconnect() {} },
),
})
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status: 503 },
})
expect(requests.map((request) => request.method)).toEqual(["GET"])
})
})
-20
View File
@@ -1,20 +0,0 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
directory: string
project?: LocationGetOutput["project"]
branch?: string
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
projectID: project.id,
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
await input.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -1,85 +0,0 @@
import { expect, test } from "bun:test"
import { MemoryRouter, Route, createMemoryHistory, useNavigate, useParams } from "@solidjs/router"
import { createEffect, onCleanup } from "solid-js"
import { createComponent, render } from "solid-js/web"
import { createSessionOwnership } from "@/session/session-ownership"
test("session ownership follows navigation, back, forward, replacement, and route disposal", async () => {
const host = document.createElement("div")
const history = createMemoryHistory()
history.set({ value: "/session/A", replace: true, scroll: false })
const captured: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>[] = []
const navigate: ReturnType<typeof useNavigate>[] = []
const cleaned: string[] = []
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const Session = () => {
const params = useParams<{ id: string }>()
const ownership = createSessionOwnership(() => params.id)
const label = document.createElement("div")
navigate.push(useNavigate())
createEffect(() => {
label.textContent = params.id
captured.push(ownership.capture())
})
onCleanup(() => cleaned.push("session"))
return label
}
const dispose = render(
() =>
createComponent(MemoryRouter, {
history,
get children() {
return [
createComponent(Route, { path: "/", component: () => "Home" }),
createComponent(Route, { path: "/session/:id", component: Session }),
]
},
}),
host,
)
try {
expect(host.textContent).toBe("A")
expect(captured[0].current()).toBe(true)
navigate[0]("/session/B")
await settle()
expect(host.textContent).toBe("B")
expect(captured[0].current()).toBe(false)
expect(captured[1].current()).toBe(true)
history.back()
await settle()
expect(host.textContent).toBe("A")
expect(captured[0].current()).toBe(false)
expect(captured[1].current()).toBe(false)
expect(captured[2].current()).toBe(true)
history.forward()
await settle()
expect(host.textContent).toBe("B")
expect(captured[2].current()).toBe(false)
expect(captured[3].current()).toBe(true)
expect(navigate).toHaveLength(1)
expect(cleaned).toEqual([])
navigate[0]("/", { replace: true })
await settle()
expect(host.textContent).toBe("Home")
expect(captured.every((owner) => !owner.current())).toBe(true)
expect(cleaned).toEqual(["session"])
history.back()
await settle()
expect(host.textContent).toBe("A")
expect(captured[4].current()).toBe(true)
expect(navigate).toHaveLength(2)
} finally {
dispose()
}
expect(captured.every((owner) => !owner.current())).toBe(true)
expect(cleaned).toEqual(["session", "session"])
})
@@ -1,89 +0,0 @@
import { expect, test } from "bun:test"
import {
createComputed,
createMemo,
createResource,
createRoot,
createSignal,
onCleanup,
startTransition,
Suspense,
type Accessor,
type JSX,
} from "solid-js"
import { createComponent, render } from "solid-js/web"
// Solid 1.9.15 includes the former transition patch for solidjs/solid#2046.
test("a memo created during a paused transition has a committed value", async () => {
const host = document.createElement("div")
const loaded = Promise.withResolvers<string>()
const started = Promise.withResolvers<void>()
const memos: Accessor<string>[] = []
const [session, setSession] = createSignal(false)
const dispose = render(
() =>
createComponent(Suspense, {
fallback: "Loading",
get children() {
return createMemo(() => {
if (!session()) return "Home"
memos.push(createMemo(() => "Session"))
const [data] = createResource(() => loaded.promise)
started.resolve()
return createMemo(() => data() ?? "")
}) as unknown as JSX.Element
},
}),
host,
)
try {
expect(host.textContent).toBe("Home")
const pending = startTransition(() => setSession(true))
await started.promise
expect(host.textContent).toBe("Home")
expect(memos).toHaveLength(1)
expect(memos[0]()).toBe("Session")
loaded.resolve("Session loaded")
await pending
expect(host.textContent).toBe("Session loaded")
expect(memos[0]()).toBe("Session")
} finally {
loaded.resolve("Session loaded")
dispose()
}
})
// Exercise each patched bundle independently; importing both dev formats emits Solid's duplicate-runtime warning.
test.each(["solid.js", "solid.cjs", "dev.js", "dev.cjs", "server.js", "server.cjs"])(
"%s reentrant disposal cleans owned computations and callbacks exactly once",
async (file) => {
const runtime: {
createRoot: typeof createRoot
createComputed: typeof createComputed
onCleanup: typeof onCleanup
} = await import(`solid-js/dist/${file}`)
const cleaned: string[] = []
const dispose = runtime.createRoot((dispose) => {
runtime.onCleanup(() => cleaned.push("root"))
runtime.createComputed(() => {
runtime.onCleanup(() => cleaned.push("first"))
})
runtime.createComputed(() => {
runtime.onCleanup(() => cleaned.push("second"))
runtime.onCleanup(() => {
cleaned.push("reentrant")
// Bound the regression so an unpatched runtime fails without overflowing the stack.
if (cleaned.filter((item) => item === "reentrant").length === 1) dispose()
})
})
return dispose
})
expect(dispose).not.toThrow()
expect(cleaned.toSorted()).toEqual(["first", "reentrant", "root", "second"])
dispose()
expect(cleaned).toHaveLength(4)
},
)
-2
View File
@@ -10,8 +10,6 @@ export function serviceWorker(directory: string) {
injectRegister: false,
manifest: false,
workbox: {
// Workbox runs after Sentry's upload and cleanup, so do not publish an unuploaded map.
sourcemap: false,
globDirectory: directory,
clientsClaim: false,
// Keep each open tab on its complete build until all old clients close.
@@ -1,58 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, FileSystem, Layer, Stream } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import assert from "node:assert/strict"
import path from "node:path"
import { Updater } from "../updater"
const latest = { version: "0.0.0-beta-17498" }
const installs: string[] = []
// This fixture runs in its own process; no real update requests or installs occur.
globalThis.fetch = Object.assign(async () => Response.json(latest), { preconnect() {} })
await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const dependencies = Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, fs),
Layer.succeed(
Global.Service,
Global.make({ home: directory, config: directory, cache: path.join(directory, "cache") }),
),
Layer.succeed(AppProcess.Service, {
...ChildProcessSpawner.make(() => Effect.die("Unexpected process spawn")),
runStream: () => Stream.die("Unexpected streaming process"),
run: (command) => {
assert.equal(command._tag, "StandardCommand")
if (command.command === "npm" && command.args[0] === "install") {
assert.ok(command.args.includes("--global"))
installs.push(command.args.at(-1)!)
}
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.from(command.command === "npm" ? "@opencode-ai/cli" : ""),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
})
},
}),
)
yield* Effect.gen(function* () {
const updater = yield* Updater.Service
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
yield* updater.check()
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
latest.version = "0.0.0-beta-17499"
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498", "@opencode-ai/cli@0.0.0-beta-17499"])
}).pipe(Effect.provide(Updater.layer.pipe(Layer.provide(dependencies))))
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)
+2 -2
View File
@@ -5,9 +5,9 @@ const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy, installed = current): Action {
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(installed)
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
-30
View File
@@ -1,30 +1,8 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("remembers successful installs across checks and accepts the next release", async () => {
// Isolate compiled version constants and the update endpoint from other tests.
const child = Bun.spawn(
[
process.execPath,
"--define",
'OPENCODE_VERSION="0.0.0-next-16473"',
"--define",
'OPENCODE_CHANNEL="beta"',
path.join(import.meta.dir, "fixtures/updater.ts"),
],
{ env: { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "" }, stdout: "pipe", stderr: "pipe" },
)
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(code, stdout + stderr).toBe(0)
})
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
@@ -54,14 +32,6 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
})
test("skips an installed update but still accepts the next release", () => {
const current = "0.0.0-next-16473"
const installed = "0.0.0-beta-17498"
expect(action(current, installed, true)).toBe("upgrade")
expect(action(current, installed, true, installed)).toBe("none")
expect(action(current, "0.0.0-beta-17499", true, installed)).toBe("upgrade")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
+11 -15
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -38,7 +38,6 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installed = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
@@ -113,20 +112,18 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl" | "npm">, string[]> = {
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun" || method === "npm") {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const command =
method === "bun"
? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target]
: ["npm", "install", "--global", "--cache", cache, target]
return yield* run(command, "5 minutes")
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
@@ -159,19 +156,18 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const version = yield* latest()
const current = yield* Ref.get(installed)
yield* Effect.logInfo("update check", {
current,
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy, current)
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Ref.set(installed, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
})
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
-2
View File
@@ -183,7 +183,6 @@ export type SessionCreateInput = {
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -411,7 +410,6 @@ export type SessionLogOutput =
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly version: string
}
}
@@ -360,7 +360,6 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -513,7 +513,6 @@ export function make(options: ClientOptions) {
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
},
successStatus: 200,
declaredStatuses: [401, 400],
@@ -29,8 +29,6 @@ export type TokenUsageInfo = {
export type LocationRef = { directory: string; workspaceID?: string }
export type SessionMetadata = { [x: string]: JsonValue }
export type FileDiffInfo = {
file: string
patch: string
@@ -550,7 +548,6 @@ export type SessionCreated = {
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
@@ -1645,7 +1642,6 @@ export type SessionInfo = {
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
@@ -2691,7 +2687,6 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2699,7 +2694,6 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2707,7 +2701,6 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2715,7 +2708,6 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2723,16 +2715,7 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["location"]
readonly metadata?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["metadata"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2769,7 +2752,6 @@ export type SessionImportInput = {
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3046,7 +3028,6 @@ export type SessionImportInput = {
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3323,7 +3304,6 @@ export type SessionImportInput = {
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly revert?: {
readonly messageID: string
readonly partID?: string
+1 -1
View File
@@ -19,7 +19,7 @@
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Report host failure messages and underlying causes rather than replacing them with generic diagnostics. Preserve interruption behavior.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool.
+2 -2
View File
@@ -139,8 +139,8 @@ Diagnostic kinds:
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. |
Host failures and defects report their messages and underlying causes. Invalid outputs include the validation or
copying error. Interruption propagates without becoming an error diagnostic.
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` explicitly exposes a
safe refusal to the model; its optional cause remains private.
## Discovery
+2 -2
View File
@@ -365,7 +365,7 @@ ultimate source of truth.
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.
+2 -2
View File
@@ -1,11 +1,11 @@
import { Schema } from "effect"
/** Tool failure reported as `ToolFailure`. */
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
export class ToolError extends Schema.TaggedError<ToolError>()("ToolError", {
message: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
}) {}
/** Creates a tool failure with an optional underlying cause. */
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
export const toolError = (message: string, cause?: unknown): ToolError =>
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })
+24 -18
View File
@@ -1,5 +1,5 @@
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
import { toolError } from "./tool-error.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
decodeOutput as decodeToolOutput,
@@ -117,6 +117,15 @@ export class ToolRuntimeError extends Error {
}
}
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
}),
)
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
@@ -498,12 +507,19 @@ export const make = <R>(
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const decodeOutput = (value: unknown, name: string) =>
Effect.try({
try: () => copyIn(value, `Result from tool '${name}'`),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
@@ -532,22 +548,12 @@ export const make = <R>(
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
return Effect.fail(
toolError(
Cause.prettyErrors(cause)
.map((error) => (error.cause ? Formatter.format(error) : error.message || error.name))
.join("\n"),
),
)
}),
)
return yield* Effect.try({
try: () => copyIn(decodeToolOutput(tool, raw), `Result from tool '${name}'`),
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
return yield* decodeOutput(result, name)
}),
call,
)
+22 -23
View File
@@ -5,15 +5,15 @@ import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class HostError extends Schema.TaggedError<HostError>()("HostError", {
message: Schema.String,
class UnsafeHostError extends Schema.TaggedError<UnsafeHostError>()("UnsafeHostError", {
reason: Schema.String,
}) {}
describe("CodeMode host failure boundary", () => {
test("preserves explicit tool failures", async () => {
test("preserves explicit safe tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail",
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("Authorized request was refused")),
@@ -26,10 +26,10 @@ describe("CodeMode host failure boundary", () => {
})
})
test("does not rewrite explicit tool failures", async () => {
test("does not rewrite explicit safe tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail",
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
@@ -42,15 +42,10 @@ describe("CodeMode host failure boundary", () => {
})
})
test("reports failures, defects, rejected Promises, and nested causes", async () => {
test("sanitizes unknown host failures and defects", async () => {
for (const failure of [
Effect.fail(new HostError({ message: "Connection refused" })),
Effect.die(new Error("Connection refused")),
Effect.promise(async () => {
throw new Error("Connection refused")
}),
Effect.fail(toolError("Request failed", new Error("Connection refused"))),
Effect.failCause(Cause.combine(Cause.fail("Request failed"), Cause.die("Connection refused"))),
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
]) {
const result = await run(
Tool.make({
@@ -63,28 +58,31 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "ToolFailure",
message: expect.stringContaining("Connection refused"),
message: "Tool execution failed",
})
expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/)
}
})
test("reports invalid host output", async () => {
test("sanitizes invalid host output", async () => {
const secret = "invalid-output-secret"
const result = await run(
Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.succeed({ value: 1 } as unknown as { readonly value: string }),
output: Schema.Struct({ safe: Schema.String }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
}),
)
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call': SchemaError(Expected string\n at [\"value\"])",
message: "Invalid output from tool 'host.call'.",
})
expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/)
})
test("reports host output copying errors", async () => {
test("sanitizes host output that throws while being copied", async () => {
const result = await run(
Tool.make({
description: "Return hostile output",
@@ -96,7 +94,7 @@ describe("CodeMode host failure boundary", () => {
{},
{
ownKeys: () => {
throw new Error("Cannot enumerate output")
throw new Error("host-output-secret")
},
},
),
@@ -106,8 +104,9 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call': Error: Cannot enumerate output",
message: "Invalid output from tool 'host.call'.",
})
expect(JSON.stringify(result)).not.toMatch(/host-output-secret/)
})
test("caught tool failures are Error values in-program", async () => {
@@ -230,7 +229,7 @@ describe("CodeMode tool-call observation", () => {
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
])
})
+1 -3
View File
@@ -12,9 +12,7 @@ import { AbsolutePath } from "../src/schema"
const args = process.argv.slice(2)
const iterationsIndex = args.indexOf("--iterations")
const iterations = iterationsIndex === -1 ? 10 : Number(args[iterationsIndex + 1])
const directory =
args.find((arg, index) => !arg.startsWith("--") && (iterationsIndex === -1 || index !== iterationsIndex + 1)) ??
process.cwd()
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== iterationsIndex + 1) ?? process.cwd()
if (!Number.isInteger(iterations) || iterations < 1) {
console.error("--iterations must be a positive integer")
+5 -60
View File
@@ -514,7 +514,7 @@ function userPart(part: ContentPart): UserContent {
function assistantPart(part: ContentPart): AssistantContent {
switch (part.type) {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
return [{ type: "text", text: part.text }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
case "reasoning":
@@ -612,12 +612,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
catch: (error) => llmError(error, "request"),
catch: llmError,
}).pipe(
Effect.map((result) =>
Stream.fromReadableStream({
evaluate: () => result.stream,
onError: (error) => llmError(error, "read"),
onError: llmError,
}).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
@@ -744,7 +744,7 @@ function streamPartEvents(
}),
])
case "error":
return Effect.fail(llmError(event.error, "read"))
return Effect.fail(llmError(event.error))
}
}
@@ -794,20 +794,9 @@ function messageValue(input: unknown) {
}
}
function llmError(error: unknown, operation: "request" | "read") {
function llmError(error: unknown) {
if (error instanceof AIError) return error
if (APICallError.isInstance(error)) return apiCallError(error)
const network = networkFailure(error)
if (network)
return new AIError({
reason: new TransportError({
message: network.message.trim() === "" ? unknownErrorMessage(error) : network.message,
cause: error,
transport: "http",
operation,
code: network.code,
}),
})
return new AIError({
reason: new UnknownProviderError({
message: unknownErrorMessage(error),
@@ -817,50 +806,6 @@ function llmError(error: unknown, operation: "request" | "read") {
})
}
// Runtime-generated network failure shapes. The codes mirror the AI SDK's own
// Bun network error list in handleFetchError; the messages are undici's fetch
// TypeError and stream termination strings plus our SSE chunk timeout error.
// Unrecognized shapes still retry via the UnknownProvider default; this match
// only adds transport semantics (continuation eligibility, display).
const NETWORK_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"EPIPE",
"ConnectionRefused",
"ConnectionClosed",
"FailedToOpenSocket",
])
const NETWORK_ERROR_MESSAGES = new Set([
"fetch failed",
"failed to fetch",
"terminated",
"other side closed",
"sse read timed out",
])
const NativeErrorShape = Schema.Struct({
message: Schema.String,
code: Schema.optionalKey(Schema.String),
cause: Schema.optionalKey(Schema.Unknown),
})
const decodeNativeErrorShape = Schema.decodeUnknownOption(NativeErrorShape)
function networkFailure(error: unknown, depth = 0): { message: string; code?: string } | undefined {
if (depth > 4) return undefined
const shape = Option.getOrUndefined(decodeNativeErrorShape(error))
if (!shape) return undefined
// Prefer the deepest match: wrappers like undici's "fetch failed" TypeError
// carry the specific network code on their cause.
const cause = networkFailure(shape.cause, depth + 1)
if (cause) return cause
if (shape.code !== undefined && (NETWORK_ERROR_CODES.has(shape.code) || shape.code.startsWith("UND_ERR")))
return { message: shape.message, code: shape.code }
if (NETWORK_ERROR_MESSAGES.has(shape.message.trim().toLowerCase()))
return { message: shape.message, code: shape.code }
return undefined
}
function apiCallError(error: APICallError) {
const failure = RequestExecutor.httpFailure({
message: providerErrorMessage(error),
+5 -3
View File
@@ -3,7 +3,7 @@ export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { definition } from "../tool/runtime.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -65,7 +65,9 @@ export const create = (
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* executeTool(name, tool, input, context)
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.mapError((failure) => toolError(failure.message, failure)),
)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
@@ -163,7 +165,7 @@ function runtime(
}
function qualifiedName(registration: Info) {
const normalized = normalizedName(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
+18 -39
View File
@@ -53,9 +53,6 @@ export class UpdateError extends Schema.TaggedError<UpdateError>()("Config.Updat
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
// false skips the global config dir, ~/.claude, and ~/.agents; wellknown,
// file, and content entries still load.
global: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
})
@@ -84,13 +81,12 @@ export const testLayer = (initial: Entry[] = []) =>
Effect.gen(function* () {
const current = yield* Ref.get(entries)
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
if (index === -1)
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const entry = current[index]
if (!entry || entry.type !== "document")
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const info = yield* Effect.try({
try: () => produce(entry.info, update),
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
const info = produce(entry.info, update)
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
return info
}),
@@ -223,39 +219,28 @@ export const layer = (options?: Options) =>
})
.pipe(Effect.orDie)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below.
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
path.resolve(item),
)
const visible = globalEnabled
? discovered
: discovered.filter((item) => {
const resolved = path.resolve(item)
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
})
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
const directPaths = discovered
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
@@ -287,21 +272,15 @@ export const layer = (options?: Options) =>
)
: []
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...globalSupplementary,
...(supplementary[0] ?? []),
...explicit,
...direct,
...projectSupplementary,
...supplementary.slice(1).flat(),
...content,
]
})

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