mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b436244729 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/theme": patch
|
||||
---
|
||||
|
||||
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": major
|
||||
---
|
||||
|
||||
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
"@opencode-ai/app": patch
|
||||
---
|
||||
|
||||
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
|
||||
|
||||
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+1
-1
@@ -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", "electron-builder", "electron-publish", "blume"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+1
-2
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
+4
-4
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
+22
-26
@@ -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",
|
||||
"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",
|
||||
|
||||
+11
-29
@@ -214,40 +214,22 @@ the requests sent by code under test:
|
||||
import { Effect } from "effect"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||
})
|
||||
|
||||
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||
const programWithTestClient = Effect.gen(function* () {
|
||||
const test = yield* TestLLM.Test
|
||||
yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
|
||||
const result = yield* program
|
||||
console.log(yield* test.requests())
|
||||
const test = yield* TestLLM.Service
|
||||
console.log(test.requests)
|
||||
return result
|
||||
}).pipe(Effect.provide(TestLLM.testLayer()))
|
||||
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||
```
|
||||
|
||||
`testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
|
||||
normal client; tests use the additional controls. Each layer build has fresh state.
|
||||
|
||||
- `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
|
||||
- `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
|
||||
- `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
|
||||
other without changing queued replies; queued replies take precedence.
|
||||
- `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
|
||||
`serve` receives the original canonical request.
|
||||
- `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
|
||||
- `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
|
||||
unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
|
||||
already provide Scope.
|
||||
|
||||
Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||
available on the yielded `TestLLM.Service`.
|
||||
|
||||
## Caching
|
||||
|
||||
|
||||
@@ -496,7 +496,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 +574,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 +612,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 +703,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(),
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -1169,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>(),
|
||||
@@ -1183,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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+52
-103
@@ -1,6 +1,6 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { LLMClient, type Interface as LLMClientShape } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
@@ -16,33 +16,13 @@ export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
|
||||
|
||||
export type Responder = (request: LLMRequest) => Response
|
||||
|
||||
export interface TestInterface extends ClientInterface {
|
||||
/** Returns a snapshot of requests observed at execution time. */
|
||||
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
/** Replaces the fallback without changing queued responses. */
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
|
||||
readonly serve: (responder: Responder) => Effect.Effect<void>
|
||||
/** Waits for request arrivals, not output or completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
|
||||
|
||||
/** @deprecated Use TestInterface through Test and testLayer. */
|
||||
export interface Interface {
|
||||
readonly requests: LLMRequest[]
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
|
||||
readonly client: ClientInterface
|
||||
readonly client: LLMClientShape
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
@@ -51,7 +31,6 @@ export interface LayerOptions {
|
||||
readonly fallback?: Response
|
||||
}
|
||||
|
||||
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
@@ -101,64 +80,59 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback: Response | Responder | undefined = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const stream = ((request: LLMRequest) => {
|
||||
requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
const response = responses.shift() ?? fallback
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
|
||||
const streamed = toStream(response)
|
||||
const gate = activeGate
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
}) as LLMClientShape["stream"]
|
||||
const client = LLMClient.Service.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
})
|
||||
const test = Test.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
|
||||
return Service.of({
|
||||
requests,
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
}),
|
||||
),
|
||||
requests: () => Effect.sync(() => [...requests]),
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
serve: (responder) =>
|
||||
Effect.sync(() => {
|
||||
fallback = responder
|
||||
}),
|
||||
wait,
|
||||
gate: () =>
|
||||
Effect.gen(function* () {
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
wait,
|
||||
gate: Effect.gen(function* () {
|
||||
const gate = {
|
||||
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
|
||||
release: yield* Latch.make(),
|
||||
@@ -173,36 +147,11 @@ const make = (options: LayerOptions) =>
|
||||
release,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
return { test, requests }
|
||||
})
|
||||
|
||||
/** Provides one shared implementation under the normal client and test-control tags. */
|
||||
export const testLayer = (options: LayerOptions = {}) =>
|
||||
Layer.effectContext(
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test)),
|
||||
),
|
||||
client,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Service.of({
|
||||
requests: implementation.requests,
|
||||
push: implementation.test.push,
|
||||
always: implementation.test.always,
|
||||
wait: implementation.test.wait,
|
||||
gate: implementation.test.gate(),
|
||||
client: implementation.test,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated testLayer provides LLMClient.Service directly. */
|
||||
export const clientLayer = Layer.effect(
|
||||
LLMClient.Service,
|
||||
Effect.map(Service, (service) => service.client),
|
||||
|
||||
@@ -574,7 +574,6 @@ describe("WebSocket channel execution", () => {
|
||||
const model = 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_item.added", item: { type: "message", id: "msg_1" } }),
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
@@ -32,8 +32,6 @@ describe("public exports", () => {
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
expect(TestLLM.testLayer).toBeFunction()
|
||||
expect(TestLLM.Test.of).toBeFunction()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
Vendored
+1
-1
@@ -24,7 +24,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -41,7 +41,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { model } from "@opencode-ai/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
@@ -38,8 +36,7 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
expect(modules[19].model).toBe(modules[21].model)
|
||||
expect(modules[19].model).not.toBe(modules[20].model)
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
@@ -142,10 +139,8 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
path: "/messages",
|
||||
})
|
||||
expect(
|
||||
Endpoint.render(selected.route.endpoint, { request: LLM.request({ model: selected }), body: {} }).toString(),
|
||||
).toBe("https://messages.example.test/v1/messages")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -807,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(
|
||||
|
||||
@@ -405,19 +405,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(
|
||||
|
||||
@@ -305,11 +305,9 @@ describe("OpenRouter", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
content: null,
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -337,14 +335,7 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -370,14 +361,7 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "AB",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -392,16 +376,7 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: undefined,
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: undefined,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }),
|
||||
prompt: "Say hello",
|
||||
})
|
||||
const legacy = testEffect(TestLLM.layer())
|
||||
const it = testEffect(TestLLM.testLayer())
|
||||
|
||||
describe("TestLLM legacy client", () => {
|
||||
legacy.effect("does not observe requests or consume responses until execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
|
||||
llm.client.stream(request)
|
||||
llm.client.generate(request)
|
||||
expect(llm.requests).toEqual([])
|
||||
|
||||
expect((yield* llm.client.generate(request)).text).toBe("first")
|
||||
expect((yield* llm.client.generate(request)).text).toBe("second")
|
||||
expect(llm.requests).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("assigns and records a fresh response for each execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(
|
||||
TestLLM.text("first", "first"),
|
||||
TestLLM.text("second", "second"),
|
||||
TestLLM.text("third", "third"),
|
||||
TestLLM.text("fourth", "fourth"),
|
||||
)
|
||||
const stream = llm.client.stream(request)
|
||||
const generate = llm.client.generate(request)
|
||||
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("first", "first"))
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("second", "second"))
|
||||
expect((yield* generate).text).toBe("third")
|
||||
expect((yield* generate).text).toBe("fourth")
|
||||
expect(llm.requests).toEqual([request, request, request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("keeps module-level controls and clientLayer on the same backing state", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const requests = llm.requests
|
||||
yield* TestLLM.push(TestLLM.text("queued", "queued"))
|
||||
yield* TestLLM.always(TestLLM.text("fallback", "fallback"))
|
||||
expect((yield* LLMClient.generate(request).pipe(Effect.provide(TestLLM.clientLayer))).text).toBe("queued")
|
||||
yield* TestLLM.wait(1)
|
||||
expect(requests).toEqual([request])
|
||||
requests.length = 0
|
||||
expect((yield* llm.client.generate(request)).text).toBe("fallback")
|
||||
expect(llm.requests).toBe(requests)
|
||||
expect(requests).toEqual([request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const client = yield* LLMClient.Service
|
||||
expect(client).toBe(llm)
|
||||
const before = yield* llm.requests()
|
||||
yield* llm.push(TestLLM.text("hello", "answer"))
|
||||
const generate = client.generate(request)
|
||||
client.stream(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
|
||||
expect((yield* generate).text).toBe("hello")
|
||||
expect(before).toEqual([])
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
expect(yield* llm.requests()).not.toBe(yield* llm.requests())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prioritizes queued replies over request-dependent and constant fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const served: LLMRequest[] = []
|
||||
yield* llm.always(TestLLM.text("old fallback", "old"))
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
yield* llm.serve((request) => {
|
||||
served.push(request)
|
||||
return TestLLM.text(request.promptCacheKey ?? "default", "served")
|
||||
})
|
||||
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("first")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("second")
|
||||
expect(served).toEqual([])
|
||||
const selected = LLMRequest.update(request, { promptCacheKey: "selected" })
|
||||
expect((yield* LLMClient.generate(selected)).text).toBe("selected")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("default")
|
||||
expect(served).toEqual([selected, request])
|
||||
|
||||
yield* llm.push(TestLLM.text("queued again", "queued"))
|
||||
yield* llm.always(TestLLM.text("constant", "constant"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("queued again")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect(served).toEqual([selected, request])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
TestLLM.testLayer({
|
||||
transformRequest: (request) => LLMRequest.update(request, { promptCacheKey: "observation" }),
|
||||
}),
|
||||
).effect("transforms observations without changing the request passed to the responder", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.serve((input) => {
|
||||
expect(input).toBe(request)
|
||||
return TestLLM.text("original", "answer")
|
||||
})
|
||||
const generate = llm.generate(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
expect((yield* generate).text).toBe("original")
|
||||
expect(yield* llm.requests()).toEqual([LLMRequest.update(request, { promptCacheKey: "observation" })])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("broadcasts request-arrival waits and satisfies waits registered afterward", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const first = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* llm.generate(request)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* llm.generate(request)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
yield* llm.wait(2)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
;(["queued", "served"] as const).forEach((mode) => {
|
||||
it.effect(`assigns ${mode} replies before resuming request-arrival continuations`, () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const responses = [TestLLM.text("first", "first"), TestLLM.text("second", "second")]
|
||||
yield* mode === "queued" ? llm.push(...responses) : llm.serve(() => responses.shift() ?? [])
|
||||
const later = yield* llm
|
||||
.wait(1)
|
||||
.pipe(Effect.andThen(llm.generate(request)), Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
expect((yield* llm.generate(request)).text).toBe("first")
|
||||
expect((yield* Fiber.join(later)).text).toBe("second")
|
||||
expect(yield* llm.requests()).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("notifies arrival waiters even when the responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
const waiter = yield* llm.wait(1).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* Fiber.join(waiter)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds independent state even when the same layer is provided concurrently", () => {
|
||||
const layer = TestLLM.testLayer()
|
||||
const run = Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
yield* llm.push(TestLLM.text("one", "answer"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("one")
|
||||
return yield* llm.requests()
|
||||
}).pipe(Effect.provide(layer))
|
||||
return Effect.gen(function* () {
|
||||
expect(yield* Effect.all([run, run], { concurrency: "unbounded" })).toEqual([[request], [request]])
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("counts concurrent starts on one gate without serializing their response assignment", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
const generate = llm.generate(request)
|
||||
const gate = yield* llm.gate()
|
||||
const first = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
const second = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* llm.wait(2)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect((yield* Fiber.join(first)).text).toBe("first")
|
||||
expect((yield* Fiber.join(second)).text).toBe("second")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not clear a replacement gate when the previous gate is released", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const previous = yield* llm.gate()
|
||||
const first = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* previous.started
|
||||
const next = yield* llm.gate()
|
||||
yield* previous.release
|
||||
yield* Fiber.join(first)
|
||||
|
||||
const second = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* next.started
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* next.release
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases a gate when its deliberately narrower scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
// Only the gate is scoped here; its release must happen before the test ends.
|
||||
const run = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
return run
|
||||
}),
|
||||
)
|
||||
yield* Fiber.join(run)
|
||||
yield* llm.generate(request)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an executed response consumed after interruption and permits later requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("interrupted", "first"), TestLLM.text("next", "second"))
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* Fiber.interrupt(run)
|
||||
yield* gate.release
|
||||
expect((yield* llm.generate(request)).text).toBe("next")
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("consumes a supplied stream's post-finish tail and runs its finalizer", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const tail = yield* Latch.make()
|
||||
const release = yield* Latch.make()
|
||||
const finalized = yield* Latch.make()
|
||||
yield* llm.push(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => finalized.open)
|
||||
return Stream.fromIterable(TestLLM.text("complete", "answer")).pipe(
|
||||
Stream.concat(Stream.fromEffect(tail.open.pipe(Effect.andThen(release.await))).pipe(Stream.drain)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* tail.await
|
||||
expect(run.pollUnsafe()).toBeUndefined()
|
||||
yield* release.open
|
||||
expect((yield* Fiber.join(run)).text).toBe("complete")
|
||||
yield* finalized.await
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves irregular events, ordinary EOF, typed failures, and responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const events = [LLMEvent.textDelta({ id: "without-start", text: "partial" })]
|
||||
yield* llm.push(events, [])
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual(events)
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual([])
|
||||
|
||||
const failure = new AIError({ reason: new RateLimitError({ message: "Try later" }) })
|
||||
const observed: LLMEvent[] = []
|
||||
yield* llm.serve(() => TestLLM.failAfter(failure, ...events))
|
||||
expect(
|
||||
yield* llm.stream(request).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => observed.push(event))),
|
||||
Effect.flip,
|
||||
),
|
||||
).toBe(failure)
|
||||
expect(observed).toEqual(events)
|
||||
expect(yield* llm.generate(request).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* llm.push(TestLLM.text("recovered", "answer"))
|
||||
expect((yield* llm.generate(request)).text).toBe("recovered")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defects on unexpected requests instead of waiting for a late script", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("TestLLM has no response for request 1")
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
yield* llm.push(TestLLM.stop())
|
||||
yield* llm.generate(request)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
"include": ["test/**/*.types.ts"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -892,6 +892,23 @@ export const dict = {
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.tab.about": "About",
|
||||
"settings.about.version": "Version {{version}}",
|
||||
"settings.about.devVersion": "development",
|
||||
"settings.about.license": "Released under the MIT License",
|
||||
"settings.about.writtenBy": "Written by",
|
||||
"settings.about.illustratedBy": "Illustrated by",
|
||||
"settings.about.and": "and",
|
||||
"settings.about.otherContributor.one": "{{count}} other",
|
||||
"settings.about.otherContributor.other": "{{count}} others",
|
||||
"settings.about.firstPublished": "First published in Missouri, USA",
|
||||
"settings.about.firstIllustrated": "First illustrated in London, England",
|
||||
"settings.about.website": "www.opencode.ai",
|
||||
"settings.about.description": "OpenCode, the open source coding agent",
|
||||
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
|
||||
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
|
||||
"settings.about.tagline": "AI can’t build great software, without you",
|
||||
"settings.about.copyright": "© Anomaly Innovations, Inc.",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.appearance.section.experimental": "Experimental",
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { For, createResource, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import legal from "./legal.svg"
|
||||
import anomalyBrush from "./anomaly-brush.svg"
|
||||
import { AnimatedWordmark } from "./animated-wordmark"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
|
||||
|
||||
const writers = [
|
||||
"thdxr",
|
||||
"adamdotdev",
|
||||
"rekram1-node",
|
||||
"kitlangton",
|
||||
"iamdavidhill",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"brendonovich",
|
||||
"nexxeln",
|
||||
"hona",
|
||||
"kommander",
|
||||
"jlongster",
|
||||
"vimtor",
|
||||
"r44vcorp",
|
||||
"simonklee",
|
||||
"arvsrn",
|
||||
] as const
|
||||
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
|
||||
|
||||
export function SettingsAbout(props: { active: boolean }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [otherContributors] = createResource(
|
||||
() => props.active || undefined,
|
||||
() => loadOtherContributorCount(platform.fetch ?? fetch),
|
||||
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="settings-about-content">
|
||||
<div class="settings-about-intro">
|
||||
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
|
||||
<p>{language.t("settings.about.license")}</p>
|
||||
</div>
|
||||
|
||||
<AnimatedWordmark active={props.active} />
|
||||
|
||||
<div class="settings-about-credits">
|
||||
<CreditLine
|
||||
label={language.t("settings.about.writtenBy")}
|
||||
names={writers}
|
||||
and={language.t("settings.about.and")}
|
||||
tail={
|
||||
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
|
||||
{language.plural("settings.about.otherContributor", otherContributors(), {
|
||||
count: otherContributors(),
|
||||
})}
|
||||
</ExternalLink>
|
||||
}
|
||||
/>
|
||||
<CreditLine
|
||||
label={language.t("settings.about.illustratedBy")}
|
||||
names={illustrators}
|
||||
and={language.t("settings.about.and")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-about-publication">
|
||||
<p>{language.t("settings.about.firstPublished")}</p>
|
||||
<p>{language.t("settings.about.firstIllustrated")}</p>
|
||||
</div>
|
||||
|
||||
<p class="settings-about-faint">
|
||||
<ExternalLink href="https://opencode.ai">
|
||||
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
<div class="settings-about-details">
|
||||
<p>{language.t("settings.about.description")}</p>
|
||||
<p>{language.t("settings.about.trademark")}</p>
|
||||
<p>{language.t("settings.about.typeset")}</p>
|
||||
</div>
|
||||
|
||||
<p>{language.t("settings.about.tagline")}</p>
|
||||
<img class="settings-about-legal" src={legal} alt="" />
|
||||
<div class="settings-about-copyright">
|
||||
<p>{language.t("settings.about.copyright")}</p>
|
||||
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditLine(props: {
|
||||
label: string
|
||||
names: readonly string[]
|
||||
and: string
|
||||
tail?: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<p>
|
||||
{props.label}{" "}
|
||||
<For each={props.names}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
|
||||
<bdi dir="ltr">
|
||||
<ExternalLink href={profile(name)}>{name}</ExternalLink>
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{props.tail ? <>, {props.and} {props.tail}</> : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function profile(name: string) {
|
||||
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
|
||||
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
|
||||
return `https://github.com/${name}`
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
|
||||
const choices = ["o", "p", "e", "n", "c", "d"] as const
|
||||
|
||||
export function AnimatedWordmark(props: { active: boolean }) {
|
||||
const [state, setState] = createStore({ letters: [...target] })
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.clear()
|
||||
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setState("letters", [...target])
|
||||
return
|
||||
}
|
||||
|
||||
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
|
||||
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
|
||||
const last = Math.max(...settles)
|
||||
setState("letters", starts)
|
||||
|
||||
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
|
||||
const timer = setTimeout(() => {
|
||||
setState(
|
||||
"letters",
|
||||
target.map((letter, index) =>
|
||||
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
|
||||
),
|
||||
)
|
||||
timers.delete(timer)
|
||||
}, tick * 75)
|
||||
timers.add(timer)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => timers.forEach(clearTimeout))
|
||||
|
||||
return (
|
||||
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
|
||||
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
|
||||
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
|
||||
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
<For each={state.letters}>
|
||||
{(letter, index) => (
|
||||
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
|
||||
|
||||
describe("otherContributorCount", () => {
|
||||
test("subtracts the contributors named in the colophon", () => {
|
||||
expect(
|
||||
otherContributorCount(
|
||||
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
|
||||
),
|
||||
).toBe(988)
|
||||
})
|
||||
|
||||
test("falls back when pagination metadata is unavailable", () => {
|
||||
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const CREDITED_CONTRIBUTORS = 16
|
||||
let request: Promise<number> | undefined
|
||||
|
||||
export const FALLBACK_OTHER_CONTRIBUTORS = 935
|
||||
|
||||
export function otherContributorCount(link: string | null) {
|
||||
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
|
||||
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
|
||||
return total - CREDITED_CONTRIBUTORS
|
||||
}
|
||||
|
||||
export function loadOtherContributorCount(fetcher: typeof fetch) {
|
||||
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}).then(
|
||||
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
|
||||
() => FALLBACK_OTHER_CONTRIBUTORS,
|
||||
)
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity=".5" fill="#808080">
|
||||
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
|
||||
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
|
||||
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -74,6 +74,102 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-about {
|
||||
align-items: center;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.settings-about-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
min-height: 462px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
padding-block: 80px 32px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-about-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-about-intro,
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-about-intro {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-about-wordmark {
|
||||
width: 178px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.2;
|
||||
color: var(--v2-text-text-muted);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.settings-about-letter-shadow {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.settings-about-faint,
|
||||
.settings-about-credits {
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-about-legal {
|
||||
width: 109.2px;
|
||||
height: 19.2px;
|
||||
}
|
||||
|
||||
.settings-about-copyright {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-about-anomaly-brush {
|
||||
width: 126.72px;
|
||||
height: 48.384px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-about-content a {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-about-content a:hover,
|
||||
.settings-about-content a:focus-visible {
|
||||
color: var(--v2-text-text-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SettingsServers } from "./servers/servers"
|
||||
import { SettingsWorkspaces } from "./workspaces/workspaces"
|
||||
import { SettingsProjects } from "./workspaces/projects"
|
||||
import { SettingsExtensions } from "./providers/extensions"
|
||||
import { SettingsAbout } from "./about/about"
|
||||
import { SettingsServerScope } from "./server-scope"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -161,6 +162,21 @@ export const SettingsScreen: Component<{
|
||||
{language.t("settings.tab.extensions")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
|
||||
{/* Group 4: About */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<Tabs.Trigger value="about">
|
||||
<svg data-slot="icon-svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<g transform="translate(2 2)">
|
||||
<path
|
||||
d="M12 12H0V0H12V12ZM1 1V11H11V1H1ZM6.5791 4.81641V9.37207H5.5791V5.81641H4.46777V4.81641H6.5791ZM6.85645 2.62891V3.62891H5.30078V2.62891H6.85645Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{language.t("settings.tab.about")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-footer">
|
||||
@@ -203,6 +219,9 @@ export const SettingsScreen: Component<{
|
||||
<SettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
<SettingsAbout active={tab() === "about"} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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,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)
|
||||
},
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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)),
|
||||
)
|
||||
@@ -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): Action {
|
||||
export function action(current: string, latest: string, policy: Policy, installed = current): Action {
|
||||
if (policy === false) return "none"
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const currentVersion = parseReleaseVersion(installed)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
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)
|
||||
@@ -32,6 +54,14 @@ 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")
|
||||
})
|
||||
|
||||
@@ -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 } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -38,6 +38,7 @@ 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* () {
|
||||
@@ -112,18 +113,20 @@ 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">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "npm">, string[]> = {
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
if (method === "bun" || method === "npm") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
const command =
|
||||
method === "bun"
|
||||
? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target]
|
||||
: ["npm", "install", "--global", "--cache", cache, target]
|
||||
return yield* run(command, "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
@@ -156,18 +159,19 @@ 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: OPENCODE_VERSION,
|
||||
current,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
const next = action(OPENCODE_VERSION, version, policy, current)
|
||||
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: OPENCODE_VERSION, latest: version })
|
||||
if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
yield* Ref.set(installed, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
})
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1639,23 +1637,6 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = { readonly sessionID: Session.ID; readonly lines?: number | undefined }
|
||||
export type ExperimentalPersistentPtyReadOutput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly title: string
|
||||
readonly cwd: string
|
||||
readonly foregroundProcess: string | null
|
||||
readonly screen: {
|
||||
readonly text: string
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
} | null
|
||||
export type ExperimentalPersistentPtyReadOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyReadInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyReadOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
|
||||
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
@@ -1804,7 +1785,6 @@ export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
|
||||
|
||||
export interface ExperimentalApi<E = never> {
|
||||
readonly persistentPty: {
|
||||
readonly read: ExperimentalPersistentPtyReadOperation<E>
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
|
||||
@@ -198,8 +198,6 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -362,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),
|
||||
@@ -1236,15 +1233,6 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: EndpointPtyConnectToken(raw) },
|
||||
})
|
||||
|
||||
const EndpointExperimentalPersistentPtyRead =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyReadInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyReadOutput>()(
|
||||
raw["persistentPty.read"]({ params: { sessionID: input["sessionID"] }, query: { lines: input["lines"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyList =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyListOutput>()(
|
||||
@@ -1333,7 +1321,6 @@ const EndpointExperimentalPersistentPtyConnectToken =
|
||||
|
||||
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
persistentPty: {
|
||||
read: EndpointExperimentalPersistentPtyRead(raw),
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
|
||||
@@ -194,8 +194,6 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -515,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],
|
||||
@@ -1686,18 +1683,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
experimental: {
|
||||
persistentPty: {
|
||||
read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyReadOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal/read`,
|
||||
query: { lines: input["lines"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -378,14 +376,6 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyReadResult = {
|
||||
ptyID: string
|
||||
title: string
|
||||
cwd: string
|
||||
foregroundProcess: string | null
|
||||
screen: { text: string; cols: number; rows: number; cursor: { x: number; y: number } }
|
||||
}
|
||||
|
||||
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
@@ -558,7 +548,6 @@ export type SessionCreated = {
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
version: string
|
||||
}
|
||||
}
|
||||
@@ -1653,7 +1642,6 @@ export type SessionInfo = {
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
@@ -2699,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
|
||||
@@ -2707,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
|
||||
@@ -2715,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
|
||||
@@ -2723,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
|
||||
@@ -2731,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"]
|
||||
@@ -2777,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
|
||||
@@ -3054,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
|
||||
@@ -3331,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
|
||||
@@ -5777,13 +5749,6 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly lines?: { readonly lines?: number | undefined }["lines"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadOutput = { data: PersistentPtyReadResult | null }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
@@ -215,10 +215,8 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const sync = createSync()
|
||||
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
|
||||
|
||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||
activeUpdates?.set(sessionID, status)
|
||||
setStore("session", "active", sessionID, status)
|
||||
}
|
||||
|
||||
@@ -475,7 +473,6 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
activeUpdates?.set(sessionID, undefined)
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
messageIndex.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
@@ -507,25 +504,17 @@ export function createData(config: CreateDataInput) {
|
||||
|
||||
function handleEvent(event: OpenCodeEvent) {
|
||||
switch (event.type) {
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
case "server.connected":
|
||||
void api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
setStore(
|
||||
"session",
|
||||
"active",
|
||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
void api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
@@ -536,7 +525,6 @@ export function createData(config: CreateDataInput) {
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
return
|
||||
}
|
||||
case "project.updated":
|
||||
setStore("project", "info", event.data.id, reconcile(event.data))
|
||||
return
|
||||
|
||||
@@ -50,8 +50,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
expect(client.experimental.persistentPty.read).toBeFunction()
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
|
||||
@@ -485,102 +485,6 @@ test("preserves assistant content replacement events across an active message re
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
"session.execution.succeeded",
|
||||
"session.execution.failed",
|
||||
"session.execution.interrupted",
|
||||
"session.execution.started",
|
||||
"session.deleted",
|
||||
] as const)("preserves %s activity when an older snapshot arrives", async (type) => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const setup = activityFixture(async () => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return Response.json({
|
||||
data: {
|
||||
...(type === "session.execution.started" ? {} : { ses_refresh: { type: "running" } }),
|
||||
ses_hydrated: { type: "running" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (type !== "session.execution.started") setup.data.session.setStatus("ses_refresh", "running")
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await requested.promise
|
||||
setup.emit({
|
||||
id: "evt_activity",
|
||||
created: 2,
|
||||
type,
|
||||
durable: { aggregateID: "ses_refresh", seq: 2, version: 1 },
|
||||
data: { sessionID: "ses_refresh", reason: "user" },
|
||||
})
|
||||
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
|
||||
release.resolve()
|
||||
await wait(() => setup.data.session.status("ses_hydrated") === "running")
|
||||
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores activity snapshots from an older connection", async () => {
|
||||
const reads: ReturnType<typeof Promise.withResolvers<Response>>[] = []
|
||||
const setup = activityFixture(() => {
|
||||
const read = Promise.withResolvers<Response>()
|
||||
reads.push(read)
|
||||
return read.promise
|
||||
})
|
||||
|
||||
try {
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await wait(() => reads.length === 1)
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await wait(() => reads.length === 2)
|
||||
reads[1]?.resolve(Response.json({ data: { ses_new: { type: "running" } } }))
|
||||
await wait(() => setup.data.session.status("ses_new") === "running")
|
||||
reads[0]?.resolve(Response.json({ data: { ses_old: { type: "running" } } }))
|
||||
await Bun.sleep(20)
|
||||
expect(setup.data.session.status("ses_new")).toBe("running")
|
||||
expect(setup.data.session.status("ses_old")).toBe("idle")
|
||||
} finally {
|
||||
reads.forEach((read) => read.resolve(Response.json({ data: {} })))
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function activityFixture(read: () => Response | Promise<Response>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/session/active") return read()
|
||||
if (path === "/api/project") return Response.json([])
|
||||
if (path === "/api/location") return Response.json({ directory: "/project" })
|
||||
return Response.json({ location: { directory: "/project" }, data: { branch: "main" } })
|
||||
},
|
||||
})
|
||||
return createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
|
||||
dispose,
|
||||
}))
|
||||
}
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) })
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user