diff --git a/.github/release/plan-release.sh b/.github/release/plan-release.sh index 01ed6f3a4c8e..fc4dcc1470ef 100755 --- a/.github/release/plan-release.sh +++ b/.github/release/plan-release.sh @@ -3,7 +3,7 @@ # (INPUT_BUMP, INPUT_VERSION) only, and writes them to $GITHUB_OUTPUT. Nothing here reads repository files. # # push or dispatch on dev -> dev 0.0.0-dev- npm tag dev, desktop -# dispatch on dev with bump/version -> latest x.y.z npm tag latest, releasers only +# dispatch on dev with bump/version -> latest x.y.z npm tag latest, releasers only, notes required # push or dispatch on beta -> beta 0.0.0-beta- desktop only # pull_request -> pr 0.0.0-pr- everything, dry run # @@ -22,6 +22,10 @@ case "$GITHUB_EVENT_NAME:$GITHUB_REF:$versioned" in *) die "$GITHUB_EVENT_NAME on $GITHUB_REF cannot publish (versioned: $versioned)" ;; esac +if [ "$channel" = latest ] && [ -z "${INPUT_RELEASE_NOTES//[[:space:]]/}" ]; then + die "Stable releases need release notes; generate them with ./script/release.ts --print" +fi + if [ "$channel" = latest ]; then actor=${GITHUB_TRIGGERING_ACTOR:-${GITHUB_ACTOR:-}} [ -n "$actor" ] || die "Stable releases can only be started by releasers; the triggering actor is unknown" diff --git a/.github/release/publish-discord.sh b/.github/release/publish-discord.sh new file mode 100755 index 000000000000..790428ab2ff8 --- /dev/null +++ b/.github/release/publish-discord.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# publish-discord.sh: announces the v release in Discord with OPENCODE_RELEASE_NOTES (DISCORD_WEBHOOK). +# Discord caps an embed description at 4096 characters, so longer notes are cut and link to the GitHub release. +. "$(dirname "$0")/lib.sh" + +tag="v$OPENCODE_VERSION" +payload=$(jq -n --arg tag "$tag" --arg notes "${OPENCODE_RELEASE_NOTES:-}" \ + --arg url "https://github.com/$GITHUB_REPOSITORY/releases/tag/$tag" ' + ($notes | if length > 4000 then .[:4000] + "\n\n[Full release notes](\($url))" else . end) as $description | + { + allowed_mentions: {parse: []}, + embeds: [{title: "OpenCode V2 \($tag)", url: $url, description: $description, color: 2829099}] + }') +echo "$payload" | jq . +if dry_run; then exit 0; fi + +test -n "${DISCORD_WEBHOOK:-}" || die "DISCORD_WEBHOOK is not set" +curl -fsS --retry 3 -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK" diff --git a/.github/release/tag-release.sh b/.github/release/tag-release.sh index 1ef6fa2a8977..1379bdb00776 100755 --- a/.github/release/tag-release.sh +++ b/.github/release/tag-release.sh @@ -7,7 +7,8 @@ . "$(dirname "$0")/lib.sh" tag="v$OPENCODE_VERSION" -git ls-files -z -- '*package.json' bun.lock | xargs -0 sed -i -E "s/\"version\": \"[^\"]+\"/\"version\": \"$OPENCODE_VERSION\"/g" +# @opencode/pty is versioned separately, by process-daemon-v tags. +git ls-files -z -- '*package.json' bun.lock ':!packages/process-daemon/**' | xargs -0 sed -i -E "s/\"version\": \"[^\"]+\"/\"version\": \"$OPENCODE_VERSION\"/g" if [ -n "${OPENCODE_RELEASE_NOTES:-}" ] && ! grep -qF -x "## $tag" CHANGELOG.md && ! grep -qF "## $tag " CHANGELOG.md; then notes=$(mktemp) diff --git a/.gitignore b/.gitignore index 477fdab9d85f..1b2296b95730 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ target # Local dev files opencode-dev -UPCOMING_CHANGELOG.md RELEASE_REVIEW.md logs/ *.bun-build diff --git a/.gitleaks.toml b/.gitleaks.toml index e6c00a4866cd..d7af245208db 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -12,4 +12,7 @@ paths = [ '''^packages/http\-recorder/test/http\.test\.ts$''', '''^packages/http\-recorder/test/redaction\.test\.ts$''', '''^packages/session\-ui/src/components/tool\-count\-summary\.tsx$''', + # Upstream v2.2.3 Claude Agent HTTP fixtures: generic-api-key matches + # api_message_id + literal REDACTED (not credentials). + '''^packages/core/test/plugin/provider/claude\-agent/fixtures/recordings/.*\.jsonl$''', ] diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md deleted file mode 100644 index b28d963d005a..000000000000 --- a/.opencode/command/changelog.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -model: opencode/gpt-5.4 ---- - -Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. -If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely. -Do not preserve, merge, or reuse text from the existing file. - -The input already contains the exact commit range since the last non-draft release. -The commits are already filtered to the release-relevant packages and grouped into -the release sections. Do not fetch GitHub releases, PRs, or build your own commit list. -The input may also include a `## Community Contributors Input` section. - -Before writing any entry you keep, inspect the real diff with -`git show --stat --format='' ` or `git show --format='' ` so you can -understand the actual code changes and not just the commit message (they may be misleading). -Do not use `git log` or author metadata when deciding attribution. - -Rules: - -- Write the final file with release sections in this order: - `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` -- Only include sections that have at least one notable entry -- Within each release section, keep bug fixes grouped under `### Bugfixes` -- Keep other notable entries under `### Improvements` when a section has bug fixes too -- Omit empty subsections -- Keep one bullet per commit you keep -- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing -- Start each bullet with a capital letter -- Prefer what changed for users over what code changed internally -- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)` -- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input -- If an input bullet has no `(@username)` suffix, do not add one -- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses -- If no notable entries remain and there is no contributor block, write exactly `No notable changes.` -- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block -- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim -- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block -- Do not derive the thank-you section from the main summary bullets -- Do not include the heading `## Community Contributors Input` in the final file -- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise - -**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail** - - - -!`bun script/raw-changelog.ts $ARGUMENTS` - - diff --git a/CHANGELOG.md b/CHANGELOG.md index bbadebf2b6d9..62b870f15e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,178 @@ Release notes for OpenCode V2, newest first. +## v2.2.4 — 2026-10-30 + +### Highlights + +- **Persistent Desktop Terminals**: Desktop terminals now belong to the session, survive server restarts with their scrollback, and stay synchronized with the TUI and other windows. Renamed terminals also keep their names across restarts. ([#60346](https://github.com/anomalyco/opencode/pull/60346)) +- **Publish Interactive Artifacts**: Enable `"experimental": { "artifacts": true }` in `opencode.json` to let agents publish, update, and serve HTML or static artifacts through the built-in local provider or plugin-provided storage. ([#60351](https://github.com/anomalyco/opencode/pull/60351)) +- **Guided Claude Agent Setup**: Select **Claude Agent** from the provider picker to copy `claude auth login`, install Claude Code, and verify the connection before continuing to model selection. ([#60603](https://github.com/anomalyco/opencode/pull/60603)) +- **Choose a Base Branch Quickly**: While creating a worktree, open **Choose base branch** with `⌘⌥.` on macOS or `Ctrl+Alt+.` on Windows and Linux. The command is also available in the command palette and Settings › Shortcuts. ([#60579](https://github.com/anomalyco/opencode/pull/60579)) + +### Core + +#### Improvements + +- Added opt-in artifacts with `artifact_create`, `artifact_read`, `artifact_update`, and `artifact_delete`, including a built-in local static-file provider. ([#60351](https://github.com/anomalyco/opencode/pull/60351)) +- Made enabled schedules run under `opencode serve` and coordinated multiple server processes so a schedule fires only once. Schedules remain opt-in through `experimental.schedules`. ([#60478](https://github.com/anomalyco/opencode/pull/60478)) +- Raised OpenCode Go limits for DeepSeek V4.1 Flash. ([#60625](https://github.com/anomalyco/opencode/pull/60625)) + +#### Bug fixes + +- Allowed user, project, and remote configuration to override Console-supplied provider names, limits, variants, settings, and headers. ([#60597](https://github.com/anomalyco/opencode/issues/60597)) +- Kept local OpenCode service requests out of environment-configured proxies. ([#60441](https://github.com/anomalyco/opencode/pull/60441)) +- Replaced Claude Code’s misleading `/login` instruction with `claude auth login`. Thanks [@Tarquinen](https://github.com/Tarquinen). ([#60592](https://github.com/anomalyco/opencode/pull/60592)) + +### TUI + +#### Bug fixes + +- Preserved prompt cursor styles applied by plugins when themes or unrelated prompt state change. ([#60599](https://github.com/anomalyco/opencode/pull/60599)) +- Added missing right padding to edit and patch diffs. ([#60614](https://github.com/anomalyco/opencode/pull/60614)) +- Extended the vertical session-tab activity pulse across the full tab width. ([#60338](https://github.com/anomalyco/opencode/pull/60338)) + +### Desktop + +#### Improvements + +- Moved terminals to persistent session PTYs shared with the TUI and other windows, with restored scrollback, server-side renaming, and restart persistence. ([#60346](https://github.com/anomalyco/opencode/pull/60346)) +- Added guided Claude Agent installation, sign-in, connection checking, and recovery from **Needs sign-in**. ([#60603](https://github.com/anomalyco/opencode/pull/60603)) +- Added **Choose base branch** to the command palette and shortcuts, bound to `⌘⌥.` or `Ctrl+Alt+.`. ([#60579](https://github.com/anomalyco/opencode/pull/60579)) +- Simplified the session location popover and made long paths easier to read. ([#60492](https://github.com/anomalyco/opencode/pull/60492)) + +#### Bug fixes + +- Kept terminal tab clicks and rename fields attached to the intended tab. ([#60645](https://github.com/anomalyco/opencode/pull/60645)) +- Listed matching commands before sessions in command-palette search. ([#60616](https://github.com/anomalyco/opencode/pull/60616)) +- Restored copying user messages from an open actions menu. ([#60576](https://github.com/anomalyco/opencode/pull/60576)) +- Reduced repeated work and allocations while streaming Markdown, especially in long sessions. ([#60549](https://github.com/anomalyco/opencode/pull/60549), [#60562](https://github.com/anomalyco/opencode/pull/60562), [#60577](https://github.com/anomalyco/opencode/pull/60577)) + +### SDK + +#### Improvements + +- Added `client.experimental.artifacts.providers`, `list`, `create`, `get`, `update`, `delete`, and `view`. ([#60351](https://github.com/anomalyco/opencode/pull/60351)) +- Extended `client.experimental.persistentPty.update` to rename terminals or clear a user title, and exposed `userTitle` plus `persistent-pty.updated` events. ([#60329](https://github.com/anomalyco/opencode/pull/60329)) + +### Extensions + +#### Improvements + +- Added `ctx.experimental.artifact.transform(...)` to Effect and Promise plugins for registering artifact providers, capabilities, prompts, and the default provider. ([#60351](https://github.com/anomalyco/opencode/pull/60351)) + +## v2.2.3 — 2026-10-30 + +### Highlights + +- **Use Claude Code in OpenCode**: Install Claude Code, run `claude auth login`, then select **Claude Agent** in `/connect` to use your Claude Code login and models. ([#58782](https://github.com/anomalyco/opencode/pull/58782)) +- **Faster Long Sessions in Desktop**: Streaming replies and long conversations now do much less rendering work. ([#60489](https://github.com/anomalyco/opencode/pull/60489), [#60521](https://github.com/anomalyco/opencode/pull/60521), [#60513](https://github.com/anomalyco/opencode/pull/60513), [#60481](https://github.com/anomalyco/opencode/pull/60481)) +- **Desktop Stability Fixes**: Fixes side-panel tabs, repeated "Session not found" toasts, and **Open file** over HTTP. ([#60430](https://github.com/anomalyco/opencode/pull/60430), [#60436](https://github.com/anomalyco/opencode/pull/60436), [#60459](https://github.com/anomalyco/opencode/pull/60459), [#60462](https://github.com/anomalyco/opencode/pull/60462)) + +### Breaking changes + +#### `@opencode/ai` experimental media and one `tool()` + +These changes affect code that imports `@opencode/ai` directly. They don't change the CLI, Desktop, or the HTTP API. + +- Speech, transcription, video, and their clients and types moved from `@opencode/ai/effect` to `@opencode/ai/experimental`. On the Promise client, use `ai.experimental.speech`, `ai.experimental.transcription`, and `ai.experimental.video`. `TestSpeech`, `TestTranscription`, and `TestVideo` moved to `@opencode/ai/experimental/testing`. ([#60484](https://github.com/anomalyco/opencode/pull/60484)) +- Both clients build tools with `tool(...)`, which replaces `AI.tool` and `Tool.make`, and `testing.tool` is now `testing.toolCall`. ([#60407](https://github.com/anomalyco/opencode/pull/60407)) + +#### TUI plugins require OpenTUI 0.8 + +`@opencode/plugin` now requires `@opentui/core` and `@opentui/solid` 0.8.0 or later. In OpenTUI 0.8, a ScrollBox's `wrapper`, `viewport`, `content`, and scrollbars are facade objects instead of renderables, and a custom `RenderContext` must implement `clock` and `onAnimationTick`. See the [OpenTUI 0.8.0 release notes](https://opentui.com/docs/releases/0.8.0). ([#60385](https://github.com/anomalyco/opencode/pull/60385)) + +#### Managed policy: `integration.use` covers integrations only + +`integration.use` now controls which integration accounts can be used, and the new `mcp.use`, `skill.use`, and `plugin.use` actions control MCP servers, skills, and plugins. Rules with the old `mcp:`, `skill:`, and `plugin:` resource prefixes are converted automatically, but a bare `integration.use` rule no longer applies to MCP servers, skills, or plugins. ([#60315](https://github.com/anomalyco/opencode/pull/60315)) + +### Core + +#### Improvements + +- Added the built-in **Claude Agent** provider. Install Claude Code, run `claude auth login`, and select **Claude Agent** in `/connect`; `OPENCODE_CLAUDE_PATH` can override the Claude executable. Thanks [@Tarquinen](https://github.com/Tarquinen). ([#58782](https://github.com/anomalyco/opencode/pull/58782)) +- Added Code Mode JavaScript checks to experimental schedules, including statically authorized tool calls, persistent state, and custom wake messages. ([#60140](https://github.com/anomalyco/opencode/pull/60140)) +- Added separate managed-policy actions for connecting to integrations and using integrations, MCP servers, skills, and plugins. ([#60315](https://github.com/anomalyco/opencode/pull/60315)) + +#### Bug fixes + +- Preserved plugin-provided model variants and delayed dynamic provider fallback until external SDK hooks have run. ([#60097](https://github.com/anomalyco/opencode/pull/60097), [#60380](https://github.com/anomalyco/opencode/pull/60380)) +- Preserved permission reasons and feedback in MCP failures, and made Code Mode failures match direct tool-call failures. ([#60102](https://github.com/anomalyco/opencode/pull/60102), [#60109](https://github.com/anomalyco/opencode/pull/60109)) +- Fixed invalid Code Mode schedule checks so creation fails immediately and existing invalid checks pause without repeated execution. ([#60349](https://github.com/anomalyco/opencode/pull/60349)) +- Removed a session’s persistent terminals when the session is deleted. ([#60322](https://github.com/anomalyco/opencode/pull/60322)) +- Kept nested tool progress from replacing the parent `execute` call rows. ([#60480](https://github.com/anomalyco/opencode/pull/60480)) + +### TUI + +#### Improvements + +- Added lazy loading for stored image references when `experimental.media_blob` is enabled. Images are downloaded only for previews, while undo, revert, fork, and prompt recall restore referenced attachments safely. ([#60116](https://github.com/anomalyco/opencode/pull/60116)) +- Added a distinct marker for the selected composer suggestion in the new designs. ([#60393](https://github.com/anomalyco/opencode/pull/60393)) + +#### Bug fixes + +- Fixed PHP syntax highlighting for code blocks without an opening `=0.8.0", + "@opentui/solid": ">=0.8.0", "electron": "*", "solid-js": ">=1.9.0", "vite": ">=6", @@ -593,7 +593,7 @@ }, "packages/plugin-browser": { "name": "@opencode/plugin-browser", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@opencode/plugin": "workspace:*", "@opencode/schema": "workspace:*", @@ -608,7 +608,7 @@ }, "packages/plugins/figma": { "name": "@opencode/plugin-figma", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "gray-matter": "4.0.3", }, @@ -638,7 +638,7 @@ }, "packages/protocol": { "name": "@opencode/protocol", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@opencode/schema": "workspace:*", "effect": "catalog:", @@ -668,7 +668,7 @@ }, "packages/schema": { "name": "@opencode/schema", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@standard-schema/spec": "catalog:", "effect": "catalog:", @@ -693,7 +693,7 @@ }, "packages/sdk": { "name": "@opencode/sdk", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -714,7 +714,7 @@ }, "packages/server": { "name": "@opencode/server", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", @@ -728,7 +728,7 @@ "devDependencies": { "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", - "@opencode/pty": "0.3.3", + "@opencode/pty": "0.4.0", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native": "catalog:", @@ -737,7 +737,7 @@ }, "packages/session-ui": { "name": "@opencode/session-ui", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -769,7 +769,7 @@ }, "packages/simulation": { "name": "@opencode/simulation", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@opencode/ai": "workspace:*", "@opencode/core": "workspace:*", @@ -789,7 +789,7 @@ }, "packages/stats/app": { "name": "@opencode/stats-app", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -801,10 +801,10 @@ "d3-scale": "4.0.2", "effect": "catalog:", "i18n-iso-countries": "7.14.0", - "nitro": "3.0.1-alpha.1", + "nitro": "3.0.260903-beta", "solid-js": "catalog:", "sst": "catalog:", - "vite": "catalog:", + "vite": "8.2.4", }, "devDependencies": { "@types/bun": "catalog:", @@ -815,7 +815,7 @@ }, "packages/stats/core": { "name": "@opencode/stats-core", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", @@ -833,7 +833,7 @@ }, "packages/stats/server": { "name": "@opencode/stats-server", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@effect/platform-node": "catalog:", "@opencode/stats-core": "workspace:*", @@ -868,7 +868,7 @@ }, "packages/theme": { "name": "@opencode/theme", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@opentui/core": "catalog:", "effect": "catalog:", @@ -882,7 +882,7 @@ }, "packages/tui": { "name": "@opencode/tui", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -918,7 +918,7 @@ }, "packages/ui": { "name": "@opencode/ui", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -953,7 +953,7 @@ }, "packages/util": { "name": "@opencode/util", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -992,7 +992,7 @@ }, "packages/web": { "name": "@opencode/web", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "@astrojs/cloudflare": "14.1.4", "@astrojs/markdown-remark": "7.3.2", @@ -1044,7 +1044,7 @@ }, "services/update": { "name": "@opencode/update", - "version": "2.2.2", + "version": "2.2.4", "dependencies": { "jose": "6.0.11", "semver": "catalog:", @@ -1101,9 +1101,9 @@ "overrides": { "@astrojs/starlight": "catalog:", "@effect/platform-node-shared": "catalog:", - "@opentui/core": "0.7.0", - "@opentui/keymap": "0.7.0", - "@opentui/solid": "0.7.0", + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", "astro": "catalog:", @@ -1124,9 +1124,9 @@ "@lydell/node-pty": "1.2.0-beta.12", "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", - "@opentui/core": "0.7.0", - "@opentui/keymap": "0.7.0", - "@opentui/solid": "0.7.0", + "@opentui/core": "0.8.0", + "@opentui/keymap": "0.8.0", + "@opentui/solid": "0.8.0", "@pierre/diffs": "1.6.1", "@pierre/highlights": "0.2.0", "@playwright/test": "1.60.0", @@ -1138,7 +1138,7 @@ "@solid-primitives/storage": "4.4.0", "@solidjs/meta": "0.29.4", "@solidjs/router": "1.0.0", - "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@solidjs/start": "2.1.0", "@standard-schema/spec": "1.1.0", "@tailwindcss/vite": "4.1.11", "@tanstack/solid-virtual": "3.13.37", @@ -1190,7 +1190,25 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.301", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.301", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.301", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.301", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.301" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-mAe4zIFRL9BejcFHQVc+D0gxrMR/7mDt5GgdekfWB2DNWZebHoeGhgP2R3rZ/BcEZnW10nSGV2IwWyywwZw83Q=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.301", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hva/QZr94pm9mN6Trz59XGLgRPn8zLPrghRK8/MsvLCTrCzD1CNd9Pe1pUV1woTX29qPOGP3xRU3Mgm2LhIL7A=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.301", "", { "os": "darwin", "cpu": "x64" }, "sha512-k6jNdu9eEhkPLTPTwHTn3rbIpKHm0mcndkS4wAZtQehCIFc69tmZZ4yeEtQ4eZ2jR0+6ttLtRy+5Q82Q6Ohm0Q=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.301", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-Uqjy+YD6ImvelrFs7WHRMboT3BqknGLrDSdiejDqzFk62TZZOqIH9OFHyiyvHexoO31CpDEcn+d7LB+SgpNurA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.301", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-C1+q1cYZY2bYxDYCfHHaq9ToGMTO/6FdbB0JShyPCyAt0lrRC+myNgsvPq/0ZsrtPWbcbrpX6QgbsFn2QzEWcw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.301", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-V0zrxg59pBSp6PXPD70m/nRwUApKM/q5SJdeHq2ZAn7mcAr/GIBRoBEAIrA2kOAK9EkIyjKNKkrwuOVeyId/lA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.301", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-qmHolcb+CgzYc4rfZrJd5iEIzOPRPz2edJBH+8V4W4R+bswsNaqf59NBkrDt3GdfmGr42touHYJ/4dV+g/b5eQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.301", "", { "os": "win32", "cpu": "arm64" }, "sha512-V/iwQ3/wcxeQaycw2Daym9CzxssjYXIWtQJEFoU+Zrck3++2lD24KxFK0pwB3f258/uwdKoAM0x5p7+FEMRzwQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.301", "", { "os": "win32", "cpu": "x64" }, "sha512-FJe1o2nt+K7mQ7KG5VdEoCEo3HtUmEeITCn6ZswKGEHmgmZDogDnExQ649+IYuRXpW5dAQlYqdWaEA6cvtmoXA=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@1.3.0", "", { "dependencies": { "@opentelemetry/api": "^1.4.1", "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw=="], "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], @@ -1228,13 +1246,13 @@ "@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w=="], - "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg=="], + "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.5.2", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg=="], - "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ=="], + "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ=="], - "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw=="], + "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.5.2", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw=="], - "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw=="], + "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw=="], "@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.5.2", "", { "dependencies": { "@emnapi/core": "1.11.3", "@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "^1.2.5" }, "cpu": "none" }, "sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw=="], @@ -1524,13 +1542,13 @@ "@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="], - "@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA=="], + "@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.9.5", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA=="], - "@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ=="], + "@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.9.5", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ=="], - "@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw=="], + "@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.9.5", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw=="], - "@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q=="], + "@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.9.5", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q=="], "@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.9.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ=="], @@ -1626,11 +1644,11 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -1698,11 +1716,11 @@ "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-pwdFJUB/oeO4S/SfmRDdE5ipbAMYoVtRMwTZ12Wn2H46PgNU5DWnSjHhUNTLuKmkQU/ZIJL5Sn9j/fzwuIzcoA=="], - "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-prbY72gR+VPoSxl1InglwSwVHM0kErYMjUOpdvjQhS8eRdXYL3hTuYQv9L7fiB0mqaDzcwFa339uMr3p8ujDrw=="], + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-prbY72gR+VPoSxl1InglwSwVHM0kErYMjUOpdvjQhS8eRdXYL3hTuYQv9L7fiB0mqaDzcwFa339uMr3p8ujDrw=="], "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQN/CEKn4c4toooGCmZ6N8cP9mhgE4tnR6rr6a4k9JjH0xi05fYdG4+BabPkr+l/mDc01DsRpSKVNBmvoZ3IlQ=="], - "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-8NzFGGyqrFaCtb+SPTFd0xQGUlHCJMf0KYGwuca46IP37nN4OtDT1wnyZ9oaPDZ+2qqyxox10hCnqGlmj3PJzQ=="], + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-8NzFGGyqrFaCtb+SPTFd0xQGUlHCJMf0KYGwuca46IP37nN4OtDT1wnyZ9oaPDZ+2qqyxox10hCnqGlmj3PJzQ=="], "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mioRbn8aAW2Jmm4W0w3poePo65iSE+REfO+Jc0iqsjirImEy6pKt7dswj+7LSU6RPWwPsMb4POTMENwNLMlJww=="], @@ -1740,6 +1758,8 @@ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], + "@hono/node-server": ["@hono/node-server@2.1.5", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q=="], + "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], @@ -1756,37 +1776,37 @@ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm", "libc": "glibc" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm", "libc": "glibc" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], @@ -1852,11 +1872,13 @@ "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.3.1", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.2", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" } }, "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw=="], "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], @@ -1984,23 +2006,23 @@ "@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"], - "@opencode/pty": ["@opencode/pty@0.3.3", "", { "optionalDependencies": { "@opencode/pty-darwin-arm64": "0.3.3", "@opencode/pty-darwin-x64": "0.3.3", "@opencode/pty-linux-arm64-gnu": "0.3.3", "@opencode/pty-linux-arm64-musl": "0.3.3", "@opencode/pty-linux-x64-gnu": "0.3.3", "@opencode/pty-linux-x64-musl": "0.3.3", "@opencode/pty-win32-arm64": "0.3.3", "@opencode/pty-win32-x64": "0.3.3" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-iIOczK9UvusAshTH+HeBvt6Q4Q5rYjfBCLT4Z7dMrIjZ4cdodmveYI5ge/2+qOM3ZKWXf5uSO2K5DhQIP5Gmxw=="], + "@opencode/pty": ["@opencode/pty@0.4.0", "", { "optionalDependencies": { "@opencode/pty-darwin-arm64": "0.4.0", "@opencode/pty-darwin-x64": "0.4.0", "@opencode/pty-linux-arm64-gnu": "0.4.0", "@opencode/pty-linux-arm64-musl": "0.4.0", "@opencode/pty-linux-x64-gnu": "0.4.0", "@opencode/pty-linux-x64-musl": "0.4.0", "@opencode/pty-win32-arm64": "0.4.0", "@opencode/pty-win32-x64": "0.4.0" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-v7cLT71yfoNM5FlyPGwMjeqIJbr0DneGq4NpoOY5iAKpsevzOIcsI2E0fiDWajuk5xAFRxwyv4CpGqow7cu79Q=="], - "@opencode/pty-darwin-arm64": ["@opencode/pty-darwin-arm64@0.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1q9sROkkbGQaG2rxyKT6hW/EN1aDUiST8h6WmQdt1dbgvo3WaYDDjaEWmgwhm7Fhhca+E9WruTfxfVBBDm4Ezg=="], + "@opencode/pty-darwin-arm64": ["@opencode/pty-darwin-arm64@0.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XzZZJcQeR42IRpxHbbPEo4AK7jdp9SpgPx234DZ6uLnl6yclXcixH70G2U88EhKtGAMyCPIXdBfvQf4OQTDvbQ=="], - "@opencode/pty-darwin-x64": ["@opencode/pty-darwin-x64@0.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-dfphCXfoaXGAa1i2P52U7QelcJoANTWimCvuy8xPNPlazrb7ANP+flzQxcy7f6rP/hnbzGLYUMxU0CQocMjwqA=="], + "@opencode/pty-darwin-x64": ["@opencode/pty-darwin-x64@0.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-NvQfygGcsGmDCIUJ/VHLcS6SduC93Y/qP2hrHjtBpiL2sSQyeCDcifNhLvqY/wxGFNNZ4GeZwwZqe7rMEuBJbQ=="], - "@opencode/pty-linux-arm64-gnu": ["@opencode/pty-linux-arm64-gnu@0.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-MyO+3wIkv6hveEY6Jyd7jgk9PRp1J4y+u0Y5FfUg1SPFUhrFTx9yrHXbSDVbAe7mLmMYu8KhRn4+plk8JXZjyg=="], + "@opencode/pty-linux-arm64-gnu": ["@opencode/pty-linux-arm64-gnu@0.4.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-VZXkYD+CT+zdodmTnKn9/VlmswksLPBLJryeBsG4IXUFXTsATr7+PVygJk96FxCh5nq68H3a/UJgyiIUMl1XCQ=="], - "@opencode/pty-linux-arm64-musl": ["@opencode/pty-linux-arm64-musl@0.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-L0+x0xald7PKwYcckE8NOFef/FpGQ2k13iunGlHJFIN1gxDpI1XtZQEjASIe4w3vq1nB49L7SnbghzMxUKNtOg=="], + "@opencode/pty-linux-arm64-musl": ["@opencode/pty-linux-arm64-musl@0.4.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-hCx25Hn9sALdnkx2Y4aPGVw3glTbX61XRiJs+XETPPIO9QTaav4OS4k2JR2uHC6/u161xKdN82iJ+OYfwcGUSg=="], - "@opencode/pty-linux-x64-gnu": ["@opencode/pty-linux-x64-gnu@0.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-j1nPB/8nwrJm20s5Gz9Q/8qPQyYowkQNXeLUaz2bilvB5L1Wl8OxOSk39WpcEZYp4gkBNsuguNm1zSZ+++RsKQ=="], + "@opencode/pty-linux-x64-gnu": ["@opencode/pty-linux-x64-gnu@0.4.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-Xal/d0+xp6XE4iCGUAw4JTQ4uf5l8V0EIbeKq+e03cHJewOMKZk4fooQjxpZvQIToBHRtnCJAmd+EEhVxXiDDw=="], - "@opencode/pty-linux-x64-musl": ["@opencode/pty-linux-x64-musl@0.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dNPmBprzTiM0u18YBsvC4hXRwi2/SGDbvaZvyfr9VooaC/49YIkKbz9hOVzIry8Q8uaWvXagCiYSQwIdPIfSeA=="], + "@opencode/pty-linux-x64-musl": ["@opencode/pty-linux-x64-musl@0.4.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-dBVtKZoVKAN6/E+65GrJksgRKzPkPHUjWZBzf3mRANXt2adpdbV+/M1AMfPCZSju2mN1j9rNu8MCrxwr+ugEig=="], - "@opencode/pty-win32-arm64": ["@opencode/pty-win32-arm64@0.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1v2e97XLZIZein/E7WGyfHOcB1S71pgKp7yq6yCe3OtbDtm5WFnp9mQB+jU2ns1d4ATTp2bJauh7rHFsqaJgeA=="], + "@opencode/pty-win32-arm64": ["@opencode/pty-win32-arm64@0.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-IKJMUnS2aluWqRj13enMWomYtwbXq8rStcf/mUbxBoAGTDMtWR/ZrRmKb2ptR7KUqDjdSEa+8C8rczS7uGFl9g=="], - "@opencode/pty-win32-x64": ["@opencode/pty-win32-x64@0.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-V7B4i/2gQxqHNBVt9q1EpJhIHX+UgPJxle6+XzAkUgwOo/75ncUUbn0hsqBy/tqVMhDSA4n5BHQ32U+X7aYI0w=="], + "@opencode/pty-win32-x64": ["@opencode/pty-win32-x64@0.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-P1QJLUX918/y8he8VEBSYoiN5bYhUnJtEaEpiE5fxoYsDP9nqAHXqIrGxTNNnda5jOBcCalxxy0WhL8qgeKOqQ=="], "@opencode/schema": ["@opencode/schema@workspace:packages/schema"], @@ -2066,27 +2088,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@opentui/core": ["@opentui/core@0.7.0", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.7.0", "@opentui/core-darwin-x64": "0.7.0", "@opentui/core-linux-arm64": "0.7.0", "@opentui/core-linux-arm64-musl": "0.7.0", "@opentui/core-linux-x64": "0.7.0", "@opentui/core-linux-x64-musl": "0.7.0", "@opentui/core-win32-arm64": "0.7.0", "@opentui/core-win32-x64": "0.7.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2iWBw9oo+ZJfPbOWeTLXfDV63yTDRfclxltI4IgTa6n7OB90x5xplLMH3y9DXhnWibzTui00GbK3ZCwKfUU1SQ=="], + "@opentui/core": ["@opentui/core@0.8.0", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.8.0", "@opentui/core-darwin-x64": "0.8.0", "@opentui/core-linux-arm64": "0.8.0", "@opentui/core-linux-arm64-musl": "0.8.0", "@opentui/core-linux-x64": "0.8.0", "@opentui/core-linux-x64-musl": "0.8.0", "@opentui/core-win32-arm64": "0.8.0", "@opentui/core-win32-x64": "0.8.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-LwnIT7k+rGelridv7390enJXZpNuQYKIS3NdttJyoLPjbRFPyHnAKkCHk6X8MRS64vcSjOeVq9sl7XLWII16jQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.7.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-meEyZVUeCIauqHLWo/IsSlKzZPTnGIEjRLigefSZbrSAcOiMryd3OcUWvSByIXpYb9UPOpW0YxKKfVwepb9u2A=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.8.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Cp+BL1DsRZhUG56AuyXlH8DA4dsKfZ3Qgh1XMYDGokkC4p9uv8kdNNhyzmURAFa2LK77XqVv1Zhwr/6DqqpMlw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.7.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZQ4OfAua5CtUv3hpZ81zhNTTLULbg0leQ/3eupjefityGP6163eS56IzymTWuiM/mVD/7Ad5bicH0I2JXhr1g=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.8.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jp4pzdIuKsS3XYq9xuWuAO52tTj9gnWeeoI3bg456CF6XnY2yQMasWOd/WmjfvYfAaJzGDFa4DNXhByBMPVuTw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.7.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/u+Kalzkz8NntUW5zjypgt9fnzeKohLG9YMEas7n98KvyaYScrtO4Mdj5iv/STNB3IfocJOChzYAbO6C8TmlPQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-b6D/VP9y0Z18tO86GSTp9BBykgR6r5CQM1ol9UMJfUt+PjN5HGksbOCDTMIQufXvt4WIUFHrSBProysJRFzbYw=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.7.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-BkTn+WLf9OPR4KhBnuOki+R/cgk2qwbL49eQn5fEYPeiJD4HPalGbEZDea59UjwNEJ7rqKq2yuPAXQjkDZ6y3w=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.8.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-8ZPl/2DChcS7XIVRPIazigXaMNyxa2bwvuzPprNbzT0+cVJLIoGC19pjiV3bI3oVXBPVMNqgIcppOrEHBhlouw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.7.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OD2tGl1r27zcgCsuc+TYU5e+qhrbsRSI0YUBEb0ebQo8P6Ps2FL9LFenH9om4fddCh06G9Zd45ekJMhlDrGD3Q=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-aXBNIdE6iMf58/Tdnd6UzXeFjYfaHzQ6E0nukjLe2ir41/meJJYEAHUQ62EBr/gxvuCNZHTBQB4T9BzF7PdENQ=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.7.0", "", { "os": "linux", "cpu": "x64" }, "sha512-HrJbcAIuayzQmWVzv+cjez37fNHL9FDoHmjwxAb4iK5GTjl89FZCxeKopyua14RA6WpP8/zUJQi8U16mm6XptA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.8.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-xGEYw6wkX0sPwPx6RosQZanuWF0+5cIup2alhHHQQfKJkcLS8r8NBWlcy2LK9CoEWH6SBtNXC+9Y5IrryCOvJQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.7.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8yqBoRsDbvKl2kGX0O8t5kpnVEnbzs+2qUZlJ8sbeA3Z+73WFj36G+clUIMVxpspOu78iKXlD84NIwTxjZUPKw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.8.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XtK0QMyIiqeDmrIyyAXqqp7TKaRGaWnBlbnEsNrkRLrgP+aLwRhRSHdafxi+RMqj3h9qOlb1MgnPal1kx7wP4g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OfNAX7fNFHhOiiojUkIjhVOZf+IDjpbpT5G7HrxGqhlZ+n4jITrrJzACHctteWbMUYcibieJzIIoKuLdmD5I+w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.8.0", "", { "os": "win32", "cpu": "x64" }, "sha512-P5X2RcDDntIJ4q1WvQawGWCae5BzHrm50PMy7+PZ3Su8GGwnaCQ5xRHvDIQfhki8pZMo2SoTOkpStR0Wj1QoXQ=="], - "@opentui/keymap": ["@opentui/keymap@0.7.0", "", { "dependencies": { "@opentui/core": "0.7.0" }, "peerDependencies": { "@opentui/react": "0.7.0", "@opentui/solid": "0.7.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-2IqUnLP+SCZR7+LXRL/7/DGFyaAR6r9y16H5oQVdRTfbACxAjszOaq2iZDEzHp1HVtamQRqEfteuaLEDwmO3kg=="], + "@opentui/keymap": ["@opentui/keymap@0.8.0", "", { "dependencies": { "@opentui/core": "0.8.0" }, "peerDependencies": { "@opentui/react": "0.8.0", "@opentui/solid": "0.8.0", "react": ">=19.2.0", "solid-js": "^1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Kv00d1Bw/9U1ORM5fJyn9PQ9LbZi+Q7m9jO2EhyBeNAienSKb4PAjdcu9di6jxhkqt6jG+FXyndoemED8TzK4A=="], - "@opentui/solid": ["@opentui/solid@0.7.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.7.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-S6SNZGRykV02UjNbM+f/6lSeAPN3dfEbjcK1jAAdFCfa+EVhJqcJVyaJgtwHLU9fI6c/O1VKXPzbbFYj2RhNVQ=="], + "@opentui/solid": ["@opentui/solid@0.8.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.8.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.16", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "^1.9.12" } }, "sha512-fk035Q9iGXFgpkXSbpdyWYnT1jFuY0gOhzxaqBXDVtuWrQPB5MRxUxXHgESIMXJPsMTROIjOcKuW+PPhsakpnw=="], "@opentunnel/client": ["@opentunnel/client@0.6.1", "", { "dependencies": { "@opentunnel/protocol": "0.6.1" }, "peerDependencies": { "effect": "^4.0.0", "undici": ">=7.0.0" }, "optionalPeers": ["undici"] }, "sha512-OuVNlWywQebIXCtkixjVHebOsR3KFRxJ0DSZry6Dzj4hHaHIf3eTVmQ7VwLCjuVmw+ctU2qHIP6RxuAPr07WDw=="], @@ -2094,67 +2116,47 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="], - - "@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="], - - "@oxc-minify/binding-darwin-x64": ["@oxc-minify/binding-darwin-x64@0.96.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-C5vI0WPR+KPIFAD5LMOJk2J8iiT+Nv65vDXmemzXEXouzfEOLYNqnW+u6NSsccpuZHHWAiLyPFkYvKFduveAUQ=="], - - "@oxc-minify/binding-freebsd-x64": ["@oxc-minify/binding-freebsd-x64@0.96.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-3//5DNx+xUjVBMLLk2sl6hfe4fwfENJtjVQUBXjxzwPuv8xgZUqASG4cRG3WqG5Qe8dV6SbCI4EgKQFjO4KCZA=="], - - "@oxc-minify/binding-linux-arm-gnueabihf": ["@oxc-minify/binding-linux-arm-gnueabihf@0.96.0", "", { "os": "linux", "cpu": "arm" }, "sha512-WXChFKV7VdDk1NePDK1J31cpSvxACAVztJ7f7lJVYBTkH+iz5D0lCqPcE7a9eb7nC3xvz4yk7DM6dA9wlUQkQg=="], - - "@oxc-minify/binding-linux-arm-musleabihf": ["@oxc-minify/binding-linux-arm-musleabihf@0.96.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7B18glYMX4Z/YoqgE3VRLs/2YhVLxlxNKSgrtsRpuR8xv58xca+hEhiFwZN1Rn+NSMZ29Z33LWD7iYWnqYFvRA=="], - - "@oxc-minify/binding-linux-arm64-gnu": ["@oxc-minify/binding-linux-arm64-gnu@0.96.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Yl+KcTldsEJNcaYxxonwAXZ2q3gxIzn3kXYQWgKWdaGIpNhOCWqF+KE5WLsldoh5Ro5SHtomvb8GM6cXrIBMog=="], - - "@oxc-minify/binding-linux-arm64-musl": ["@oxc-minify/binding-linux-arm64-musl@0.96.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rNqoFWOWaxwMmUY5fspd/h5HfvgUlA3sv9CUdA2MpnHFiyoJNovR7WU8tGh+Yn0qOAs0SNH0a05gIthHig14IA=="], - - "@oxc-minify/binding-linux-riscv64-gnu": ["@oxc-minify/binding-linux-riscv64-gnu@0.96.0", "", { "os": "linux", "cpu": "none" }, "sha512-3paajIuzGnukHwSI3YBjYVqbd72pZd8NJxaayaNFR0AByIm8rmIT5RqFXbq8j2uhtpmNdZRXiu0em1zOmIScWA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.141.0", "", { "os": "android", "cpu": "arm" }, "sha512-jk7086MFvR/T4DG9IY7MKBVt1PMxvSZoz/TvnifodvS0pjghVwJHRttnAExhlwdMOgHv1TmLdENnbNpYk2zjvA=="], - "@oxc-minify/binding-linux-s390x-gnu": ["@oxc-minify/binding-linux-s390x-gnu@0.96.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-9ESrpkB2XG0lQ89JlsxlZa86iQCOs+jkDZLl6O+u5wb7ynUy21bpJJ1joauCOSYIOUlSy3+LbtJLiqi7oSQt5Q=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.141.0", "", { "os": "android", "cpu": "arm64" }, "sha512-a4XDQ27ZT7e7zwAlxJDTiCA7IBGWDuy2+MhFq85Of7XlBSmpkfcBFml11q0Zx6f7RMuI0B4xCtt2ytBS4yOptg=="], - "@oxc-minify/binding-linux-x64-gnu": ["@oxc-minify/binding-linux-x64-gnu@0.96.0", "", { "os": "linux", "cpu": "x64" }, "sha512-UMM1jkns+p+WwwmdjC5giI3SfR2BCTga18x3C0cAu6vDVf4W37uTZeTtSIGmwatTBbgiq++Te24/DE0oCdm1iQ=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.141.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m/kVk6rzYmBeHYnz+1Y5fod00AVTTxMbC71azFfm/zjx1j9XxwKtA0+VfkKuVMC8rbghb9TtfevnuWZa9OuPEg=="], - "@oxc-minify/binding-linux-x64-musl": ["@oxc-minify/binding-linux-x64-musl@0.96.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8b1naiC7MdP7xeMi7cQ5tb9W1rZAP9Qz/jBRqp1Y5EOZ1yhSGnf1QWuZ/0pCc+XiB9vEHXEY3Aki/H+86m2eOg=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.141.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-o0X+6KZlfucWU/v5oKRQPwdFXsXAjW8jmpo/Gpw/qyKsbKtlfkHoeH9Bjp/m13TwjewvJnCkwF0DWzgpC4HjTQ=="], - "@oxc-minify/binding-wasm32-wasi": ["@oxc-minify/binding-wasm32-wasi@0.96.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, "cpu": "none" }, "sha512-bjGDjkGzo3GWU9Vg2qiFUrfoo5QxojPNV/2RHTlbIB5FWkkV4ExVjsfyqihFiAuj0NXIZqd2SAiEq9htVd3RFw=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.141.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-W5KbTnNkTMMMylqj6dYqnsXvkmESVPodPKYLJ5zdzIPdl9fUJtolkpUeSzYEbGGYB4a4A4avl3EePnZ/wLIdJg=="], - "@oxc-minify/binding-win32-arm64-msvc": ["@oxc-minify/binding-win32-arm64-msvc@0.96.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-4L4DlHUT47qMWQuTyUghpncR3NZHWtxvd0G1KgSjVgXf+cXzFdWQCWZZtCU0yrmOoVCNUf4S04IFCJyAe+Ie7A=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.141.0", "", { "os": "linux", "cpu": "arm" }, "sha512-g3dtbJa8zeOGK36Sr9cQavsdi5H/ie2hVjrSjIxsNAR1qZA40ZYVXnfdfoMAlq8CmB9qFL1yhsSCUHeNmdmt8w=="], - "@oxc-minify/binding-win32-x64-msvc": ["@oxc-minify/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-T2ijfqZLpV2bgGGocXV4SXTuMoouqN0asYTIm+7jVOLvT5XgDogf3ZvCmiEnSWmxl21+r5wHcs8voU2iUROXAg=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.141.0", "", { "os": "linux", "cpu": "arm" }, "sha512-e6hwQqd+3lvP13G2jxvFpoA7dzHcFLN+Mq47JCVMtdNHbbyBRo756JCtbbJH6ca8inTfyqZoqBmS3vhQlzAK2w=="], - "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], - - "@oxc-transform/binding-android-arm64": ["@oxc-transform/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-wOm+ZsqFvyZ7B9RefUMsj0zcXw77Z2pXA51nbSQyPXqr+g0/pDGxriZWP8Sdpz/e4AEaKPA9DvrwyOZxu7GRDQ=="], - - "@oxc-transform/binding-darwin-arm64": ["@oxc-transform/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-td1sbcvzsyuoNRiNdIRodPXRtFFwxzPpC/6/yIUtRRhKn30XQcizxupIvQQVpJWWchxkphbBDh6UN+u+2CJ8Zw=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.141.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-vXz2BLAuypA+4MLyBg94pzEo6THVnzYnCtAjXoihIIQo0t2pnp/AmW+SH1EI+4VbuJnC//KplIJ5yyaCGua4jA=="], - "@oxc-transform/binding-darwin-x64": ["@oxc-transform/binding-darwin-x64@0.96.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgqxnqhPYH2NYkgbqtnCJfhbXvxIf/pnhF/ig5UBK8PYpCEWIP/cfLpQRQ9DcQnRfuxi7RMIF6LdmB1AiS6Fkg=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.141.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-jMkS/EztNW34HKsXIaT/SoHcmtocq/vWhwFOVduF9kduuuRIVwfwQ6uxzIO+qPKSXdd2TXt54of0BJ2zFMXnmw=="], - "@oxc-transform/binding-freebsd-x64": ["@oxc-transform/binding-freebsd-x64@0.96.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1i67OXdl/rvSkcTXqDlh6qGRXYseEmf0rl/R+/i88scZ/o3A+FzlX56sThuaPzSSv9eVgesnoYUjIBJELFc1oA=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.141.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-vo+MR+n3zQJ6Mq92hiP084NZcgDv5iJlVR02gMf28neMvVT1tKVm7VeiW/DxhdqOi3QLeaXIk9cUcLL1qrkngw=="], - "@oxc-transform/binding-linux-arm-gnueabihf": ["@oxc-transform/binding-linux-arm-gnueabihf@0.96.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9MJBs0SWODsqyzO3eAnacXgJ/sZu1xqinjEwBzkcZ3tQI8nKhMADOzu2NzbVWDWujeoC8DESXaO08tujvUru+Q=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.141.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-oh80w+7RuiO5gBp9Jnoa/H8Qlt3JsHL2MkW+0dwEdlDMdslVZX/YsekSK6EeyEenY66/mhCfypsNATQ7Ph3qlQ=="], - "@oxc-transform/binding-linux-arm-musleabihf": ["@oxc-transform/binding-linux-arm-musleabihf@0.96.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BQom57I2ScccixljNYh2Wy+5oVZtF1LXiiUPxSLtDHbsanpEvV/+kzCagQpTjk1BVzSQzOxfEUWjvL7mY53pRQ=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.141.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-LOyEmFA8sCnYbEXP1+iQvCC/P1YXHMA/t6x1Ksp0Y9VwhLFsiBJFzV1zIxrOIE2LKaGGhDjQ29xq9cbq6omDXA=="], - "@oxc-transform/binding-linux-arm64-gnu": ["@oxc-transform/binding-linux-arm64-gnu@0.96.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-kaqvUzNu8LL4aBSXqcqGVLFG13GmJEplRI2+yqzkgAItxoP/LfFMdEIErlTWLGyBwd0OLiNMHrOvkcCQRWadVg=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.141.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-3wnwk/l1CvszVE5TJR1wSl/zSEfydRqrNhn6s7Vr9IzSJpUQIroqVsIoPARHRFA+FQwkxAFDAHDAasa7v8OobQ=="], - "@oxc-transform/binding-linux-arm64-musl": ["@oxc-transform/binding-linux-arm64-musl@0.96.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiG/L3wEkPgTm4p906ufptyblBgtiQWTubGg/JEw82f8uLRroayr5zhbUqx40EgH037a3SfJthIyLZi7XPRFJw=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.141.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-qtyQVAAebFq57B2tifTlel3TgGqUtsYNI/e+p6aya9rN9lOZVTDvr215fGYSA9XWooxzMxDiVxkBLk2jQHbsOQ=="], - "@oxc-transform/binding-linux-riscv64-gnu": ["@oxc-transform/binding-linux-riscv64-gnu@0.96.0", "", { "os": "linux", "cpu": "none" }, "sha512-r01CY6OxKGtVeYnvH4mGmtkQMlLkXdPWWNXwo5o7fE2s/fgZPMpqh8bAuXEhuMXipZRJrjxTk1+ZQ4KCHpMn3Q=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.141.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-SkGV1nKw40roEc94pv5EaaeH2ay14G6+roe8Q0wIUC1LcEKxzKW921h7+ZuZX0D3q2Mb/7aSFmxEVqnko3lPRw=="], - "@oxc-transform/binding-linux-s390x-gnu": ["@oxc-transform/binding-linux-s390x-gnu@0.96.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4djg2vYLGbVeS8YiA2K4RPPpZE4fxTGCX5g/bOMbCYyirDbmBAIop4eOAj8vOA9i1CcWbDtmp+PVJ1dSw7f3IQ=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.141.0", "", { "os": "none", "cpu": "arm64" }, "sha512-cVgDM7n8QziQqOaP5hNgUYfMG7S/ZeuPxFWXnnHRv7rh025COk0rfQ6eEdKG3j/GaUuyvNZN4ifF1J8KmuXLLA=="], - "@oxc-transform/binding-linux-x64-gnu": ["@oxc-transform/binding-linux-x64-gnu@0.96.0", "", { "os": "linux", "cpu": "x64" }, "sha512-f6pcWVz57Y8jXa2OS7cz3aRNuks34Q3j61+3nQ4xTE8H1KbalcEvHNmM92OEddaJ8QLs9YcE0kUC6eDTbY34+A=="], + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.141.0", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-HggH++Fkn3OilBn+bs3jpgIFQa34oMAyUUHy0vpGum+gt1Eb5nyLc8dNU/RAPSw6lsLrx7ncKtHSZE+3Sp0l2g=="], - "@oxc-transform/binding-linux-x64-musl": ["@oxc-transform/binding-linux-x64-musl@0.96.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NSiRtFvR7Pbhv3mWyPMkTK38czIjcnK0+K5STo3CuzZRVbX1TM17zGdHzKBUHZu7v6IQ6/XsQ3ELa1BlEHPGWQ=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.141.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KLSEH9GwgbrqbJOjtGHt9STw96s+78yDzp7IDN8Lno+7Ut9sNBfZ4jYZIz4mD50qmWUjoOI7i9I6UENbhNbMZQ=="], - "@oxc-transform/binding-wasm32-wasi": ["@oxc-transform/binding-wasm32-wasi@0.96.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, "cpu": "none" }, "sha512-A91ARLiuZHGN4hBds9s7bW3czUuLuHLsV+cz44iF9j8e1zX9m2hNGXf/acQRbg/zcFUXmjz5nmk8EkZyob876w=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.141.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-9UVWUOOCI/1YkiSSNjg2zyBJYM9E/t1A/8GNobd48JDn/fQ6mzxcVO3H08jb3rAaW/B1VBf8eCORTvSsO9T08g=="], - "@oxc-transform/binding-win32-arm64-msvc": ["@oxc-transform/binding-win32-arm64-msvc@0.96.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-IedJf40djKgDObomhYjdRAlmSYUEdfqX3A3M9KfUltl9AghTBBLkTzUMA7O09oo71vYf5TEhbFM7+Vn5vqw7AQ=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.141.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HI/wsvbWT5RHHw5c37D0fEgeTd8/1Q4OJs5jUmEBc17VZFG6SsCIe4barq7NsAPPks/JW+3ayi3Rp+PQI5h4Kg=="], - "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0fI0P0W7bSO/GCP/N5dkmtB9vBqCA4ggo1WmXTnxNJVmFFOtcA1vYm1I9jl8fxo+sucW2WnlpnI4fjKdo3JKxA=="], + "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="], @@ -2226,11 +2228,11 @@ "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], - "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], - "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], @@ -2522,10 +2524,12 @@ "@solidjs/router": ["@solidjs/router@1.0.0", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q=="], - "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="], + "@solidjs/start": ["@solidjs/start@2.1.0", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/trace-mapping": "^0.3.31", "@solidjs/meta": "^0.29.4", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.10", "cookie-es": "^3.1.1", "defu": "^6.1.7", "error-stack-parser-es": "^2.0.1", "fast-glob": "^3.3.3", "h3": "^2.0.1-rc.26", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "oxc-parser": "^0.141.0", "path-to-regexp": "^8.4.2", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.6.11", "seroval-plugins": "^1.6.11", "shiki": "^4.3.1", "srvx": "^0.12.4", "terracotta": "^1.2.4", "vite-plugin-solid": "^2.11.13" }, "peerDependencies": { "@solidjs/router": ">=0.16.0 <2.0.0-0", "solid-js": "^1.9.15", "vite": "^8 || ^9" }, "optionalPeers": ["@solidjs/router"] }, "sha512-WxYkBPmjnrP30ODWLui8QVPki80puvsKZ5jOmy0m/t7mZsmhY0IBHpra8n+UQoMFgvvJkaAJS+DJqPFzmoKf+Q=="], "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], @@ -2562,14 +2566,8 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], - "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="], - "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="], - - "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="], - "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.37", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-RxJC8n/6nnFHcPvDGMg7ZkqSzCVUco4JNw3ctIChj1HjoKs5I9wt5nsU+DUVtMVEiMfizUxOAx4TPIN8SHjxUQ=="], @@ -2616,8 +2614,6 @@ "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="], "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], @@ -2626,8 +2622,6 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -2744,20 +2738,6 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], - "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - - "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], - - "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], - - "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], @@ -2786,11 +2766,11 @@ "@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.7", "", { "os": "linux", "cpu": "arm" }, "sha512-9r//Z022QYVoIAY458Dpk8HWkisk/y4NJC3d52RlkdsLD+9p0zn0cKKSoMF/xUsOWDFSmOf+cwU/Xadi/wxFMQ=="], - "@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="], + "@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="], "@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-eLLx4P8DzNnuPif19nAvKPaf88lSf6KFbDetH3nAIRZvI495h0jigN835Ayr7uAADssohGfO9MPMH33DXmFErA=="], - "@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="], + "@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="], "@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-YOcOkIwVvIhpwuGJ428h15vpE0KnT3teFG0g2J31FrBxrVh9IH4HeTGXjelI3l9SI5gyiZDnD4PQnQ1U6oqTcA=="], @@ -2806,6 +2786,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -2820,6 +2802,8 @@ "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="], "am-i-vibing": ["am-i-vibing@0.5.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA=="], @@ -2830,8 +2814,6 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "app-builder-lib": ["app-builder-lib@26.15.7", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.7", "electron-builder-squirrel-windows": "26.15.7" } }, "sha512-C7APoYISPExUmrEntNhDpz9Tccb4uWuEDfLaC0WPPc7/pwzz0WZGznCz/ycPfkkzw6tKOalceD8g6TgHmVz1QA=="], @@ -2850,8 +2832,6 @@ "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astro": ["astro@7.3.8", "", { "dependencies": { "@astrojs/compiler-rs": "^0.5.1", "@astrojs/internal-helpers": "0.12.0", "@astrojs/markdown-satteri": "0.4.3", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.1", "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.5.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.9.4", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^3.0.2", "find-proc": "0.2.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.6", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "jsonc-parser": "^3.3.1", "magic-string": "^1.4.2", "magicast": "^0.5.5", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.3", "p-queue": "^9.3.3", "package-manager-detector": "^1.8.0", "piccolore": "^0.1.3", "picomatch": "^4.0.7", "rolldown": "^1.2.11", "shiki": "^4.4.3", "smol-toml": "^1.9.0", "svgo": "^4.1.0", "tinyclip": "^1.0.3", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "ultrahtml": "^1.7.0", "unifont": "~0.7.5", "unstorage": "^1.17.5", "verkit": "^0.5.0", "vite": "^8.3.1", "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.6.5" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-LHzDHSaV+YzZiR3EFndDYTLLeNeuMtP1qX1FLcCNUUHOCqTTIWPczy2UV3zpA5VqvJe5cWZolqM9Z8JFqR2i9w=="], @@ -2880,8 +2860,6 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.10", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA=="], "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], @@ -2916,6 +2894,8 @@ "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -2946,6 +2926,8 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], @@ -2966,8 +2948,6 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -3040,25 +3020,31 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "content-disposition": ["content-disposition@2.0.1", "", {}, "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], - "cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="], + "crossws": ["crossws@0.4.15", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-NsUOpDZosz7nyI+qEIl05yaiXOWaXH2oTeM+XBWtiSWZtb5rzcIsl1AST/HzM2jzUn76eFuyrkn83E6iSBRUvw=="], "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], @@ -3100,7 +3086,7 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], + "db0": ["db0@0.4.1", "", {}, "sha512-6RBY/bSn42UrqATwsiULj2uYFyEykB3XeA/NLIVQeHNXlYV6D4Idxx3/aa6k5Y45Dwzx7sygeLotnqHWSeURYA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -3128,6 +3114,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -3182,6 +3170,8 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@4.0.6", "", {}, "sha512-Tc3mkzuLR6t6P9dhLt0D2GrxYtmU5td4XEJEiXCmILz53IRIfJyHsKZWjTA3xEjiwpNTtXBgI4iqYPxMztBDrw=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -3214,7 +3204,7 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -3230,11 +3220,11 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "env-runner": ["env-runner@0.2.3", "", { "dependencies": { "crossws": "^0.4.12", "exsolve": "^1.1.1", "httpxy": "^0.5.5", "srvx": "^1.0.5" }, "bin": { "env-runner": "./dist/cli.mjs" } }, "sha512-S8EFBKyznRwPVwayK187cYjvnpgC92xy+q0BIF++V965pfjMVVRbQ/HoTYGA/1EHmZ7E68k6Heq+4WnvVhOL5w=="], - "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "error-stack-parser-es": ["error-stack-parser-es@2.0.1", "", {}, "sha512-J36ntO+rMQVRuR/umlmxmfLi4TpWwtmTnHoJoXTiC2xDNazs0VDPKcX6pbhZMDd2HFtH9isMBJXMdbki+A++Pg=="], "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], @@ -3244,7 +3234,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], @@ -3264,6 +3254,8 @@ "escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], @@ -3288,6 +3280,8 @@ "eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -3298,12 +3292,16 @@ "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.3.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.3.0", "content-disposition": "^2.0.1", "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.8", "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.1.0", "vary": "^1.1.2" } }, "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g=="], + + "express-rate-limit": ["express-rate-limit@8.7.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA=="], + "expressive-code": ["expressive-code@0.44.2", "", { "dependencies": { "@expressive-code/core": "^0.44.2", "@expressive-code/plugin-frames": "^0.44.2", "@expressive-code/plugin-shiki": "^0.44.2", "@expressive-code/plugin-text-markers": "^0.44.2" } }, "sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg=="], + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + "ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="], "ext-name": ["ext-name@5.0.0", "", { "dependencies": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" } }, "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ=="], @@ -3320,6 +3318,8 @@ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -3340,6 +3340,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], "find-proc": ["find-proc@0.2.0", "", {}, "sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w=="], @@ -3364,6 +3366,10 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], @@ -3438,7 +3444,7 @@ "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], - "h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="], + "h3": ["h3@2.0.7", "", { "dependencies": { "rou3": "^1.0.0", "srvx": "^1.0.6" }, "peerDependencies": { "crossws": "^0.4.12", "ocache": ">=0.3.0" }, "optionalPeers": ["crossws", "ocache"], "bin": { "h3": "./bin/h3.mjs" } }, "sha512-00SVloxtViij9/JBsc/Ayd9H7GLXWB0KRfkReWG0dDwuUFKUKRrNGM3Csu+KZgppi5DD8Y1KpnfjtudWaRCoiQ=="], "happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="], @@ -3502,6 +3508,8 @@ "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + "hookable": ["hookable@6.1.2", "", {}, "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg=="], + "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], @@ -3518,6 +3526,8 @@ "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -3526,6 +3536,8 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "httpxy": ["httpxy@0.5.5", "", {}, "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA=="], + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], @@ -3562,6 +3574,8 @@ "ip-address": ["ip-address@10.7.4", "", {}, "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -3624,6 +3638,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="], @@ -3692,6 +3708,8 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], @@ -3854,8 +3872,12 @@ "mdurl": ["mdurl@1.0.1", "", {}, "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -3986,9 +4008,9 @@ "neotraverse": ["neotraverse@1.0.1", "", {}, "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w=="], - "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], + "nf3": ["nf3@0.3.24", "", {}, "sha512-HxLK4bo+5jNsEETZp4w3tJblHOA9MCBY14IN9nZJJV8JDxt9yNIYxuBuLMjTAR5GFa3HL61+8VQDUrXv3/C8fw=="], - "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], + "nitro": ["nitro@3.0.260903-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.12", "db0": "^0.4.1", "env-runner": "^0.2.1", "h3": "^2.0.1-rc.31", "hookable": "^6.1.1", "nf3": "^0.3.24", "ocache": "^0.3.0", "rolldown": "^1.2.7", "rou3": "^0.9.2", "srvx": "^1.0.3", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.10" }, "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-54gANPi62O8rfMvepiJUVuIzEIinYfpeHbebywlLxvVTgIYXDwSvpaU9Id+0sJOBjBx0wC1/CVYXJkH7SY+l0g=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -4006,8 +4028,6 @@ "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], - "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], - "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], @@ -4040,6 +4060,8 @@ "number-flow": ["number-flow@0.6.2", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -4050,12 +4072,16 @@ "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], + "ocache": ["ocache@0.3.1", "", {}, "sha512-qeBrhQ0ly6tgXsakgNwLx6diRUppJZIcV5OjVGa5yp9HzztutkaGDDXJqVMo1fKWz5fNDtTp3iPU2fuhHf0+Lg=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], "oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="], + "on-finished": ["on-finished@2.4.2", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], @@ -4072,9 +4098,7 @@ "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], - "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], - - "oxc-transform": ["oxc-transform@0.96.0", "", { "optionalDependencies": { "@oxc-transform/binding-android-arm64": "0.96.0", "@oxc-transform/binding-darwin-arm64": "0.96.0", "@oxc-transform/binding-darwin-x64": "0.96.0", "@oxc-transform/binding-freebsd-x64": "0.96.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-transform/binding-linux-arm-musleabihf": "0.96.0", "@oxc-transform/binding-linux-arm64-gnu": "0.96.0", "@oxc-transform/binding-linux-arm64-musl": "0.96.0", "@oxc-transform/binding-linux-riscv64-gnu": "0.96.0", "@oxc-transform/binding-linux-s390x-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-musl": "0.96.0", "@oxc-transform/binding-wasm32-wasi": "0.96.0", "@oxc-transform/binding-win32-arm64-msvc": "0.96.0", "@oxc-transform/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dQPNIF+gHpSkmC0+Vg9IktNyhcn28Y8R3eTLyzn52UNymkasLicl3sFAtz7oEVuFmCpgGjaUTKkwk+jW2cHpDQ=="], + "oxc-parser": ["oxc-parser@0.141.0", "", { "dependencies": { "@oxc-project/types": "^0.141.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.141.0", "@oxc-parser/binding-android-arm64": "0.141.0", "@oxc-parser/binding-darwin-arm64": "0.141.0", "@oxc-parser/binding-darwin-x64": "0.141.0", "@oxc-parser/binding-freebsd-x64": "0.141.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.141.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.141.0", "@oxc-parser/binding-linux-arm64-gnu": "0.141.0", "@oxc-parser/binding-linux-arm64-musl": "0.141.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.141.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.141.0", "@oxc-parser/binding-linux-riscv64-musl": "0.141.0", "@oxc-parser/binding-linux-s390x-gnu": "0.141.0", "@oxc-parser/binding-linux-x64-gnu": "0.141.0", "@oxc-parser/binding-linux-x64-musl": "0.141.0", "@oxc-parser/binding-openharmony-arm64": "0.141.0", "@oxc-parser/binding-wasm32-wasi": "0.141.0", "@oxc-parser/binding-win32-arm64-msvc": "0.141.0", "@oxc-parser/binding-win32-ia32-msvc": "0.141.0", "@oxc-parser/binding-win32-x64-msvc": "0.141.0" } }, "sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w=="], "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="], @@ -4108,6 +4132,8 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -4188,6 +4214,8 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], @@ -4204,12 +4232,18 @@ "qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="], + "qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], @@ -4326,7 +4360,9 @@ "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], - "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -4358,6 +4394,8 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], "serialize-javascript": ["serialize-javascript@7.1.0", "", {}, "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw=="], @@ -4366,12 +4404,16 @@ "seroval-plugins": ["seroval-plugins@1.6.11", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-LmB3nd8pL39jKHKvnpTUjcjBtNwR8MLty/ydAR8qbfXt5j6/6KqR1atuw8cVmD2/lDxAkvAwq5tI/HsisD9Fqg=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -4390,8 +4432,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], @@ -4462,7 +4502,7 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], + "srvx": ["srvx@0.12.8", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-7k9HoDhluTlD7UJ+F+OopZoPZXB2J8f/5nW2VMLaB8KsSlZlRQxyyoX0hWEQCxB+F+3ZvB5QX3LqB6xSBRfxQQ=="], "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], @@ -4484,13 +4524,11 @@ "sst-win32-x86": ["sst-win32-x86@4.13.1", "", { "os": "win32", "cpu": "none" }, "sha512-YPxBVdac/MsrzwlC6pF0NrrvMcmfdBLYjv7MbzHc5jNh1FQ1WPh6bdWQqgv0KD9EQTNLLEkej0beydgUvcCWJg=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -4554,7 +4592,7 @@ "tempy": ["tempy@0.6.0", "", { "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", "type-fest": "^0.16.0", "unique-string": "^2.0.0" } }, "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw=="], - "terracotta": ["terracotta@1.1.1", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Sa6wnvkGMFmPq8N/wQb2aKkIW2eXH0LJvUOEk6QZLBEjqy+LsbzAOpDuqEfOmfA/hexssE6eQve2i1IIOeOh4g=="], + "terracotta": ["terracotta@1.3.1", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-8HNvzQbgp2KiK8CiaQayhEQrjW97n9K1Exfj2+TMLVrHTmGXBJ61QypXlGa+xf5Gc7gzrAIysnmy/7qkUyN8Ow=="], "terser": ["terser@5.50.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w=="], @@ -4564,20 +4602,14 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyclip": ["tinyclip@1.0.3", "", {}, "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA=="], "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], "tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="], @@ -4590,14 +4622,12 @@ "toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], - - "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], - "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -4618,6 +4648,8 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -4688,7 +4720,9 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "unstorage": ["unstorage@2.0.0-beta.0", "", {}, "sha512-VeTY+zQ3ozrNtIFBRQRuSwanHdpuRH+4t/ZzchZIVQO8/oKq2xl7fJ9c+z76tWJDW/BEXSEuzpHOxqoAl6B64A=="], "unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="], @@ -4706,6 +4740,8 @@ "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "verkit": ["verkit@0.5.0", "", {}, "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -4724,8 +4760,6 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], - "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], "volar-service-emmet": ["volar-service-emmet@0.0.71", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA=="], @@ -4792,8 +4826,6 @@ "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="], "workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="], @@ -4870,6 +4902,8 @@ "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], @@ -4878,11 +4912,15 @@ "@ai-sdk/provider-utils/undici": ["undici@6.29.1", "", {}, "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA=="], + "@anthropic-ai/sdk/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@astrojs/astro2tsx/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + "@astrojs/astro2tsx/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], - "@astrojs/compiler-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], - "@astrojs/compiler-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@astrojs/internal-helpers/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -4900,8 +4938,6 @@ "@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], - "@astrojs/mdx/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "@astrojs/solid-js/vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="], "@astrojs/starlight/@astrojs/markdown-satteri": ["@astrojs/markdown-satteri@0.3.8", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.4", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "satteri": "^0.10.3" } }, "sha512-n8ItpFTCmlDsVR5+rwDmehSf+jFCYLWmZiisZNGuF7xILqYhsVeBfcha4qgS2Seq3fAc9Tm58ZVrvqFQ6RQgRQ=="], @@ -4928,7 +4964,7 @@ "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@bruits/satteri-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], + "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], "@cloudflare/vite-plugin/miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="], @@ -4978,6 +5014,8 @@ "@modelcontextprotocol/client/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], + "@npmcli/arborist/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], "@npmcli/config/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], @@ -5004,9 +5042,9 @@ "@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], - "@octokit/request-error/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + "@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "@opencode/console-app/vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="], + "@octokit/request-error/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], "@opencode/console-app/wrangler": ["wrangler@4.50.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.11", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20251118.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251118.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251118.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-+nuZuHZxDdKmAyXOSrHlciGshCoAPiy5dM+t6mEohWm7HpXvTHmWQGUf/na9jjWlWJHCJYOWzkA1P5HBJqrIEA=="], @@ -5022,8 +5060,6 @@ "@opencode/session-ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="], - "@opencode/stats-app/vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="], - "@opencode/stories/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="], "@opencode/stories/vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="], @@ -5054,7 +5090,7 @@ "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.16", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.11" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.16" }, "optionalPeers": ["solid-js"] }, "sha512-+0h40Qk2oBfkQ4OGDBHIDtvq/XQiOnCfzjNHmMxriAr8MmdsaIbyJgqYglDs4d70sF8O08sSpx61TKdpUt3oRw=="], "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], @@ -5092,14 +5128,6 @@ "@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "@solidjs/start/seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], - - "@solidjs/start/seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], - - "@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], - - "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -5114,12 +5142,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/directive-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - - "@tanstack/router-utils/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - - "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -5160,6 +5182,8 @@ "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "builder-util/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -5198,8 +5222,14 @@ "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "env-runner/srvx": ["srvx@1.0.6", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-QeTKgAhk8OSsfeq60bl3wuVs9PmBftzmk7owBInA/F1YQne+TRw0UcPymNpURldAmx8hfmM2PNNGH/zgo3GpLA=="], + "esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "express/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "ffi-rs/@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OueBlUFBT9IwD9pQnoYs0UszRBEySskfrEPXXfvKfGjL/DXnfn6kUheQ3oIP6sSmshVGNQUwrTCPo6feAa4QjA=="], @@ -5224,14 +5254,18 @@ "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "gitlab-ai-provider/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "h3/rou3": ["rou3@1.0.0", "", {}, "sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ=="], + + "h3/srvx": ["srvx@1.0.6", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-QeTKgAhk8OSsfeq60bl3wuVs9PmBftzmk7owBInA/F1YQne+TRw0UcPymNpURldAmx8hfmM2PNNGH/zgo3GpLA=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -5254,9 +5288,9 @@ "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], + "nitro/rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="], - "nitro/undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], + "nitro/srvx": ["srvx@1.0.6", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-QeTKgAhk8OSsfeq60bl3wuVs9PmBftzmk7owBInA/F1YQne+TRw0UcPymNpURldAmx8hfmM2PNNGH/zgo3GpLA=="], "node-gyp/undici": ["undici@6.29.1", "", {}, "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA=="], @@ -5268,6 +5302,8 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.141.0", "", {}, "sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -5294,6 +5330,8 @@ "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], @@ -5338,7 +5376,7 @@ "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.2", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -5352,12 +5390,6 @@ "vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "vitest/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - - "vitest/obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], - - "vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], - "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@9.0.1", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="], @@ -5400,6 +5432,12 @@ "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "youch-core/error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "@astrojs/astro2tsx/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "@astrojs/internal-helpers/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "@astrojs/markdown-remark/@astrojs/internal-helpers/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], @@ -5410,13 +5448,13 @@ "@astrojs/markdown-satteri/satteri/@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q=="], - "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w=="], + "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w=="], - "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA=="], + "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA=="], - "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw=="], + "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw=="], - "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg=="], + "@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg=="], "@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.10.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.2.3" }, "cpu": "none" }, "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw=="], @@ -5444,8 +5482,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], "@cloudflare/vite-plugin/miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -5522,6 +5558,8 @@ "@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentui/solid/babel-preset-solid/babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.11", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-k7TkOv7uVYnrq/bYyVvG5cWOC9IObAGfmyW3aMkG8l2sq2qt/5THkUBja69PMLEGGi8G+fXB2Fh7eS4OwZRBoQ=="], + "@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], @@ -5536,18 +5574,6 @@ "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], - "@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], - - "@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], - - "@solidjs/start/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], - - "@solidjs/start/shiki/@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="], - - "@solidjs/start/shiki/@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="], - - "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], @@ -5596,17 +5622,17 @@ "astro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="], - "astro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="], + "astro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="], - "astro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="], + "astro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="], - "astro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="], + "astro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="], - "astro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="], + "astro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="], - "astro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="], + "astro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="], - "astro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="], + "astro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="], "astro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="], @@ -5618,8 +5644,6 @@ "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], - "astro/vite/postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], @@ -5714,6 +5738,38 @@ "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="], + + "nitro/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="], + + "nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="], + + "nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="], + + "nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="], + + "nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="], + + "nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="], + + "nitro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="], + + "nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="], + + "nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="], + + "nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="], + + "nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="], + + "nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="], + + "nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="], + + "nitro/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="], + + "nitro/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="], + "node-html-parser/css-select/css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -5800,6 +5856,10 @@ "@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], + "@astrojs/mdx/@astrojs/internal-helpers/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "@astrojs/solid-js/vite/postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], @@ -5818,17 +5878,17 @@ "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="], - "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="], + "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="], - "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="], + "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="], - "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="], + "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="], - "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="], + "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="], - "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="], + "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="], - "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="], + "@astrojs/solid-js/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="], "@astrojs/solid-js/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="], @@ -5842,13 +5902,13 @@ "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q=="], - "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w=="], + "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w=="], - "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA=="], + "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA=="], - "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw=="], + "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw=="], - "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg=="], + "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg=="], "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.10.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.2.3" }, "cpu": "none" }, "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw=="], @@ -6068,12 +6128,12 @@ "@opencode/web/@astrojs/check/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "@opentui/solid/babel-preset-solid/babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], - "astro/unstorage/chokidar/readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], "astro/unstorage/h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], @@ -6106,10 +6166,12 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], @@ -6190,10 +6252,6 @@ "@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], - - "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], - "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -6206,8 +6264,6 @@ "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], - "@astrojs/starlight/@astrojs/markdown-satteri/satteri/@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], diff --git a/nix/hashes.json b/nix/hashes.json index e42eaff3c7c1..37448e7b05e3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,7 +1,7 @@ { "nodeModules": { - "x86_64-linux": "sha256-ZeDwprKqzVEm/8IkD7D/EkwAGSy9HR8yQnGUMoae1Kc=", - "aarch64-linux": "sha256-DUkIdnwsQ2p+vqVFCRtQwMCYlETJl1QV8OxVsen273g=", - "aarch64-darwin": "sha256-ie3dihmkL/U4kPw+hKyVNj8uryed9mxGEiq8VpxMMIc=" + "x86_64-linux": "sha256-j261NtmgO2n2vUlIPXv4t0W3r8uNQQrpMoQ/NRE51X0=", + "aarch64-linux": "sha256-Q3YkWzCLqxVSzPDWWTUMyddJjkmldbXAPSQs+NEgEdk=", + "aarch64-darwin": "sha256-rb8stm2Va+SkGrZXeCYDwFvrKtpsRnggQeBp5p8NPYY=" } } diff --git a/package.json b/package.json index 73d223e2a904..03a0fb46c051 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode", "description": "AI-powered development tool", - "version": "2.2.2", + "version": "2.2.4", "private": true, "type": "module", "packageManager": "bun@1.4.4", @@ -53,9 +53,9 @@ "@types/bun": "1.4.3", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", - "@opentui/core": "0.7.0", - "@opentui/keymap": "0.7.0", - "@opentui/solid": "0.7.0", + "@opentui/core": "0.8.0", + "@opentui/keymap": "0.8.0", + "@opentui/solid": "0.8.0", "@tanstack/solid-virtual": "3.13.37", "@standard-schema/spec": "1.1.0", "@kobalte/core": "0.13.13", @@ -102,7 +102,7 @@ "vite": "7.3.7", "@solidjs/meta": "0.29.4", "@solidjs/router": "1.0.0", - "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@solidjs/start": "2.1.0", "@sentry/solid": "10.71.0", "@sentry/vite-plugin": "5.4.0", "solid-js": "1.9.17", @@ -150,9 +150,9 @@ "electron" ], "overrides": { - "@opentui/core": "0.7.0", - "@opentui/keymap": "0.7.0", - "@opentui/solid": "0.7.0", + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", "@effect/platform-node-shared": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index ca85b9017645..c79efda7fe26 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -5,8 +5,9 @@ - **Forward compatibility.** Providers ship new models, options, and values constantly. Accept unknown pass-through values (`Known | (string & {})` for string options); use closed literals only when OpenCode branches on the value. - **Tolerate unknowns.** Unknown provider events, statuses, and enum values must not fail a request; ignore them or pass them through. Fail only on errors in the provider's documented shape, malformed known types, or a missing terminal event. - **One canonical model.** The Schema classes in `src/schema/` are the data model. Do not add a parallel shape or a second construction path. -- **Independent of Session.** Auth, permissions, plugins, and orchestration belong in Core. `LLM.stream`/`LLM.generate` run exactly one model call. +- **Independent of Session.** Auth, permissions, plugins, and orchestration belong in Core. `ai.stream`/`ai.generate` run exactly one model call. - **Reuse protocols.** Providers compose existing protocols with `Route.make(...)` instead of cloning them. +- **One API, two runtimes.** `ai` from `@opencode/ai` and `ai` from `@opencode/ai/effect` have the same namespaces and operations, with the same names, overloads, input and options (plus `signal` on the Promise side); keep `test/promise-parity.types.ts` passing. ## Code style diff --git a/packages/ai/README.md b/packages/ai/README.md index d5ac6503d434..4a3622110ed3 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1,39 +1,39 @@ # @opencode/ai -Schema-first APIs for text, images, video, speech, transcription, and decisions, built with Effect. +Schema-first APIs for text, images, and decisions, built with Effect. Video, speech, transcription, extraction, and +realtime voice are [experimental](#experimental). `@opencode/ai` is the Promise API: one managed runtime behind the LLM and media methods, plus asset helpers. `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed: ```ts -import { AI } from "@opencode/ai" +import { ai } from "@opencode/ai" import { OpenAI } from "@opencode/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }) -const ai = AI.make() const input = { model: openai.responses("gpt-4o-mini"), prompt: "Say hello." } -const response = await ai.llm.generate(input) +const response = await ai.generate(input) console.log(response.text) const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }) await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset) -for await (const event of ai.llm.stream(ai.llm.request(input))) { +for await (const event of ai.stream(ai.request(input))) { // LLMEvent } -await ai.dispose() ``` -The Effect API lives at `@opencode/ai/effect`. Its methods return `Effect` or `Stream`: +The Effect API lives at `@opencode/ai/effect`. Its `ai` has the same namespaces and methods, returning `Effect` or +`Stream`: ```ts import { Effect } from "effect" -import { AIClient, LLM } from "@opencode/ai/effect" +import { ai, AIClient } from "@opencode/ai/effect" import { OpenAI } from "@opencode/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }) const program = Effect.gen(function* () { - const response = yield* LLM.generate({ + const response = yield* ai.generate({ model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead system: "You are concise.", prompt: "Say hello in one short sentence.", @@ -46,8 +46,8 @@ const program = Effect.gen(function* () { await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer))) ``` -Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt -`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, +Run `ai.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt +`ai.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. ### Tool loop @@ -55,22 +55,22 @@ Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployme `stream` runs one model call; `loop` runs the model's tool calls and calls the model again until it stops calling tools. Events from every step arrive on one stream, with a `tool-result` after each tool runs. `until` is checked after each step that has tool calls, with every step's `LLMResponse`; returning `true` ends the loop without running -them. `AI.tool` takes a plain `async` execute; a throw or rejection goes back to the model as an error result, and +them. `tool` takes a plain `async` execute; a throw or rejection goes back to the model as an error result, and `signal` aborts when the loop is interrupted. -`Tool.make` from `@opencode/ai/effect` is the Effect form, with a validated `success` schema: +`tool` from `@opencode/ai/effect` takes the same options (including an optional `success` schema) with an Effect execute: ```ts import { readFile } from "node:fs/promises" import { Schema } from "effect" -import { AI } from "@opencode/ai" +import { ai, tool } from "@opencode/ai" -const read_file = AI.tool({ +const read_file = tool({ description: "Read a UTF-8 file.", parameters: Schema.Struct({ path: Schema.String }), execute: ({ path }, { signal }) => readFile(path, { encoding: "utf8", signal }), }) -const events = ai.llm.loop({ +const events = ai.loop({ model, prompt: "Summarize package.json.", tools: { read_file }, @@ -87,7 +87,7 @@ A tool with exactly one required string parameter can set `freeform`, so the mod instead of JSON arguments: unconstrained `text`, or text matching a `regex` or `lark` grammar. ```ts -const run_code = AI.tool({ +const run_code = tool({ description: "Run JavaScript", parameters: Schema.Struct({ code: Schema.String }), freeform: { type: "lark", grammar: JS_GRAMMAR }, @@ -108,7 +108,7 @@ history works with or without `freeform`. unvalidated `unknown` object: ```ts -const { object } = await ai.llm.generateObject({ +const { object } = await ai.generateObject({ model, prompt: "Pick a city.", schema: Schema.Struct({ city: Schema.String }), @@ -123,12 +123,12 @@ are visible too. Raw events are off by default, and `generate` ignores them. Tra ```ts import { Stream } from "effect" -import { LLM, LLMEvent } from "@opencode/ai/effect" +import { ai, LLMEvent } from "@opencode/ai/effect" const flagged = (data: unknown) => typeof data === "object" && data !== null && "type" in data && data.type === "moderation.flagged" -const events = LLM.stream({ model, prompt: "Say hello.", includeRaw: true }).pipe( +const events = ai.stream({ model, prompt: "Say hello.", includeRaw: true }).pipe( Stream.map((event) => event.type === "raw" && flagged(event.data) ? LLMEvent.providerError({ message: "Flagged by moderation" }) : event, ), @@ -147,7 +147,7 @@ live under `providerMetadata.vertex`. The default is still full-history replay w ```ts const model = Google.configure({ apiKey }).interactions("gemini-3.8-flash") -const response = yield* LLM.generate({ +const response = yield* ai.generate({ model, prompt: "Say hello.", providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto", store: true }, @@ -165,15 +165,15 @@ Raw usage is preserved in `usage.providerMetadata.google`. `inputTokens` follows `contextTokens` uses its full `raw_prompt_token` count when supplied. These can differ substantially with server-side continuation. Explicit caches, hosted tools, and generated media are not supported by this initial protocol. -The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the -provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes: +The same configured facade names image models (and the experimental video, speech, and transcription models). +`ai.image.generate` resolves the provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes: ```ts import { NodeFileSystem } from "@effect/platform-node" -import { Image, Media } from "@opencode/ai/effect" +import { ai, Media } from "@opencode/ai/effect" const image = Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A robot tending a rooftop garden", aspect: "1:1", @@ -209,12 +209,12 @@ const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged }) select the same API, and `responses` selects its Responses API; credentials default to `VENICE_API_KEY`. ```ts -import { LLM } from "@opencode/ai/effect" +import { ai } from "@opencode/ai/effect" import { Venice } from "@opencode/ai/providers" import { Effect } from "effect" const program = Effect.gen(function* () { - const response = yield* LLM.generate({ + const response = yield* ai.generate({ model: Venice.configure({ apiKey: process.env.VENICE_API_KEY }).chat("qwen3-6-27b"), prompt: "Explain this design.", providerOptions: { @@ -245,10 +245,10 @@ values, or a `score` on ordered levels, each with the probability of every optio ```ts import { Effect } from "effect" -import { Decision, DecisionClient, Media } from "@opencode/ai/effect" +import { ai, DecisionClient, Media } from "@opencode/ai/effect" import { OpenAI } from "@opencode/ai/providers" -const program = Decision.generate({ +const program = ai.decision.generate({ model: OpenAI.configure().decision("gpt-6-luna"), input: [ { type: "text", text: "I was charged twice. Please refund the duplicate payment." }, @@ -309,38 +309,6 @@ CloudflareAIGateway.configure({ accountId }).decision("typesafe/jev") OpenAI and Vercel AI Gateway use OpenAI's `/v1/decisions` API. TypeSafe, OpenCode, OpenRouter, Baseten and DigitalOcean use System One (`/systemone`), and Cloudflare sends the System One body through `/ai/run`. -### Extraction (experimental) - -Extraction models read typed values out of text instead of answering questions with probabilities. A field of type -`text`, `number`, `date`, `time`, `datetime`, `duration`, `list`, `estimate` or `forecast` gets an answer typed to -match; other types are sent and answered as-is. Only Cloudflare's Clairic serves it today, so it lives under -`@opencode/ai/experimental`. - -```ts -import { ai } from "@opencode/ai" -import { Extraction } from "@opencode/ai/experimental" -import { CloudflareWorkersAI } from "@opencode/ai/providers" - -const response = await ai.run( - Extraction.generate({ - model: CloudflareWorkersAI.configure({ accountId }).experimental.extraction("@cf/cloudflare/clairic"), - input: "Invoice INV-2291, issued 2026-09-02, net 30. Total $1,080.00. Contact: Dana Ruiz, Priya Shah.", - fields: { - invoiceNumber: { type: "text" }, - dueDate: { type: "date", instructions: "Issue date plus the net payment days" }, - total: { type: "number", decimals: 2, unit: "USD" }, - contacts: { type: "list", items: "text" }, - }, - }), -) -response.answers.total.value // 1080, or null when the input does not say -``` - -Value answers carry `guess`, `nullProbability` and `confidence`; text answers carry a `span` into `input`. Estimates and -forecasts return `median`, `p10` and `p90`. Field options other than `type`, `instructions` and `nullThreshold` go to -Clairic as written. Effect users provide `RequestExecutor.fetchLayer` (or `AIClient.layer`). Images are not supported -yet. - ## Alibaba Cloud Model Studio `Alibaba` provides standard Model Studio inference. Configure a region explicitly, then select @@ -348,7 +316,7 @@ Chat Completions (`.model` or `.chat`), Anthropic-compatible Messages (`.message Responses (`.responses`). These routes use HTTP/SSE. ```ts -import { LLM } from "@opencode/ai/effect" +import { ai } from "@opencode/ai/effect" import { Alibaba } from "@opencode/ai/providers" const alibaba = Alibaba.configure({ @@ -357,7 +325,7 @@ const alibaba = Alibaba.configure({ // workspaceID: "llm-your-workspace", // use a workspace-dedicated endpoint }) -const request = LLM.request({ +const request = ai.request({ model: alibaba.model("qwen3.8-max"), prompt: "Explain this design.", providerOptions: { reasoningEffort: "medium" }, @@ -428,11 +396,11 @@ hours. A complete `baseURL` for images includes `/api/v1`. the existing `.image(...)` selector provides image generation. ```ts -import { LLM } from "@opencode/ai/effect" +import { ai } from "@opencode/ai/effect" import { ZAI, ZAICodingPlan } from "@opencode/ai/providers" const zai = ZAI.configure({ apiKey: process.env.ZAI_API_KEY }) -const request = LLM.request({ +const request = ai.request({ model: zai.model("glm-5.3"), // also zai.chat("glm-5.3") prompt: "Explain this design.", providerOptions: { @@ -442,7 +410,7 @@ const request = LLM.request({ }) const coding = ZAICodingPlan.configure({ apiKey: process.env.ZAI_API_KEY }) -const messages = LLM.request({ +const messages = ai.request({ model: coding.messages("glm-5.3"), prompt: "Explain this design.", providerOptions: { effort: "high" }, @@ -494,24 +462,24 @@ Package entrypoints are `@opencode/ai/providers/zai`, `zai/chat`, `zai-coding-pl Moonshot defaults to Chat Completions, with Messages and Responses selectors for Kimi K3: ```ts -import { LLM } from "@opencode/ai/effect" +import { ai } from "@opencode/ai/effect" import { Moonshot } from "@opencode/ai/providers" const moonshot = Moonshot.configure({ apiKey: process.env.MOONSHOT_API_KEY }) -const request = LLM.request({ +const request = ai.request({ model: moonshot.model("kimi-k3"), // also moonshot.chat("kimi-k3") prompt: "Explain the tradeoffs in this design.", providerOptions: { reasoningEffort: "high" }, }) -const messages = LLM.request({ +const messages = ai.request({ model: moonshot.messages("kimi-k3"), prompt: "Explain the tradeoffs in this design.", providerOptions: { effort: "high" }, }) -const responses = LLM.request({ +const responses = ai.request({ model: moonshot.responses("kimi-k3"), prompt: "Explain the tradeoffs in this design.", providerOptions: { reasoningEffort: "high" }, @@ -556,18 +524,18 @@ MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` i ```ts import { Effect } from "effect" -import { AIClient, LLM } from "@opencode/ai/effect" +import { ai, AIClient } from "@opencode/ai/effect" import { MiniMax } from "@opencode/ai/providers" const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY }) -const request = LLM.request({ +const request = ai.request({ model: minimax.model("MiniMax-M3"), // also minimax.messages("MiniMax-M3") prompt: "What is 173 multiplied by 219?", providerOptions: { thinking: { type: "adaptive" } }, generation: { maxTokens: 1536 }, }) -const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer))) +const response = await Effect.runPromise(ai.generate(request).pipe(Effect.provide(AIClient.layer))) console.log(response.text) ``` @@ -598,7 +566,7 @@ Use Meta's direct [Model API](https://dev.meta.ai/docs/overview) with `META_API_ import { Meta } from "@opencode/ai/providers" const meta = Meta.configure() // or Meta.configure({ apiKey }) -const request = LLM.request({ +const request = ai.request({ model: meta.responses("muse-spark-1.3"), // meta.model(...) also selects Responses prompt: "What is 173 multiplied by 219? Reply with the integer.", providerOptions: { reasoningEffort: "low" }, @@ -632,19 +600,19 @@ To include search result lists, set `include: ["reasoning.encrypted_content", "w Messages exposes hosted search calls; the recorded Messages API stream does not supply structured citations or separate result blocks. Retain `response.message` for either API's continuation. -Use `Image.generate` for one-off generation or editing: +Use `ai.image.generate` for one-off generation or editing: ```ts -import { Image, Media } from "@opencode/ai/effect" +import { ai, Media } from "@opencode/ai/effect" -const generation = Image.generate({ +const generation = ai.image.generate({ model: meta.image("muse-image-1.0"), prompt: "A flat black square on a white background.", n: 1, providerOptions: { reasoningStrength: "low" }, }) -const edit = Image.generate({ +const edit = ai.image.generate({ model: meta.image("muse-image-1.0"), prompt: "Make the square purple.", images: [Media.bytes(imageBytes, "image/webp")], @@ -664,20 +632,20 @@ Meta Responses is explicitly HTTP/SSE-only and does not use WebSockets, even whe ## Image generation -Use `Image.generate` with an image model for direct asset generation. `Image.request` mirrors `LLM.request`: the +Use `ai.image.generate` with an image model for direct asset generation. `ai.image.request` mirrors `ai.request`: the model comes from the facade's `.image(...)` selector (mirroring `.responses(...)`), common fields (`images`, `n`, `aspect`, `resolution`) lower natively or fail typed, and `providerOptions` is inferred from the selected model. `aspect` is a ratio such as `"16:9"`; `resolution` is a size tier (`"1K"` is about 1024×1024 pixels at that ratio, `"2K"` about 2048×2048). Exact sizes, seeds, masks, and output formats are provider options: ```ts -import { Image, Media } from "@opencode/ai/effect" +import { ai, Media } from "@opencode/ai/effect" import { OpenAI } from "@opencode/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }) const program = Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A robot tending a rooftop garden", n: 2, @@ -712,7 +680,7 @@ table before swapping only the `model`: | Cloudflare Workers AI | 1 | width × height² | width × height² | ✗ | `width`, `height`, `seed` | ✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. "width × height" -computes a size the provider accepts and fails when the request falls outside its limits. ¹ `Image.stream` on OpenAI +computes a size the provider accepts and fails when the request falls outside its limits. ¹ `ai.image.stream` on OpenAI generates one image. On OpenAI, `gpt-image-2` and later get a computed size; older models get the nearest of their fixed sizes. `providerOptions.size` replaces `aspect` and `resolution`, so setting both fails. Vercel AI Gateway forwards `outputFormat` but does not apply it, so the returned bytes keep the upstream model's own format. ² Workers AI @@ -734,7 +702,7 @@ Pass ordered image inputs to the same method for editing, composition, or image- ```ts const composed = Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "Combine these product photos into one studio scene", images: [ @@ -758,7 +726,7 @@ uses multipart for byte/data-URL edits and its JSON reference body for URL or fi provider option on the routes that support it (OpenAI): ```ts -const inpainted = Image.generate({ +const inpainted = ai.image.generate({ model: openai.image("gpt-image-2"), prompt, images: [Media.bytes(sourceBytes, "image/png")], @@ -774,7 +742,7 @@ not accept image inputs. These cases fail with a typed `AIError` before network Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them: ```ts -const medium = Image.generate({ +const medium = ai.image.generate({ model: openai.image("gpt-image-2"), prompt, providerOptions: { quality: "medium" }, @@ -787,7 +755,7 @@ xAI image models use the same request API with xAI-native controls: ```ts import { XAI } from "@opencode/ai/providers" -const xai = Image.generate({ +const xai = ai.image.generate({ model: XAI.configure({ apiKey }).image("any-model-id"), prompt, n: 2, @@ -807,7 +775,7 @@ Google's current Gemini image models use the same direct API: import { Google } from "@opencode/ai/providers" const googleProgram = Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: Google.configure({ apiKey }).image("any-model-id"), prompt: "A robot tending a rooftop garden", aspect: "16:9", @@ -835,7 +803,7 @@ Z.ai image models infer open Z.ai-native options from the selected model: ```ts import { ZAI } from "@opencode/ai/providers" -const zai = Image.generate({ +const zai = ai.image.generate({ model: ZAI.configure({ apiKey }).image("any-model-id"), prompt, providerOptions: { @@ -856,7 +824,7 @@ persist the bytes promptly if they must remain available. ```ts import { MiniMax } from "@opencode/ai/providers" -const minimax = Image.generate({ +const minimax = ai.image.generate({ model: MiniMax.configure({ apiKey }).image("image-01"), prompt, n: 2, @@ -873,14 +841,14 @@ error. ### Partial images -OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2) -and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request: +OpenAI's GPT image models stream previews. `ai.image.stream` sends `stream: true` with `partialImages` (0–3, default 2) +and emits `image-partial` events before each final `image`; `ai.image.generate` keeps the plain JSON request: ```ts import { Stream } from "effect" import { ImageEvent } from "@opencode/ai/effect" -const previews = Image.stream({ +const previews = ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A lighthouse at dusk", providerOptions: { partialImages: 2 }, @@ -896,8 +864,8 @@ Conversational image generation remains part of the LLM interaction. OpenAI Resp ```ts const program = Effect.gen(function* () { - const response = yield* LLM.generate( - LLM.request({ + const response = yield* ai.generate( + ai.request({ model: OpenAI.configure({ apiKey }).responses("gpt-5"), prompt: "Design a solarpunk rooftop garden, then show me.", tools: [OpenAI.imageGeneration({ quality: "high" })], @@ -910,7 +878,42 @@ const program = Effect.gen(function* () { The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes. -## Video generation +## Public API + +Everything below is exported from `@opencode/ai/effect` unless noted. + +- **`ai.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. +- **`ai.generate` / `ai.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use. +- **`ai.loop`** — stream model calls, running `tool(...)` tools between them until the model stops calling tools. +- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. +- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. +- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. +- **`ai.image.request` / `generate` / `stream` / `start` / `resume`** — images over inline, streaming (partial previews), and queued routes through a provider-neutral request and response model. +- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. +- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests. +- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes. +- **`ai.decision.request` / `ai.decision.generate`** — typed predicate, choice, and score questions over text, JSON, and media input; `DecisionClient` is its Effect service and layer. +- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer. +- **`@opencode/ai`** (Promise root) — a default `ai` with the text calls (`request`, `generate`, `stream`, `loop`, `generateObject`, `compact`, `canCompact`) at the top level plus `image` and `decision` (plus `experimental.video`, `experimental.speech`, and `experimental.transcription`) as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets. `AI.make({ layer?, clients? })` builds a client over a custom executor or test layers. The same `ai` from `@opencode/ai/effect` returns `Effect` and `Stream`. +- **`@opencode/ai/experimental`** — APIs that may change without compatibility guarantees: `Video`, `Speech`, `Transcription`, `Extraction`, and `Realtime`. See [Experimental](#experimental). + +## Experimental + +These APIs may change without compatibility guarantees. The Effect API is exported from `@opencode/ai/experimental`, +the Promise client exposes it under `ai.experimental`, and test layers come from `@opencode/ai/experimental/testing`. + +- **`Video`**, **`Speech`**, and **`Transcription`** — media generation and speech-to-text, with `VideoClient`, + `SpeechClient`, and `TranscriptionClient` as their Effect services. Promise: `ai.experimental.video`, `.speech`, and + `.transcription`. Testing: `TestVideo`, `TestSpeech`, and `TestTranscription`. +- **`Extraction`** — typed values read out of text. +- **`Realtime`** — `Realtime.connect` starts an OpenAI, xAI, or Gemini voice session and returns a WebRTC answer SDP or + a short-lived WebSocket URL and token. + +The provider selectors for these modalities (`.video(...)`, `.speech(...)`, `.transcription(...)`, +`.experimental.extraction(...)`) and their protocol entrypoints (such as `@opencode/ai/protocols/openai-speech`) are +part of the same experimental surface. + +### Video generation Video mirrors `Image` with one difference: every provider is asynchronous, so the route is a submit-then-poll `Generation`. Models come from `.video(...)` selectors on the `Google` (Veo), `XAI`, and `Runway` facades. @@ -919,7 +922,7 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspect`, `re under `providerOptions`, inferred from the selected model. ```ts -import { Video } from "@opencode/ai/effect" +import { Video } from "@opencode/ai/experimental" import { Google, Runway } from "@opencode/ai/providers" const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY }) @@ -981,9 +984,9 @@ Provider notes: - **Runway** expects pixel ratios in `aspect` for most models (`"1280:720"`), pins `X-Runway-Version`, reports `usage: { type: "credits" }`, and its output URLs expire after 24–48 hours. -The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`, -`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and -`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was +The promise client exposes the same surface: `ai.experimental.video.start(...)` resolves to a handle with `await`, `events`, +`result`, `refresh`, `cancel`, and `token`; `ai.experimental.video.generate`, `ai.experimental.video.resume(model, token)`, and +`ai.experimental.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was created; `refresh()` resolves to a new handle. Every promise method and stream accepts `{ signal }`: like `fetch`, aborting rejects the Promise or throws from the `for await` loop with `signal.reason` (an `AbortError` `DOMException` unless `abort(reason)` passed one), while `break` stops a stream without throwing. @@ -991,13 +994,13 @@ unless `abort(reason)` passed one), while `break` stops a stream without throwin ```ts import { ai } from "@opencode/ai" -const generation = await ai.video.start({ model, prompt }) +const generation = await ai.experimental.video.start({ model, prompt }) for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type) const video = await generation.result({ signal }) await ai.write(video.video, "./kite.mp4") ``` -## Speech generation +### Speech generation Speech (text-to-speech) is one request whose response is parsed incrementally, so every route supports both `Speech.generate` (the whole file) and `Speech.stream` (audio chunks as they arrive). Models come from `.speech(...)` @@ -1006,7 +1009,8 @@ selectors on the `OpenAI`, `Google` (Gemini TTS), `ElevenLabs`, `Cartesia`, and before any network call; provider-native controls live under `providerOptions`, inferred from the selected model. ```ts -import { Media, Speech, SpeechClient, SpeechEvent } from "@opencode/ai/effect" +import { Media } from "@opencode/ai/effect" +import { Speech, SpeechEvent } from "@opencode/ai/experimental" import { ElevenLabs, OpenAI } from "@opencode/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }) @@ -1068,20 +1072,20 @@ Provider notes: and `providerOptions` lower to query parameters (`encoding`, `container`, `sample_rate`, `bit_rate`); `pcm` is `linear16` without a container. Auth is `Authorization: Token `. -The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterable`. +The promise client mirrors the Effect API; `ai.experimental.speech.stream` is an `AsyncIterable`. ```ts import { ai } from "@opencode/ai" -const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" }) +const response = await ai.experimental.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" }) await ai.write(response.audio, "hello.mp3") -for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) { +for await (const event of ai.experimental.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) { if (event.type === "audio-delta") player.write(event.chunk) } ``` -## Transcription +### Transcription Transcription (speech-to-text) is the one modality whose providers use every route kind: OpenAI and Gemini stream, Deepgram and ElevenLabs answer inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream` @@ -1093,7 +1097,8 @@ any network call; a route may return more than asked. ```ts import { Console, Effect, Stream } from "effect" -import { Media, Transcription, TranscriptionEvent } from "@opencode/ai/effect" +import { Media } from "@opencode/ai/effect" +import { Transcription, TranscriptionEvent } from "@opencode/ai/experimental" import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }) @@ -1146,33 +1151,43 @@ The promise client mirrors the Effect API: ```ts const audio = await ai.file("./call.mp3") -const text = (await ai.transcription.generate({ model, audio })).text -for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta) -const generation = await ai.transcription.start({ model: assemblyai, audio }) +const text = (await ai.experimental.transcription.generate({ model, audio })).text +for await (const event of ai.experimental.transcription.stream({ model, audio })) + if (event.type === "text-delta") write(event.delta) +const generation = await ai.experimental.transcription.start({ model: assemblyai, audio }) const transcript = await generation.await({ poll: { interval: 3_000 } }) ``` -## Public API +### Extraction -Everything below is exported from `@opencode/ai/effect` unless noted. +Extraction models read typed values out of text instead of answering questions with probabilities. A field of type +`text`, `number`, `date`, `time`, `datetime`, `duration`, `list`, `estimate` or `forecast` gets an answer typed to +match; other types are sent and answered as-is. Only Cloudflare's Clairic serves it today. -- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. -- **`LLM.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use. -- **`LLM.loop`** — stream model calls, running `Tool.make(...)` tools between them until the model stops calling tools. -- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. -- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. -- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. -- **`Image.request` / `generate` / `stream` / `start` / `resume`** — images over inline, streaming (partial previews), and queued routes through a provider-neutral request and response model. -- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. -- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests. -- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes. -- **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer. -- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer. -- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer. -- **`Decision.request` / `Decision.generate`** — typed predicate, choice, and score questions over text, JSON, and media input; `DecisionClient` is its Effect service and layer. -- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer. -- **`@opencode/ai`** (Promise root) — `AI.make({ layer?, clients? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, `transcription`, and `decision` as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets. -- **`@opencode/ai/experimental`** — APIs that may still change: `Extraction` (typed values read out of text) and `Realtime` (`Realtime.connect` starts an OpenAI, xAI, or Gemini voice session and returns a WebRTC answer SDP or a short-lived WebSocket URL and token). +```ts +import { ai } from "@opencode/ai" +import { Extraction } from "@opencode/ai/experimental" +import { CloudflareWorkersAI } from "@opencode/ai/providers" + +const response = await ai.run( + Extraction.generate({ + model: CloudflareWorkersAI.configure({ accountId }).experimental.extraction("@cf/cloudflare/clairic"), + input: "Invoice INV-2291, issued 2026-09-02, net 30. Total $1,080.00. Contact: Dana Ruiz, Priya Shah.", + fields: { + invoiceNumber: { type: "text" }, + dueDate: { type: "date", instructions: "Issue date plus the net payment days" }, + total: { type: "number", decimals: 2, unit: "USD" }, + contacts: { type: "list", items: "text" }, + }, + }), +) +response.answers.total.value // 1080, or null when the input does not say +``` + +Value answers carry `guess`, `nullProbability` and `confidence`; text answers carry a `span` into `input`. Estimates and +forecasts return `median`, `p10` and `p90`. Field options other than `type`, `instructions` and `nullThreshold` go to +Clairic as written. Effect users provide `RequestExecutor.fetchLayer` (or `AIClient.layer`). Images are not supported +yet. ## Testing @@ -1218,7 +1233,7 @@ For explicit compaction, script a `CompactionResponse` through `push`, `always`, For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls. -`TestImage`, `TestVideo`, `TestSpeech`, `TestTranscription`, and `TestDecision` follow the same pattern: `testLayer()` provides the modality's client and `Test`, with the same `push`, `always`, `serve`, `requests`, `wait`, and `gate` controls. Script an `ImageResponse` (or the modality's response) or an `AIError`. `stream` expands the response into its events, and `start` returns an already finished generation that `resume` accepts by token. Decision responses are returned as scripted, without checking the answers against the questions. +`TestImage` and `TestDecision` (and `TestVideo`, `TestSpeech`, and `TestTranscription` from `@opencode/ai/experimental/testing`) follow the same pattern: `testLayer()` provides the modality's client and `Test`, with the same `push`, `always`, `serve`, `requests`, `wait`, and `gate` controls. Script an `ImageResponse` (or the modality's response) or an `AIError`. `stream` expands the response into its events, and `start` returns an already finished generation that `resume` accepts by token. Decision responses are returned as scripted, without checking the answers against the questions. The Promise client accepts the same layers as `AI.make({ clients: TestImage.testLayer() })`; `ai.run(TestImage.Test.use(...))` reaches the controls. @@ -1234,25 +1249,25 @@ This is different from prompt caching, server-side history storage, or truncatio ### Explicit compaction -`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor. +`ai.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor. Prefer this operation, where supported, when the application owns compaction policy and durable context updates. ```ts const compacted = Effect.gen(function* () { - const result = yield* LLMClient.compact(request) + const result = yield* ai.compact(request) const next = LLMRequest.update(request, { messages: result.replacement, }) - return yield* LLMClient.generate(next) + return yield* ai.generate(next) }) ``` -From the Promise API, `await ai.llm.compact(request)` takes the same request and options (plus `signal`) and returns the same result. +From the Promise API, `await ai.compact(request)` takes the same request and options (plus `signal`) and returns the same result. `replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly. -The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation. +The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `ai.canCompact(request)` before calling `ai.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation. Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint. Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay. @@ -1264,7 +1279,7 @@ OpenAI Responses also exposes a separate, explicitly selected mechanism: ```ts const checkpoint = Effect.gen(function* () { - const result = yield* LLMClient.compact(request, { + const result = yield* ai.compact(request, { mechanism: "trigger", webSocket, // Optional: without it, the request uses HTTP/SSE. }) @@ -1287,8 +1302,8 @@ Trigger support is separate from endpoint support. Only the OpenAI Responses rou ```ts const narrowed = Effect.gen(function* () { - if (LLMClient.canCompact(request, { mechanism: "trigger" })) { - const result = yield* LLMClient.compact(request, { mechanism: "trigger" }) + if (ai.canCompact(request, { mechanism: "trigger" })) { + const result = yield* ai.compact(request, { mechanism: "trigger" }) } }) ``` @@ -1303,10 +1318,10 @@ Enable OpenAI compaction with typed provider options: ```ts import { Effect } from "effect" -import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai/effect" +import { ai, LLMClient, LLMRequest, Message } from "@opencode/ai/effect" import { OpenAI } from "@opencode/ai/providers" -const request = LLM.request({ +const request = ai.request({ model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"), messages, providerOptions: { @@ -1382,7 +1397,7 @@ Requests below a provider's minimum cacheable size simply do not produce a reusa ### Opting out ```ts -LLM.request({ +ai.request({ model, system, prompt: "one-off question", @@ -1408,7 +1423,7 @@ An object with placement fields places exactly those breakpoints. An object with `ttl` is a duration in models.dev's format (`` followed by `ms`, `s`, `m`, `h` or `d`). It is sent to the provider as given, so use one the provider accepts. Leaving it unset keeps the provider default. ```ts -LLM.request({ model, system, prompt, cache: { ttl: "1h" } }) +ai.request({ model, system, prompt, cache: { ttl: "1h" } }) ``` | Protocol | How `ttl` is sent | @@ -1426,7 +1441,7 @@ OpenAI's two fields cover different models: `prompt_cache_retention` (`"in_memor Inline `CacheHint` (`{ type: "ephemeral", ttl? }`) on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots. ```ts -LLM.request({ +ai.request({ model, system: [ { type: "text", text: "stable system prompt", cache: { type: "ephemeral" } }, @@ -1551,7 +1566,7 @@ Route/provider defaults are overridden by request-level values for each axis. The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral: ```ts -LLM.request({ +ai.request({ model, prompt, providerOptions: { diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts index 216903cf810d..6da24e57cd28 100644 --- a/packages/ai/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -1,7 +1,8 @@ import { Config, Effect, Formatter, Schema, Stream } from "effect" import { HttpClientResponse } from "effect/http" import { NodeFileSystem } from "@effect/platform-node" -import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai/effect" +import { ai, AIClient, LLMRequest, Media, Message, ProviderID, ToolRuntime } from "@opencode/ai/effect" +import { toDefinitions, tool } from "@opencode/ai/effect" import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route" import { OpenAI } from "@opencode/ai/providers" @@ -49,7 +50,7 @@ const model = openai.responses("gpt-4o-mini") // and query params. Prefer typed `providerOptions` when a field is stable. // // Route/provider options are defaults. Request options override them for this call. -const request = LLM.request({ +const request = ai.request({ model, system: "You are concise and practical.", prompt: "Tell me a joke", @@ -60,7 +61,7 @@ const request = LLM.request({ // 3. `generate` sends the request and collects the event stream into one // response object. `response.text` is the collected text output. const generateOnce = Effect.gen(function* () { - const response = yield* LLM.generate(request) + const response = yield* ai.generate(request) console.log("\n== generate ==") console.log("generated text:", response.text) @@ -69,7 +70,7 @@ const generateOnce = Effect.gen(function* () { // 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want // incremental text, reasoning, tool input, usage, or finish events. -const streamText = LLM.stream(request).pipe( +const streamText = ai.stream(request).pipe( Stream.tap((event) => Effect.sync(() => { if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) @@ -86,7 +87,7 @@ const streamText = LLM.stream(request).pipe( // advertise definitions on the request, stream one turn, dispatch local calls, // then persist/build follow-up history in the enclosing product flow. const tools = { - get_weather: Tool.make({ + get_weather: tool({ description: "Get current weather for a city.", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ forecast: Schema.String }), @@ -95,13 +96,13 @@ const tools = { } const streamWithTools = Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, prompt: "Use get_weather for San Francisco, then answer in one sentence.", generation: { maxTokens: 80, temperature: 0 }, - tools: Tool.toDefinitions(tools), + tools: toDefinitions(tools), }) - const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect)) + const events = Array.from(yield* ai.stream(request).pipe(Stream.runCollect)) for (const event of events) { if (event.type === "tool-call") console.log("tool call", event.name, event.input) if (event.type === "text-delta") process.stdout.write(event.text) @@ -132,7 +133,7 @@ const WeatherReport = Schema.Struct({ }) const generateStructuredObject = Effect.gen(function* () { - const response = yield* LLM.generateObject({ + const response = yield* ai.generateObject({ model, system: "Return only structured weather data.", prompt: "Give me today's weather for San Francisco.", @@ -146,7 +147,7 @@ const generateStructuredObject = Effect.gen(function* () { // If the shape is only known at runtime, pass raw JSON Schema instead. The // `.object` type is `unknown`; callers that need static types should validate it. -const generateDynamicObject = LLM.generateObject({ +const generateDynamicObject = ai.generateObject({ model, prompt: "Extract the city and forecast from: San Francisco is sunny.", jsonSchema: { @@ -230,7 +231,7 @@ const fakeExecutor = RequestExecutor.middleware((request) => ) const generateFake = Effect.gen(function* () { - const response = yield* LLM.generate({ model: FakeEcho.configure().model("echo-1"), prompt: "Say hello." }) + const response = yield* ai.generate({ model: FakeEcho.configure().model("echo-1"), prompt: "Say hello." }) console.log("\n== fake provider ==") console.log("generated text:", response.text) }) @@ -239,7 +240,7 @@ const generateFake = Effect.gen(function* () { // `response.image` is a `Media.Asset`: bytes decode lazily and are cached, and // `Media.write` persists them through the Effect `FileSystem`. const generateImage = Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openai.image("gpt-image-1-mini"), prompt: "A flat black circle centered on a plain white background.", aspect: "1:1", diff --git a/packages/ai/package.json b/packages/ai/package.json index 9bbb195c745d..c9354d71b25e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.2", + "version": "2.2.4", "name": "@opencode/ai", "type": "module", "license": "MIT", @@ -20,6 +20,7 @@ "./effect": "./src/effect.ts", "./testing": "./src/testing.ts", "./experimental": "./src/experimental.ts", + "./experimental/testing": "./src/experimental/testing.ts", "./route": "./src/route.ts", "./providers": "./src/providers.ts", "./providers/alibaba": "./src/providers/alibaba.ts", diff --git a/packages/ai/src/decision.ts b/packages/ai/src/decision.ts index 2d1df43d39d1..6a0daa9ff4b2 100644 --- a/packages/ai/src/decision.ts +++ b/packages/ai/src/decision.ts @@ -308,7 +308,6 @@ export function request(input: DecisionRequest | DecisionRequestInput) { export function generate( input: DecisionRequestInput, ): Effect.Effect, AIError, Service> -export function generate(input: DecisionRequest): Effect.Effect export function generate(input: DecisionRequest | DecisionRequestInput) { return tryRequest(() => request(input)).pipe(Effect.flatMap(DecisionClient.generate)) } diff --git a/packages/ai/src/effect.ts b/packages/ai/src/effect.ts index be732ba346a8..4210b101d28e 100644 --- a/packages/ai/src/effect.ts +++ b/packages/ai/src/effect.ts @@ -1,3 +1,29 @@ +import { Decision } from "./decision.js" +import { Image } from "./image.js" +import { LLM } from "./llm.js" +import { Speech } from "./speech.js" +import { Transcription } from "./transcription.js" +import { Video } from "./video.js" + +/** The Effect API: the same shape as `ai` from `@opencode/ai`, returning `Effect` and `Stream`. */ +export const ai = { + request: LLM.request, + generate: LLM.generate, + stream: LLM.stream, + loop: LLM.loop, + generateObject: LLM.generateObject, + compact: LLM.compact, + canCompact: LLM.canCompact, + image: Image, + decision: Decision, + /** Experimental: may change without compatibility guarantees. */ + experimental: { + speech: Speech, + transcription: Transcription, + video: Video, + }, +} as const + export { AIClient } from "./ai-client.js" export { LLMClient } from "./route/client.js" export { ImageClient } from "./image-client.js" @@ -115,64 +141,6 @@ export type { ImageResolution, ImageRoute, } from "./image.js" -export { Image } from "./image.js" -export { VideoClient } from "./video-client.js" -export { - VideoAspectRatio, - VideoEvent, - VideoFrames, - VideoModel, - VideoModelSchema, - VideoRequest, - VideoResponse, -} from "./video.js" -export type { - VideoModelOptions, - VideoOptions, - VideoRequestFor, - VideoRequestInput, - VideoResolution, - VideoRoute, -} from "./video.js" -export { Video } from "./video.js" -export { SpeechClient } from "./speech-client.js" -export { - SpeechEvent, - SpeechModel, - SpeechModelSchema, - SpeechRequest, - SpeechResponse, - SpeechTimestamp, - SpeechVoice, -} from "./speech.js" -export type { - SpeechFormat, - SpeechModelOptions, - SpeechOptions, - SpeechRequestFor, - SpeechRequestInput, - SpeechRoute, -} from "./speech.js" -export { Speech } from "./speech.js" -export { TranscriptionClient } from "./transcription-client.js" -export { - TranscriptionEvent, - TranscriptionModel, - TranscriptionModelSchema, - TranscriptionRequest, - TranscriptionResponse, - TranscriptionSegment, - TranscriptionTimestamps, - TranscriptionWord, -} from "./transcription.js" -export type { - TranscriptionModelOptions, - TranscriptionOptions, - TranscriptionRequestFor, - TranscriptionRequestInput, - TranscriptionRoute, -} from "./transcription.js" -export { Transcription } from "./transcription.js" export { DecisionClient } from "./decision-client.js" export { ChoiceAnswer, @@ -209,7 +177,6 @@ export type { DecisionResponseFor, DecisionModelRoute, } from "./decision.js" -export { Decision } from "./decision.js" export { DecisionRoute } from "./route/decision.js" export { Media } from "./media.js" export { Generation } from "./generation.js" @@ -221,18 +188,16 @@ export type { Snapshot as GenerationSnapshot, Status as GenerationStatus, } from "./generation.js" -export { Tool, ToolFailure, emptyInputJsonSchema, toDefinitions } from "./tool.js" +export { ToolFailure, emptyInputJsonSchema, toDefinitions, tool } from "./tool.js" export { ToolRuntime } from "./tool-runtime.js" export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime.js" export type { - AnyExecutableTool, - AnyTool, ExecutableTool, - ExecutableTools, - Definition as ToolShape, + Tool, ToolExecute, ToolExecuteContext, ToolModelOutputInput, + ToolOptions, Tools, ToolSchema, ToolToModelOutput, diff --git a/packages/ai/src/experimental.ts b/packages/ai/src/experimental.ts index 6c14f4de6e52..0eb9713697fe 100644 --- a/packages/ai/src/experimental.ts +++ b/packages/ai/src/experimental.ts @@ -27,6 +27,63 @@ export type { EvaluationResponseFor, EvaluationRoute, } from "./experimental/evaluation.js" +export { VideoClient } from "./video-client.js" +export { + VideoAspectRatio, + VideoEvent, + VideoFrames, + VideoModel, + VideoModelSchema, + VideoRequest, + VideoResponse, +} from "./video.js" +export type { + VideoModelOptions, + VideoOptions, + VideoRequestFor, + VideoRequestInput, + VideoResolution, + VideoRoute, +} from "./video.js" +export { Video } from "./video.js" +export { SpeechClient } from "./speech-client.js" +export { + SpeechEvent, + SpeechModel, + SpeechModelSchema, + SpeechRequest, + SpeechResponse, + SpeechTimestamp, + SpeechVoice, +} from "./speech.js" +export type { + SpeechFormat, + SpeechModelOptions, + SpeechOptions, + SpeechRequestFor, + SpeechRequestInput, + SpeechRoute, +} from "./speech.js" +export { Speech } from "./speech.js" +export { TranscriptionClient } from "./transcription-client.js" +export { + TranscriptionEvent, + TranscriptionModel, + TranscriptionModelSchema, + TranscriptionRequest, + TranscriptionResponse, + TranscriptionSegment, + TranscriptionTimestamps, + TranscriptionWord, +} from "./transcription.js" +export type { + TranscriptionModelOptions, + TranscriptionOptions, + TranscriptionRequestFor, + TranscriptionRequestInput, + TranscriptionRoute, +} from "./transcription.js" +export { Transcription } from "./transcription.js" export { Extraction, ExtractionModel, ExtractionRequest, ExtractionResponse } from "./experimental/extraction.js" export type { ExtractionAnswerFor, diff --git a/packages/ai/src/experimental/testing.ts b/packages/ai/src/experimental/testing.ts new file mode 100644 index 000000000000..44cbc9dfb30e --- /dev/null +++ b/packages/ai/src/experimental/testing.ts @@ -0,0 +1,3 @@ +export { TestSpeech } from "../testing/speech.js" +export { TestTranscription } from "../testing/transcription.js" +export { TestVideo } from "../testing/video.js" diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index b932fe60b1b0..b1a31ad026b2 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -3,13 +3,13 @@ import { AIClient } from "./ai-client.js" import { Decision, type DecisionModel, type DecisionQuestions, type DecisionRequestInput } from "./decision.js" import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js" import { Image, type ImageModel, type ImageRequest, type ImageRequestInput } from "./image.js" -import { LLM } from "./effect.js" +import { LLM } from "./llm.js" import { Media } from "./media.js" import { - LLMClient, type CheckpointRequest, type CompactionRequest, type EndpointCompactOptions, + type StreamOptions, type TriggerCompactOptions, } from "./route/client.js" import { RequestExecutor } from "./route/executor.js" @@ -35,7 +35,8 @@ import { type TranscriptionRequest, type TranscriptionRequestInput, } from "./transcription.js" -import { Tool, ToolFailure, type FreeformOption, type ToolExecuteContext, type ToolSchema } from "./tool.js" +import { ToolFailure, build } from "./tool.js" +import type { NoExecute, ToolExecuteContext, ToolOptions, ToolSchema } from "./tool.js" import { fileMediaType } from "./utils/media-type.js" import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } from "./video.js" @@ -62,6 +63,8 @@ export type Services = AIClient.Services * new handle rather than updating this one. */ export type GenerationHandle = Snapshot & { + readonly snapshot: Snapshot + readonly terminal: boolean /** Serializable JSON; pass it back to `resume` from another process. */ readonly token: unknown readonly await: (options?: AwaitOptions & RunOptions) => Promise @@ -96,7 +99,7 @@ export const make = (options: Options = {}) => { ), ) - /** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */ + /** Run any package Effect (for example `ai.compact(...)` from `@opencode/ai/effect`) inside this runtime. */ const run = (effect: Effect.Effect, options?: RunOptions) => runtime.runPromise(Effect.raceFirst(effect, abortEffect(options?.signal))) @@ -132,21 +135,23 @@ export const make = (options: Options = {}) => { options: TriggerCompactOptions & RunOptions, ): Promise function compact(request: CompactionRequest, options?: EndpointCompactOptions & RunOptions): Promise - // The overloads pair each request type with its mechanism; `LLMClient.compact` checks the route's capability at runtime. + // The overloads pair each request type with its mechanism; `LLM.compact` checks the route's capability at runtime. function compact( request: CheckpointRequest | CompactionRequest, options?: (TriggerCompactOptions | EndpointCompactOptions) & RunOptions, ) { return options?.mechanism === "trigger" - ? run(LLMClient.compact(request as CheckpointRequest, options), options) - : run(LLMClient.compact(request as CompactionRequest, options), options) + ? run(LLM.compact(request as CheckpointRequest, options), options) + : run(LLM.compact(request as CompactionRequest, options), options) } const handle = (generation: Generation): GenerationHandle => ({ ...generation.snapshot, + snapshot: generation.snapshot, + terminal: generation.terminal, token: generation.token, - await: (options) => run(generation.await({ poll: options?.poll }), options), - events: (options) => iterate(generation.events({ poll: options?.poll }), options), + await: (options) => run(generation.await(options), options), + events: (options) => iterate(generation.events(options), options), result: (options) => run(generation.result(), options), refresh: (options) => run(generation.refresh(), options).then(handle), cancel: (options) => run(generation.cancel(), options), @@ -190,78 +195,38 @@ export const make = (options: Options = {}) => { options, ) }, - llm: { - request: LLM.request, - generate: (input: RequestInput | LLMRequest, options?: RunOptions) => - run(LLM.generate(input), options), - stream: (input: RequestInput | LLMRequest, options?: RunOptions) => - iterate(LLM.stream(input), options), - /** Stream model calls, running their tool calls, until the model stops calling tools or `until` is true. */ - loop: (input: LoopInput, options?: RunOptions) => - iterate(LLM.loop(input), options), - /** Decode the model's output against `schema` (or `jsonSchema`, unvalidated) like `LLM.generateObject`. */ - generateObject, - /** Compact a Responses request like `LLMClient.compact`: a replacement window, or a checkpoint with `mechanism: "trigger"`. */ - compact, - }, + request: LLM.request, + generate: ( + input: RequestInput | LLMRequest, + options?: StreamOptions & RunOptions, + ) => run(LLM.generate(input, options), options), + stream: ( + input: RequestInput | LLMRequest, + options?: StreamOptions & RunOptions, + ) => iterate(LLM.stream(input, options), options), + /** Stream model calls, running their tool calls, until the model stops calling tools or `until` is true. */ + loop: (input: LoopInput, options?: StreamOptions & RunOptions) => + iterate(LLM.loop(input, options), options), + /** Decode the model's output against `schema` (or `jsonSchema`, unvalidated). */ + generateObject, + /** Compact a Responses request: a replacement window, or a checkpoint with `mechanism: "trigger"`. */ + compact, + canCompact: LLM.canCompact, image: { request: Image.request, generate: ( input: ImageRequestInput | ImageRequest, options?: AwaitOptions & RunOptions, - ) => run(Image.generate(input, { poll: options?.poll }), options), + ) => run(Image.generate(input, options), options), stream: ( input: ImageRequestInput | ImageRequest, options?: AwaitOptions & RunOptions, - ) => iterate(Image.stream(input, { poll: options?.poll }), options), + ) => iterate(Image.stream(input, options), options), start: (input: ImageRequestInput | ImageRequest, options?: RunOptions) => run(Image.start(input), options).then(handle), resume: (model: ImageModel, token: unknown, options?: RunOptions) => run(Image.resume(model, token), options).then(handle), }, - video: { - request: Video.request, - start: (input: VideoRequestInput | VideoRequest, options?: RunOptions) => - run(Video.start(input), options).then(handle), - generate: ( - input: VideoRequestInput | VideoRequest, - options?: AwaitOptions & RunOptions, - ) => run(Video.generate(input, { poll: options?.poll }), options), - resume: (model: VideoModel, token: unknown, options?: RunOptions) => - run(Video.resume(model, token), options).then(handle), - stream: ( - input: VideoRequestInput | VideoRequest, - options?: AwaitOptions & RunOptions, - ) => iterate(Video.stream(input, { poll: options?.poll }), options), - }, - speech: { - request: Speech.request, - generate: ( - input: SpeechRequestInput | SpeechRequest, - options?: RunOptions, - ) => run(Speech.generate(input), options), - stream: ( - input: SpeechRequestInput | SpeechRequest, - options?: RunOptions, - ) => iterate(Speech.stream(input), options), - }, - transcription: { - request: Transcription.request, - generate: ( - input: TranscriptionRequestInput | TranscriptionRequest, - options?: AwaitOptions & RunOptions, - ) => run(Transcription.generate(input, { poll: options?.poll }), options), - stream: ( - input: TranscriptionRequestInput | TranscriptionRequest, - options?: AwaitOptions & RunOptions, - ) => iterate(Transcription.stream(input, { poll: options?.poll }), options), - start: ( - input: TranscriptionRequestInput | TranscriptionRequest, - options?: RunOptions, - ) => run(Transcription.start(input), options).then(handle), - resume: (model: TranscriptionModel, token: unknown, options?: RunOptions) => - run(Transcription.resume(model, token), options).then(handle), - }, decision: { request: Decision.request, generate: ( @@ -269,6 +234,52 @@ export const make = (options: Options = {}) => { options?: RunOptions, ) => run(Decision.generate(input), options), }, + /** Experimental: may change without compatibility guarantees. */ + experimental: { + speech: { + request: Speech.request, + generate: ( + input: SpeechRequestInput | SpeechRequest, + options?: RunOptions, + ) => run(Speech.generate(input), options), + stream: ( + input: SpeechRequestInput | SpeechRequest, + options?: RunOptions, + ) => iterate(Speech.stream(input), options), + }, + transcription: { + request: Transcription.request, + generate: ( + input: TranscriptionRequestInput | TranscriptionRequest, + options?: AwaitOptions & RunOptions, + ) => run(Transcription.generate(input, { poll: options?.poll }), options), + stream: ( + input: TranscriptionRequestInput | TranscriptionRequest, + options?: AwaitOptions & RunOptions, + ) => iterate(Transcription.stream(input, { poll: options?.poll }), options), + start: ( + input: TranscriptionRequestInput | TranscriptionRequest, + options?: RunOptions, + ) => run(Transcription.start(input), options).then(handle), + resume: (model: TranscriptionModel, token: unknown, options?: RunOptions) => + run(Transcription.resume(model, token), options).then(handle), + }, + video: { + request: Video.request, + start: (input: VideoRequestInput | VideoRequest, options?: RunOptions) => + run(Video.start(input), options).then(handle), + generate: ( + input: VideoRequestInput | VideoRequest, + options?: AwaitOptions & RunOptions, + ) => run(Video.generate(input, { poll: options?.poll }), options), + resume: (model: VideoModel, token: unknown, options?: RunOptions) => + run(Video.resume(model, token), options).then(handle), + stream: ( + input: VideoRequestInput | VideoRequest, + options?: AwaitOptions & RunOptions, + ) => iterate(Video.stream(input, { poll: options?.poll }), options), + }, + }, dispose: () => runtime.dispose(), } } @@ -278,37 +289,201 @@ export type Client = ReturnType> const fileError = (message: string, cause: unknown) => new AIError({ reason: new InvalidRequestError({ message, cause }) }) -/** - * Promise counterpart of `Tool.make` for `ai.llm.loop`. A throw or rejection becomes the error result the model sees, the - * returned value is sent unvalidated (`undefined` as `null`), and `signal` aborts when the loop is interrupted. - */ -export const tool = >(config: { - readonly description: string - readonly parameters: Parameters - readonly freeform?: FreeformOption - readonly execute: ( - params: Schema.Schema.Type, - context: Partial & { readonly signal: AbortSignal }, - ) => unknown -}) => - Tool.make({ - description: config.description, - parameters: config.parameters, - success: Schema.Unknown, - freeform: config.freeform, - execute: (params, context) => - Effect.tryPromise({ - try: async (signal) => (await config.execute(params, { ...context, signal })) ?? null, - catch: (error) => new ToolFailure({ message: error instanceof Error ? error.message : String(error), error }), - }), - }) +/** `tool` from `@opencode/ai/effect`, with an `execute` that returns a value or a Promise and throws to fail. */ +export const tool = < + Parameters extends ToolSchema = typeof Schema.Unknown, + Success extends ToolSchema = typeof Schema.Unknown, + Result extends Schema.Schema.Type | PromiseLike> | NoExecute = NoExecute, +>( + config: ToolOptions< + Parameters, + Success, + (params: Schema.Schema.Type, context: Partial & { signal: AbortSignal }) => Result + >, +) => { + const execute = config.execute + return build( + config, + execute && + ((params, context) => + Effect.tryPromise({ + try: async (signal) => execute(params, { ...context, signal }), + catch: (error) => new ToolFailure({ message: error instanceof Error ? error.message : String(error), error }), + })), + ) +} -/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */ -export const ai = make() +/** Default client over `RequestExecutor.fetchLayer`; the runtime builds its layer on first use and holds nothing to dispose. */ +export const ai: Omit = make() -// Fork callers still import the Effect surface from the package root. The promise entry above uses these internally. -export { LLM, isRetryable } from "./effect.js" -export { AIError, LanguageModel, LLMEvent, Message, ToolDefinition } from "./schema/index.js" +// Fork callers (e.g. JevTool) still import ToolFailure from the package root. export { ToolFailure } export * as AI from "./index.js" +export { isContextOverflow, isContextOverflowFailure, isRetryable } from "./provider-error.js" +export { + ProtocolID, + RouteID, + ModelID, + ProviderID, + ResponseID, + ContentBlockID, + ToolCallID, + JsonSchema, + ProviderOptions, + ProviderMetadata, + HttpTimeout, + RequestEncoding, + HttpOptions, + GenerationOptions, + LanguageModelDefaults, + ReasoningEfforts, + ReasoningEffort, + LanguageModelCompatibility, + LanguageModel, + LanguageModelSchema, + CacheTTL, + CacheHint, + CachePolicyObject, + CachePolicy, + MessageRole, + SystemPart, + TextPart, + MediaPart, + ToolResultValue, + ToolOutput, + ToolCallPart, + ToolResultPart, + ReasoningPart, + CompactionPart, + EffortPart, + ContentPart, + Message, + Freeform, + ToolDefinition, + ToolNamespace, + ToolEntry, + ToolChoice, + LLMRequest, + FinishReason, + Usage, + MediaUsage, + CompactionResponse, + CompactionCheckpointResponse, + StepStart, + TextStart, + TextDelta, + TextEnd, + ReasoningStart, + ReasoningDelta, + ReasoningEnd, + ToolInputStart, + ToolInputDelta, + ToolInputEnd, + ToolInputError, + ToolCall, + ToolResult, + ToolError, + FinishReasonDetails, + StepFinish, + Finish, + MediaEvent, + ProviderErrorEvent, + LLMEvent, + LLMResponse, + HttpContext, + HttpRateLimitDetails, + InvalidRequestError, + UnsupportedOperationError, + ProviderConfigurationError, + NoRouteError, + AuthenticationError, + RateLimitError, + QuotaExceededError, + ContentPolicyError, + ProviderInternalError, + TransportType, + TransportOperation, + TransportError, + InvalidProviderOutputError, + UnknownProviderError, + TimeoutError, + AIErrorReason, + AIError, +} from "./schema/index.js" +export type { + GenerationOptionsFields, + GenerationOptionsInput, + LanguageModelInput, + LanguageModelProviderOptions, + ToolDefinitionInput, + ToolNamespaceInput, + ToolNamespaceEntryInput, + ToolEntryInput, + UsageInput, +} from "./schema/index.js" +export { ImageAspectRatio, ImageEvent, ImageModel, ImageModelSchema, ImageRequest, ImageResponse } from "./image.js" +export type { + ImageModelOptions, + ImageOptions, + ImageRequestFor, + ImageRequestInput, + ImageResolution, + ImageRoute, +} from "./image.js" +export { + ChoiceAnswer, + ChoiceQuestion, + ChoiceValue, + DecisionAnswer, + DecisionJsonPart, + DecisionMediaPart, + DecisionModel, + DecisionModelCompatibility, + DecisionModelSchema, + DecisionPart, + DecisionQuestion, + DecisionRequest, + DecisionResponse, + DecisionTextPart, + DecisionUsage, + ErrorAnswer, + ErrorReason, + PredicateAnswer, + PredicateQuestion, + ScoreAnswer, + ScoreQuestion, +} from "./decision.js" +export type { + AnswerFor, + AnswersFor, + DecisionInput, + DecisionModelOptions, + DecisionOptions, + DecisionQuestions, + DecisionRequestFor, + DecisionRequestInput, + DecisionResponseFor, + DecisionModelRoute, +} from "./decision.js" +export { Media } from "./media.js" +export type { + AwaitOptions as GenerationAwaitOptions, + Event as GenerationEvent, + Poll, + Route as GenerationRoute, + Snapshot as GenerationSnapshot, + Status as GenerationStatus, +} from "./generation.js" +export type { + ExecutableTool, + Tool, + ToolExecute, + ToolExecuteContext, + ToolModelOutputInput, + ToolOptions, + Tools, + ToolSchema, + ToolToModelOutput, +} from "./tool.js" +export type { LLM } from "./llm.js" diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 8bac475a4a92..46580ae7d129 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -17,8 +17,9 @@ import { type ContentPart, type LanguageModelProviderOptions, type ToolEntryInput, + withPartial, } from "./schema/index.js" -import { make as makeTool, toDefinitions, type ExecutableTools, type ToolSchema } from "./tool.js" +import { tool, toDefinitions, type ExecutableTool, type Tool, type ToolSchema } from "./tool.js" import { ToolRuntime } from "./tool-runtime.js" /** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ @@ -57,7 +58,7 @@ export type LoopInput, "tools" > & { - readonly tools: ExecutableTools + readonly tools: Record /** Called after each step that has tool calls; returning `true` ends the loop without running them. */ readonly until?: (state: { readonly steps: ReadonlyArray }) => boolean } @@ -74,18 +75,19 @@ export function loop(input: LoopInput, options?: StreamOptions) { const { tools, until, ...rest } = input const step = (request: LLMRequest, steps: ReadonlyArray): Stream.Stream => Stream.suspend(() => { - const events: LLMEvent[] = [] + let state = LLMResponse.empty() return LLMClient.stream(request, options).pipe( Stream.map((event) => event.type === "step-start" || event.type === "step-finish" ? { ...event, index: steps.length } : event, ), - Stream.tap((event) => Effect.sync(() => events.push(event))), + Stream.tap((event) => Effect.sync(() => (state = LLMResponse.reduce(state, event)))), + Stream.mapError((error) => withPartial(error, LLMResponse.partial(state))), Stream.concat( Stream.unwrap( Effect.gen(function* () { - const response = LLMResponse.fromEvents(events) + const response = LLMResponse.complete(state) const calls = response?.toolCalls.filter((call) => call.providerExecuted !== true) ?? [] - if (!response || calls.length === 0 || events.some(LLMEvent.is.providerError)) return Stream.empty + if (!response || calls.length === 0 || state.events.some(LLMEvent.is.providerError)) return Stream.empty const done = [...steps, response] if (until?.({ steps: done })) return Stream.empty const results = yield* Effect.forEach(calls, (call) => ToolRuntime.dispatch(tools, call), { @@ -185,10 +187,7 @@ export interface GenerateObjectDynamicOptions, -) { +const runGenerateObject = Effect.fn("LLM.generateObject")(function* (options: GenerateObjectBase, tool: Tool) { const baseRequest = request(options) const generateRequest = LLMRequest.update(baseRequest, { tools: toDefinitions({ [GENERATE_OBJECT_TOOL_NAME]: tool }), @@ -196,21 +195,22 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( }) const response = yield* LLMClient.generate(generateRequest) const call = response.toolCalls.find((event) => event.name === GENERATE_OBJECT_TOOL_NAME) - if (!call) - return yield* new AIError({ - reason: new InvalidProviderOutputError({ - message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`, - }), - }) + const invalidOutput = (reason: InvalidProviderOutputError) => new AIError({ reason, partial: response }) + if (!call) { + const message = + response.finishReason.normalized === "length" + ? "generateObject: output hit the token limit before the forced tool call completed" + : `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool` + return yield* invalidOutput(new InvalidProviderOutputError({ message })) + } const object = yield* tool._decode(call.input).pipe( - Effect.mapError( - (error) => - new AIError({ - reason: new InvalidProviderOutputError({ - message: `generateObject: tool input failed schema decode: ${error.message}`, - cause: error, - }), + Effect.mapError((error) => + invalidOutput( + new InvalidProviderOutputError({ + message: `generateObject: tool input failed schema decode: ${error.message}`, + cause: error, }), + ), ), ) return new GenerateObjectResponse(object, response) @@ -237,23 +237,12 @@ export function generateObject> | GenerateObjectDynamicOptions) { if ("schema" in options) { const { schema, ...rest } = options - return runGenerateObject( - rest, - makeTool({ - description: GENERATE_OBJECT_TOOL_DESCRIPTION, - parameters: schema, - success: Schema.Unknown as ToolSchema, - execute: () => Effect.void, - }), - ) + return runGenerateObject(rest, tool({ description: GENERATE_OBJECT_TOOL_DESCRIPTION, parameters: schema })) } const { jsonSchema, ...rest } = options - return runGenerateObject( - rest, - makeTool({ - description: GENERATE_OBJECT_TOOL_DESCRIPTION, - jsonSchema, - execute: () => Effect.void, - }), - ) + return runGenerateObject(rest, tool({ description: GENERATE_OBJECT_TOOL_DESCRIPTION, jsonSchema })) } + +export { canCompact, compact } from "./route/client.js" + +export * as LLM from "./llm.js" diff --git a/packages/ai/src/protocols/assemblyai-transcription.ts b/packages/ai/src/protocols/assemblyai-transcription.ts index 6446b04f4821..0287f31799d3 100644 --- a/packages/ai/src/protocols/assemblyai-transcription.ts +++ b/packages/ai/src/protocols/assemblyai-transcription.ts @@ -133,7 +133,7 @@ const decodeStart = Effect.fn("AssemblyAITranscription.decodeStart")(function* ( response: HttpClientResponse.HttpClientResponse, ) { const output = yield* decodeTranscript(response) - const status = yield* MediaProtocol.status(STATUS, output.value.status, output) + const status = MediaProtocol.status(STATUS, output.value.status) return { token: { transcriptID: output.value.id }, snapshot: { id: output.value.id, status } } }) @@ -142,7 +142,7 @@ const decodeStatus = Effect.fn("AssemblyAITranscription.decodeStatus")(function* context: MediaProtocol.PollContext, ) { const output = yield* decodeTranscript(response) - const status = yield* MediaProtocol.status(STATUS, output.value.status, output) + const status = MediaProtocol.status(STATUS, output.value.status) return { id: context.token.transcriptID, status } }) @@ -154,7 +154,7 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function* ) { const output = yield* decodeTranscript(response) const transcript = output.value - const status = yield* MediaProtocol.status(STATUS, transcript.status, output) + const status = MediaProtocol.status(STATUS, transcript.status) const error = transcript.error ?? undefined if (status === "failed") return yield* output.ended("failed", `${route.name} transcription failed${error === undefined ? "" : `: ${error}`}`) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 45f0caa279d6..01b88a34bd8c 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -52,7 +52,7 @@ export const OpenResponsesInputImage = Schema.Struct({ }) export const OpenResponsesInputFile = Schema.Struct({ type: Schema.tag("input_file"), - filename: Schema.String, + filename: Schema.optional(Schema.String), detail: Schema.optional(Schema.String), file_data: Schema.optional(Schema.String), file_url: Schema.optional(Schema.String), @@ -590,11 +590,12 @@ const lowerMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMReq const location = url ?? (yield* ProviderShared.requireInlineMedia(adapter.name, part.media)).dataUrl if (part.media.kind !== "image") { if (part.media.kind === "video") return { type: "input_video" as const, video_url: location } + if (url) return { type: "input_file" as const, file_url: url, detail } return { type: "input_file" as const, filename: part.filename ?? (mime === "application/pdf" ? "document.pdf" : "file"), detail, - ...(url ? { file_url: url } : { file_data: location }), + file_data: location, } } return { diff --git a/packages/ai/src/protocols/runway-video.ts b/packages/ai/src/protocols/runway-video.ts index 4bddd4ba1372..50cf94c4490a 100644 --- a/packages/ai/src/protocols/runway-video.ts +++ b/packages/ai/src/protocols/runway-video.ts @@ -121,7 +121,7 @@ const decodeStatus = Effect.fn("RunwayVideo.decodeStatus")(function* ( context: MediaProtocol.PollContext, ) { const output = yield* decodeTask(response) - const status = yield* MediaProtocol.status(STATUS, output.value.status, output) + const status = MediaProtocol.status(STATUS, output.value.status) return { id: context.token.taskID, status, progress: output.value.progress ?? undefined } }) @@ -131,7 +131,7 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* ( ) { const output = yield* decodeTask(response) const task = output.value - const status = yield* MediaProtocol.status(STATUS, task.status, output) + const status = MediaProtocol.status(STATUS, task.status) if (status === "failed") { const code = task.failureCode ?? undefined const message = `${route.name} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}` diff --git a/packages/ai/src/protocols/xai-responses.ts b/packages/ai/src/protocols/xai-responses.ts index d7e2ec8d79dc..01efb5a07055 100644 --- a/packages/ai/src/protocols/xai-responses.ts +++ b/packages/ai/src/protocols/xai-responses.ts @@ -62,8 +62,7 @@ const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LL operation: "in-band-compaction", provider: request.model.provider, route: request.model.route.id, - message: - "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported", + message: "xAI requires explicit compaction through ai.compact; automatic context management is not supported", }) const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {}) const body = yield* OpenResponses.fromRequestWithAdapter(request, adapter) diff --git a/packages/ai/src/protocols/xai-video.ts b/packages/ai/src/protocols/xai-video.ts index 3e1d53059e25..4017d853f24f 100644 --- a/packages/ai/src/protocols/xai-video.ts +++ b/packages/ai/src/protocols/xai-video.ts @@ -132,7 +132,7 @@ const decodeStatus = Effect.fn("XAIVideo.decodeStatus")(function* ( context: MediaProtocol.PollContext, ) { const output = yield* decodeVideoStatus(response) - const status = yield* MediaProtocol.status(STATUS, output.value.status, output) + const status = MediaProtocol.status(STATUS, output.value.status) return { id: context.token.requestID, status, progress: fraction(output.value.progress) } }) @@ -142,7 +142,7 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* ( ) { const output = yield* decodeVideoStatus(response) const decoded = output.value - const status = yield* MediaProtocol.status(STATUS, decoded.status, output) + const status = MediaProtocol.status(STATUS, decoded.status) if (status === "running") return yield* output.pending(context.token.requestID) if (status === "failed") { const code = decoded.error?.code ?? undefined diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 09c042a98e8b..5ca224653290 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -8,6 +8,7 @@ import type { ProviderPackage } from "../provider-package.js" import { SystemOne } from "../protocols/system-one.js" import { OpenAIImages, type OpenAIImageSizing } from "../protocols/openai-images.js" import { MediaRoute } from "../route/media.js" +import { HttpTransport } from "../route/transport/index.js" import { OpenAIChat } from "../protocols/openai-chat.js" import { OpenResponses } from "../protocols/open-responses.js" import { cacheControl } from "../protocols/utils/cache.js" @@ -74,6 +75,9 @@ export interface OpenRouterOptions { export type OpenRouterProviderOptionsInput = OpenRouterOptions +export type OpenRouterResponsesProviderOptionsInput = OpenResponsesProviderOptionsInput & + Pick + export interface OpenRouterDecisionOptions { readonly [key: string]: unknown readonly provider?: OpenRouterProviderRouting @@ -85,7 +89,7 @@ export interface OpenRouterDecisionOptions { export type Config = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string - readonly providerOptions?: OpenRouterProviderOptionsInput | OpenResponsesProviderOptionsInput + readonly providerOptions?: OpenRouterProviderOptionsInput | OpenRouterResponsesProviderOptionsInput } export type Settings = ProviderPackage.Settings & @@ -94,14 +98,18 @@ export type Settings = ProviderPackage readonly baseURL?: string } -const Options = Schema.Struct({ - usage: lenient(Schema.Union([Schema.Boolean, JsonObject])), +const SharedOptions = Schema.Struct({ models: lenient(Schema.Array(Schema.String)), provider: lenient(JsonObject), plugins: lenient(Schema.Array(JsonObject)), - web_search_options: lenient(JsonObject), debug: lenient(JsonObject), user: lenient(Schema.String), +}) + +const Options = Schema.Struct({ + ...SharedOptions.fields, + usage: lenient(Schema.Union([Schema.Boolean, JsonObject])), + web_search_options: lenient(JsonObject), reasoning: lenient(JsonObject), }) const decodeOptions = Schema.decodeUnknownSync(Options) @@ -166,12 +174,8 @@ const bodyOptions = (body: OpenAIChat.OpenAIChatBody, input: unknown, maxTokens: ...options, ...body, ...(usage !== undefined ? { usage } : {}), - ...(known.models !== undefined ? { models: known.models } : {}), - ...(known.provider !== undefined ? { provider: known.provider } : {}), - ...(known.plugins !== undefined ? { plugins: known.plugins } : {}), + ...sharedOptions(known), ...(known.web_search_options !== undefined ? { web_search_options: known.web_search_options } : {}), - ...(known.debug !== undefined ? { debug: known.debug } : {}), - ...(known.user !== undefined ? { user: known.user } : {}), ...(known.reasoning !== undefined ? { reasoning: fitReasoning(known.reasoning, maxTokens) } : {}), } } @@ -191,6 +195,32 @@ const imageSizing: OpenAIImageSizing = (request) => { } } +const sharedOptions = (known: typeof SharedOptions.Type) => ({ + ...(known.models !== undefined ? { models: known.models } : {}), + ...(known.provider !== undefined ? { provider: known.provider } : {}), + ...(known.plugins !== undefined ? { plugins: known.plugins } : {}), + ...(known.debug !== undefined ? { debug: known.debug } : {}), + ...(known.user !== undefined ? { user: known.user } : {}), +}) + +const OpenRouterResponsesBody = Schema.Struct({ + ...OpenResponses.OpenResponsesBody.fields, + ...SharedOptions.fields, +}) +type OpenRouterResponsesBody = Schema.Schema.Type + +const responsesProtocol = Protocol.make({ + id: "openrouter-responses", + body: { + schema: OpenRouterResponsesBody, + from: (request) => + OpenResponses.fromRequest(request).pipe( + Effect.map((body) => ({ ...body, ...sharedOptions(decodeOptions(request.providerOptions ?? {})) })), + ), + }, + stream: OpenResponses.protocol.stream, +}) + export const route = Route.make({ id: ADAPTER, provider: id, @@ -204,9 +234,9 @@ const responsesRoute = Route.make({ id: "openrouter-responses", provider: id, providerMetadataKey: "openrouter", - protocol: OpenResponses.protocol, + protocol: responsesProtocol, endpoint: Endpoint.path("/responses", { baseURL }), - transport: OpenResponses.httpTransport, + transport: HttpTransport.sseJson.with(), defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } }, }) @@ -247,7 +277,7 @@ export const configure = (input: Config = {}) => { model: (modelID: string | ModelID) => chatRoute.model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }), responses: (modelID: string | ModelID) => - configuredResponses.model({ id: modelID }), + configuredResponses.model({ id: modelID }), image, decision, configure, @@ -261,8 +291,8 @@ export const responses = provider.responses export const model: ProviderPackage.Model = (modelID, settings) => configure(config(settings)).model(modelID) export const responsesModel: ProviderPackage.Model< - Settings, - OpenResponsesProviderOptionsInput + Settings, + OpenRouterResponsesProviderOptionsInput > = (modelID, settings) => configure(config(settings)).responses(modelID) export const imageModel = (modelID: string, settings?: Settings) => { const input = config(settings) diff --git a/packages/ai/src/providers/openrouter/responses.ts b/packages/ai/src/providers/openrouter/responses.ts index 34a9c7f2e13b..efabaea27ded 100644 --- a/packages/ai/src/providers/openrouter/responses.ts +++ b/packages/ai/src/providers/openrouter/responses.ts @@ -1,4 +1,3 @@ -import type { OpenResponsesProviderOptionsInput } from "../open-responses-options.js" import type { OpenRouter } from "../openrouter.js" export { responsesModel as model } from "../openrouter.js" -export type Settings = OpenRouter.Settings +export type Settings = OpenRouter.Settings diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 6b763f9d77cb..74ac738cc61b 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -30,6 +30,7 @@ import { mergeGenerationOptions, mergeHttpOptions, mergeProviderOptions, + withPartial, } from "../schema/index.js" export interface RouteBody { @@ -405,9 +406,7 @@ function makeFromTransport( provider: provider ?? routeInput.provider, providerMetadataKey: providerMetadataKey ?? - (provider !== undefined && provider !== routeInput.provider - ? provider - : routeInput.providerMetadataKey), + (provider !== undefined && provider !== routeInput.provider ? provider : routeInput.providerMetadataKey), auth: auth ?? routeInput.auth, endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint, transport: (transport as Transport | undefined) ?? routeInput.transport, @@ -629,12 +628,19 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o }), ) -const generateWith = (stream: Interface["stream"]) => +export const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) { - const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) + let state = LLMResponse.empty() + yield* stream(request, options).pipe( + Stream.runForEach((event) => Effect.sync(() => (state = LLMResponse.reduce(state, event)))), + Effect.mapError((error) => withPartial(error, LLMResponse.partial(state))), + ) const response = LLMResponse.complete(state) if (response) return response - return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`) + return yield* withPartial( + incompleteStreamError(`${request.model.provider}/${request.model.route.id}`), + LLMResponse.partial(state), + ) }) export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream { @@ -702,8 +708,6 @@ export const layer: Layer.Layer = Layer export const Route = { make } as const export const LLMClient = { - canCompact, - compact, Service, layer, stream, diff --git a/packages/ai/src/route/media-protocol.ts b/packages/ai/src/route/media-protocol.ts index ea73cdb79923..64dd034c692f 100644 --- a/packages/ai/src/route/media-protocol.ts +++ b/packages/ai/src/route/media-protocol.ts @@ -309,17 +309,9 @@ export const identity = (input: { readonly id: string; readonly name: string; re export type Identity = ReturnType -export type Output = Effect.Success> - -/** Map a provider status string through the protocol's table; unknown values are an invalid provider document. */ -export const status = >( - table: Table, - raw: string, - output: Output, -): Effect.Effect => { - if (!Object.hasOwn(table, raw)) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`)) - return Effect.succeed(table[raw]) -} +/** Map a provider status string through the protocol's table; unknown values keep the generation running. */ +export const status = (table: Readonly>, raw: string): Status => + Object.hasOwn(table, raw) ? table[raw] : "running" /** Map a provider error code through the protocol's table; missing or unmapped codes are `ProviderInternal`. */ export const failure = (table: Readonly>, code: string | number | undefined): Failure => diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 4c6ab9d1c445..9c0e07a3f2f2 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -2,6 +2,7 @@ import { Schema } from "effect" import { NonNegativeInt } from "@opencode/schema" import { Tool } from "@opencode/schema/tool" import { ModelID, ProviderID, RouteID } from "./ids.js" +import type { LLMResponse } from "./events.js" export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"]) export type ProviderFailureClassification = typeof ProviderFailureClassification.Type @@ -163,12 +164,23 @@ export class AIError extends Schema.TaggedError()("AI.Error", { reason: AIErrorReason, }) { override readonly cause = this.reason + /** Output that arrived before `generate`, `generateObject` or a `loop` step failed. Not in the schema, so encoding omits it. */ + readonly partial?: LLMResponse + + constructor(props: Pick) { + super({ reason: props.reason }) + this.partial = props.partial + } override get message(): string { return this.reason.message } } +/** Return `error` with `partial` attached, or unchanged when there is no partial output. */ +export const withPartial = (error: AIError, partial: LLMResponse | undefined) => + partial === undefined ? error : new AIError({ reason: error.reason, partial }) + /** * Failure type for tool execute handlers. Handlers must map their internal * errors to this shape; the runtime catches `ToolFailure`s and surfaces them diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 6d15eb6dee73..24e1b81a2a54 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -756,6 +756,12 @@ export namespace LLMResponse { /** Convenience reducer for callers that already have a collected event list. */ export const fromEvents = (events: ReadonlyArray) => complete(events.reduce(reduce, empty())) + /** Output that arrived before a failure, or `undefined` if only `step-start` did; an unfinished response reports `error`. */ + export const partial = (state: State) => + state.events.every(LLMEvent.is.stepStart) + ? undefined + : complete({ ...state, finishReason: state.finishReason ?? { normalized: "error" } }) + /** Concatenate assistant text from a response or collected event list. */ export const text = (response: Output) => responseText(response.events) diff --git a/packages/ai/src/testing.ts b/packages/ai/src/testing.ts index ffa6d6d8ac18..ef8a5ce97b32 100644 --- a/packages/ai/src/testing.ts +++ b/packages/ai/src/testing.ts @@ -2,12 +2,10 @@ export * as TestLLM from "./testing.js" export { compileRequest } from "./route/client.js" export { TestDecision } from "./testing/decision.js" export { TestImage } from "./testing/image.js" -export { TestSpeech } from "./testing/speech.js" -export { TestTranscription } from "./testing/transcription.js" -export { TestVideo } from "./testing/video.js" import { LLMClient, + generateWith, type CompactionRequest, type CheckpointRequest, type EndpointCompactOptions, @@ -15,7 +13,6 @@ import { } from "./route/client.js" import { LLMEvent, - LLMResponse, CompactionResponse, CompactionCheckpointResponse, type FinishReasonDetails, @@ -83,7 +80,7 @@ export const textWithUsage = (value: string, id: string, inputTokens: number) => ...textEvents(value, id), ) -export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input })) +export const toolCall = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input })) export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) => Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error))) @@ -131,15 +128,7 @@ const make = (options: LayerOptions) => ...script.controls, compact, 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") - }), - ), + generate: generateWith(stream), }) }) diff --git a/packages/ai/src/tool-runtime.ts b/packages/ai/src/tool-runtime.ts index d725b1e19a7d..13104c38d18e 100644 --- a/packages/ai/src/tool-runtime.ts +++ b/packages/ai/src/tool-runtime.ts @@ -8,7 +8,7 @@ import { type ToolOutput as ToolOutputType, type ToolResultValue as ToolResultValueType, } from "./schema/index.js" -import { type AnyTool, type Tools } from "./tool.js" +import { type Tool, type Tools } from "./tool.js" export interface ToolSettlement { readonly result: ToolResultValueType @@ -51,7 +51,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect => +const decodeAndExecute = (tool: Tool, call: ToolCallPart): Effect.Effect => tool._decode(call.input).pipe( Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), Effect.flatMap((decoded) => diff --git a/packages/ai/src/tool.ts b/packages/ai/src/tool.ts index 9fd9da30f25c..22e71ac65b35 100644 --- a/packages/ai/src/tool.ts +++ b/packages/ai/src/tool.ts @@ -1,5 +1,5 @@ import { Effect, JsonSchema, Schema } from "effect" -import { Tool } from "@opencode/schema/tool" +import type { Content } from "@opencode/schema/tool" import type { Freeform, ToolCallPart, @@ -34,7 +34,7 @@ export interface ToolModelOutputInput { export type ToolToModelOutput, Success extends ToolSchema> = ( input: ToolModelOutputInput, Success["Encoded"]>, -) => ReadonlyArray +) => ReadonlyArray type FreeformField = { [K in keyof T]-?: [Exclude] extends [never] @@ -46,12 +46,13 @@ type FreeformField = { : never }[keyof T] -/** `Freeform` when the parameters are exactly one required string field, otherwise `undefined`. */ -export type FreeformOption> = [ - FreeformField>, -] extends [never] - ? undefined - : Freeform +/** `Freeform` for exactly one required string parameter or unknown ones (`jsonSchema`), otherwise `undefined`. */ +export type FreeformOption> = + unknown extends Schema.Schema.Type + ? Freeform + : [FreeformField>] extends [never] + ? undefined + : Freeform /** * A type-safe LLM tool. Each tool bundles its own description, parameter @@ -65,7 +66,7 @@ export type FreeformOption> = [ * Internally each tool also carries memoized codecs and a precomputed * `ToolDefinition` so callers do not rebuild them per invocation. */ -export interface Definition, Success extends ToolSchema> { +export interface Tool = any, Success extends ToolSchema = any> { readonly description: string readonly parameters: Parameters readonly success: Success @@ -74,133 +75,102 @@ export interface Definition, Success extends readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown /** @internal */ readonly _decode: (input: unknown) => Effect.Effect, Schema.SchemaError> + // Method syntax keeps these parameters bivariant, so a concrete tool stays assignable to `ExecutableTool`. /** @internal */ - readonly _encode: (value: Schema.Schema.Type) => Effect.Effect + _encode(value: Schema.Schema.Type): Effect.Effect /** @internal */ - readonly _project: ( - parameters: Schema.Schema.Type, - id: ToolCallPart["id"], - output: unknown, - ) => ToolOutputType + _project(parameters: Schema.Schema.Type, id: ToolCallPart["id"], output: unknown): ToolOutputType /** @internal */ readonly _legacyResult: boolean /** @internal */ readonly _definition: ToolDefinitionClass } -export type AnyTool = Definition - -export type ExecutableTool, Success extends ToolSchema> = Definition< +/** A tool with an `execute` handler, which `LLM.loop` and `ToolRuntime.dispatch` can run. */ +export type ExecutableTool = any, Success extends ToolSchema = any> = Tool< Parameters, Success > & { readonly execute: ToolExecute } -export type AnyExecutableTool = ExecutableTool - -export type ExecutableTools = Record - -type TypedToolConfig = { - readonly description: string - readonly parameters: ToolSchema - readonly success: ToolSchema - readonly freeform?: Freeform - readonly execute?: ToolExecute, ToolSchema> - readonly toModelOutput?: ToolToModelOutput, ToolSchema> - readonly toStructuredOutput?: (output: unknown) => unknown -} - -type DynamicToolConfig = { - readonly description: string - readonly jsonSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema - readonly freeform?: Freeform - readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect - readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray - readonly toStructuredOutput?: (output: unknown) => unknown -} - /** - * Constructs a tool. Two input modes: - * - * 1. **Typed** — pass Effect `parameters` and `success` Schemas; inputs and - * outputs are statically typed and decoded/encoded automatically. - * - * ```ts - * Tool.make({ - * description: "Get current weather", - * parameters: Schema.Struct({ city: Schema.String }), - * success: Schema.Struct({ temperature: Schema.Number }), - * execute: ({ city }) => Effect.succeed({ temperature: 22 }), - * }) - * ``` - * - * 2. **Dynamic** — pass raw JSON Schema as `jsonSchema`. Use this when the - * schema comes from an external source (MCP server, plugin manifest, - * dynamic config) and is not known at compile time. Inputs are typed as - * `unknown`; the handler is responsible for any validation it needs. - * - * ```ts - * Tool.make({ - * description: "Look something up", - * jsonSchema: { type: "object", properties: { ... } }, - * execute: (params) => Effect.succeed(...), - * }) - * ``` - * - * In both modes the produced tool flows through `toDefinitions(...)` - * identically. + * Options for `tool`, where `Execute` is the only part that differs between the Effect and Promise clients. Pass + * Effect `parameters` (and optionally a `success` schema that validates the result, otherwise it is sent as is with + * `undefined` as `null`), or raw `jsonSchema` known only at runtime, such as an MCP tool's. Omit `execute` to declare + * a tool the model can call without running it here. */ -export function make, Success extends ToolSchema>(config: { +export type ToolOptions< + Parameters extends ToolSchema, + Success extends ToolSchema, + Execute = ToolExecute, +> = { readonly description: string - readonly parameters: Parameters - readonly success: Success readonly freeform?: FreeformOption - readonly execute: ToolExecute + readonly execute?: Execute readonly toModelOutput?: ToolToModelOutput readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown -}): ExecutableTool -export function make, Success extends ToolSchema>(config: { - readonly description: string - readonly parameters: Parameters - readonly success: Success - readonly freeform?: FreeformOption - readonly execute?: undefined - readonly toModelOutput?: ToolToModelOutput - readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown -}): Definition -export function make(config: { - readonly description: string - readonly jsonSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema - readonly freeform?: Freeform - readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect - readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray - readonly toStructuredOutput?: (output: unknown) => unknown -}): AnyExecutableTool -export function make(config: { - readonly description: string - readonly jsonSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema - readonly freeform?: Freeform - readonly execute?: undefined - readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray - readonly toStructuredOutput?: (output: unknown) => unknown -}): AnyTool -export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { - if ("jsonSchema" in config) { +} & ( + | { readonly parameters: Parameters; readonly success?: Success } + | { readonly jsonSchema: JsonSchema.JsonSchema; readonly outputSchema?: JsonSchema.JsonSchema } +) + +/** + * Constructs a tool whose `execute` returns an Effect that fails with `ToolFailure`. + * + * ```ts + * tool({ + * description: "Get current weather", + * parameters: Schema.Struct({ city: Schema.String }), + * success: Schema.Struct({ temperature: Schema.Number }), + * execute: ({ city }) => Effect.succeed({ temperature: 22 }), + * }) + * ``` + */ +export const tool = < + Parameters extends ToolSchema = typeof Schema.Unknown, + Success extends ToolSchema = typeof Schema.Unknown, + Result extends Effect.Effect, ToolFailure> | NoExecute = NoExecute, +>( + config: ToolOptions< + Parameters, + Success, + (params: Schema.Schema.Type, context?: ToolExecuteContext) => Result + >, +) => build(config, config.execute) + +declare const noExecute: unique symbol +/** @internal The inferred `execute` result when `execute` is omitted; no function can return it. */ +export type NoExecute = { readonly [noExecute]: never } + +/** @internal Builds a tool for both clients from its options and an Effect `execute`. */ +export function build, Success extends ToolSchema, Result>( + config: ToolOptions Result>, + execute: ((params: Schema.Schema.Type, context?: ToolExecuteContext) => unknown) | undefined, +): 0 extends 1 & Result // `any` + ? ExecutableTool + : [Result, NoExecute] extends [NoExecute, Result] + ? Tool + : ExecutableTool +export function build( + config: ToolOptions, ToolSchema, unknown>, + execute: ((params: any, context?: ToolExecuteContext) => any) | undefined, +): Tool { + const shared = { + description: config.description, + execute, + toModelOutput: config.toModelOutput, + toStructuredOutput: config.toStructuredOutput, + _project: (parameters: unknown, id: ToolCallPart["id"], output: unknown) => + project(config.toModelOutput, config.toStructuredOutput, parameters, id, output), + } + if ("jsonSchema" in config) return { - description: config.description, - parameters: Schema.Unknown as ToolSchema, - success: Schema.Unknown as ToolSchema, - execute: config.execute, - toModelOutput: config.toModelOutput, - toStructuredOutput: config.toStructuredOutput, + ...shared, + parameters: Schema.Unknown, + success: Schema.Unknown, _decode: Effect.succeed, _encode: Effect.succeed, - _project: (parameters, id, output) => - project(config.toModelOutput, config.toStructuredOutput, parameters, id, output), _legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined, _definition: definition({ name: "", @@ -210,24 +180,18 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { freeform: config.freeform, }), } - } return { - description: config.description, + ...shared, parameters: config.parameters, - success: config.success, - execute: config.execute, - toModelOutput: config.toModelOutput, - toStructuredOutput: config.toStructuredOutput, + success: config.success ?? Schema.Unknown, _decode: Schema.decodeUnknownEffect(config.parameters), - _encode: Schema.encodeEffect(config.success), - _project: (parameters, id, output) => - project(config.toModelOutput, config.toStructuredOutput, parameters, id, output), + _encode: config.success ? Schema.encodeEffect(config.success) : (value) => Effect.succeed(value ?? null), _legacyResult: false, _definition: definition({ name: "", description: config.description, inputSchema: emptyInputJsonSchema(config.parameters) ?? toJsonSchema(config.parameters), - outputSchema: toJsonSchema(config.success), + outputSchema: config.success && toJsonSchema(config.success), freeform: config.freeform, }), } @@ -236,7 +200,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { /** * A record of named tools. The record key becomes the tool name on the wire. */ -export type Tools = Record +export type Tools = Record /** * Convert a tools record into the `ToolDefinition[]` shape that @@ -289,7 +253,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { } const project = ( - toModelOutput: ((input: ToolModelOutputInput) => ReadonlyArray) | undefined, + toModelOutput: ((input: ToolModelOutputInput) => ReadonlyArray) | undefined, toStructuredOutput: ((output: unknown) => unknown) | undefined, parameters: unknown, id: ToolCallPart["id"], @@ -301,5 +265,3 @@ const project = ( ) export { ToolFailure } - -export * as Tool from "./tool.js" diff --git a/packages/ai/test/adapter.test.ts b/packages/ai/test/adapter.test.ts index d3e30ae13a34..f0c59211f12f 100644 --- a/packages/ai/test/adapter.test.ts +++ b/packages/ai/test/adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMRequest, LLMResponse } from "../src/effect.js" +import { ai, LLMRequest, LLMResponse } from "../src/effect.js" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js" import { compileRequest } from "../src/route/client.js" import { LanguageModel } from "../src/schema/index.js" @@ -84,7 +84,7 @@ const gemini = Route.make({ }) const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local" } }) -const request = LLM.request({ +const request = ai.request({ id: "req_1", model: LanguageModel.make({ id: "fake-model", diff --git a/packages/ai/test/auth.test.ts b/packages/ai/test/auth.test.ts index afde4642b651..2d17e5de773a 100644 --- a/packages/ai/test/auth.test.ts +++ b/packages/ai/test/auth.test.ts @@ -1,13 +1,13 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { LLM } from "../src/effect.js" +import { ai } from "../src/effect.js" import { Auth } from "../src/route/auth.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import { LanguageModel } from "../src/schema/index.js" import { it } from "./lib/effect.js" -const request = LLM.request({ +const request = ai.request({ id: "req_auth", model: LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }), prompt: "hello", diff --git a/packages/ai/test/cache-policy.test.ts b/packages/ai/test/cache-policy.test.ts index 3ae8e8c5b34a..1c35c5827119 100644 --- a/packages/ai/test/cache-policy.test.ts +++ b/packages/ai/test/cache-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { CacheHint, CompactionPart, LLM, Message, ProviderID, ToolCallPart } from "../src/effect.js" +import { ai, CacheHint, CompactionPart, Message, ProviderID, ToolCallPart } from "../src/effect.js" import { Auth } from "../src/route.js" import { compileRequest } from "../src/route/client.js" import { @@ -44,7 +44,7 @@ describe("applyCachePolicy", () => { it.effect("undefined cache resolves to 'auto' (the recommended default)", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, system: "You are concise.", prompt: "hi", @@ -63,7 +63,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, system: [ { type: "text", text: "Base agent" }, @@ -100,7 +100,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' emits Anthropic cache markers on Vertex", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model( "claude-opus-4-8", ), @@ -121,7 +121,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' emits cache_control markers on DigitalOcean", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: DigitalOcean.configure({ apiKey: "test" }).model("anthropic-claude-fable-5.1"), system: "You are concise.", tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }], @@ -148,7 +148,7 @@ describe("applyCachePolicy", () => { it.effect("Alibaba chat caches the system and conversation tail without marking tools", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).chat("qwen3.8-max"), system: "You are concise.", tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }], @@ -173,7 +173,7 @@ describe("applyCachePolicy", () => { it.effect("Alibaba chat omits automatic cache markers when cache is none", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).chat("qwen3.8-max"), system: "You are concise.", prompt: "hi", @@ -190,7 +190,7 @@ describe("applyCachePolicy", () => { const alibaba = Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }) for (const modelID of ["kimi-k3", "glm-5.2", "deepseek-v4-flash-0731", "MiniMax-M2.5"]) { const prepared = yield* compileRequest( - LLM.request({ model: alibaba.chat(modelID), system: "You are concise.", prompt: "hi" }), + ai.request({ model: alibaba.chat(modelID), system: "You are concise.", prompt: "hi" }), ) expect(JSON.stringify(prepared.body)).not.toContain("cache_control") @@ -201,7 +201,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AnthropicCompatible.configure({ apiKey: "test", baseURL: "https://messages.example.test/v1" }).model( "compatible", ), @@ -235,7 +235,7 @@ describe("applyCachePolicy", () => { messagesModels.forEach(([route, model]) => it.effect(`'auto' emits cache markers on ${route}`, () => Effect.gen(function* () { - const prepared = yield* compileRequest(LLM.request({ model, system: "Sys", prompt: "hi" })) + const prepared = yield* compileRequest(ai.request({ model, system: "Sys", prompt: "hi" })) expect(prepared.route).toBe(route) expect(prepared.body).toMatchObject({ @@ -249,7 +249,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: openaiModel, system: "Sys", prompt: "hi", @@ -268,7 +268,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: geminiModel, system: "Sys", prompt: "hi", @@ -285,7 +285,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: bedrockModel, system: [ { type: "text", text: "Base agent" }, @@ -319,7 +319,7 @@ describe("applyCachePolicy", () => { it.effect("'none' disables auto placement even when manual hints exist", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, system: "Sys", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], @@ -338,7 +338,7 @@ describe("applyCachePolicy", () => { it.effect("granular object form: tools-only marks just tools", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, system: "Sys", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], @@ -364,7 +364,7 @@ describe("applyCachePolicy", () => { cache: manual, }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, tools: [ duplicate("first"), @@ -386,7 +386,7 @@ describe("applyCachePolicy", () => { it.effect("auto policy preserves manual CacheHints on other parts", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, system: [ { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttl: "1h" }) }, @@ -409,7 +409,7 @@ describe("applyCachePolicy", () => { it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: anthropicModel, system: [ { type: "text", text: "Base agent" }, @@ -450,7 +450,7 @@ describe("applyCachePolicy", () => { ) test("marks the final leaf inside a tool namespace", () => { - const request = LLM.request({ + const request = ai.request({ model: anthropicModel, tools: [ { @@ -476,7 +476,7 @@ describe("applyCachePolicy", () => { it.effect("a ttl-only policy keeps automatic placement", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: anthropicModel, system: "Sys", prompt: "hi", cache: { ttl: "1h" } }), + ai.request({ model: anthropicModel, system: "Sys", prompt: "hi", cache: { ttl: "1h" } }), ) expect(prepared.body).toMatchObject({ @@ -489,7 +489,7 @@ describe("applyCachePolicy", () => { it.effect("ttl in the policy flows through to wire markers", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, system: "Sys", prompt: "hi", @@ -506,7 +506,7 @@ describe("applyCachePolicy", () => { it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], cache: { messages: { tail: 2 } }, @@ -524,7 +524,7 @@ describe("applyCachePolicy", () => { it.effect("'latest-assistant' marks the last assistant message", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropicModel, messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], cache: { messages: "latest-assistant" }, @@ -542,7 +542,7 @@ describe("applyCachePolicy", () => { const compaction = CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }) const toolCall = ToolCallPart.make({ id: "tc_1", name: "lookup", input: {} }) - const mixed = LLM.request({ + const mixed = ai.request({ model: anthropicModel, messages: [Message.assistant([toolCall, compaction])], cache: { messages: "latest-assistant" }, @@ -555,7 +555,7 @@ describe("applyCachePolicy", () => { }) expect(appliedMixed.messages[0]?.content[1]).toEqual(compaction) - const compactionOnly = LLM.request({ + const compactionOnly = ai.request({ model: anthropicModel, messages: [Message.assistant([compaction])], cache: { messages: "latest-assistant" }, @@ -564,7 +564,7 @@ describe("applyCachePolicy", () => { }) test("returns the same request reference when policy is a no-op (pure function)", () => { - const request = LLM.request({ + const request = ai.request({ model: anthropicModel, prompt: "hi", cache: "none", diff --git a/packages/ai/test/compaction-http.test.ts b/packages/ai/test/compaction-http.test.ts index 0d44549d3f0e..ec133c92f67d 100644 --- a/packages/ai/test/compaction-http.test.ts +++ b/packages/ai/test/compaction-http.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { Effect } from "effect" import { FetchHttpClient } from "effect/http" -import { LLM, LLMRequest, Message } from "../src/effect.js" +import { ai, LLMRequest, Message } from "../src/effect.js" import { LLMClient } from "../src/route/client.js" import { OpenAI } from "../src/providers.js" import { testEffect } from "./lib/effect.js" @@ -72,12 +72,12 @@ testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses( "fixture", ) - const request = LLM.request({ + const request = ai.request({ model, prompt: "original", tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }], }) - const compacted = yield* LLMClient.compact(request) + const compacted = yield* ai.compact(request) const messages = [...compacted.replacement, Message.user("Look up the answer")] const first = yield* LLMClient.generate(LLMRequest.update(request, { messages })) expect(first.toolCalls).toHaveLength(1) diff --git a/packages/ai/test/compaction.test.ts b/packages/ai/test/compaction.test.ts index a2c0a943dc1e..f0f6e0043934 100644 --- a/packages/ai/test/compaction.test.ts +++ b/packages/ai/test/compaction.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" import { Effect, Schema } from "effect" import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js" -import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/effect.js" +import { ai, LanguageModel, LLMRequest } from "../src/effect.js" import { OpenAI, Anthropic } from "../src/providers.js" import { testEffect } from "./lib/effect.js" import { fixedResponse } from "./lib/http.js" @@ -9,14 +9,14 @@ import { fixedResponse } from "./lib/http.js" test("runtime capability checks follow model and route updates", () => { const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture") const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture") - const request = LLM.request({ model: supported, prompt: "hello" }) - expect(LLMClient.canCompact(request)).toBe(true) - expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true) - expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false) - expect( - LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })), - ).toBe(false) - expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true) + const request = ai.request({ model: supported, prompt: "hello" }) + expect(ai.canCompact(request)).toBe(true) + expect(ai.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true) + expect(ai.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false) + expect(ai.canCompact(ai.request({ model: LanguageModel.update(supported, { route: unsupported.route }) }))).toBe( + false, + ) + expect(ai.canCompact(ai.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true) }) test("explicit compaction serializes a replacement window without a messages alias", () => { @@ -82,14 +82,12 @@ testEffect(fixedResponse("")).effect( "explicit compaction on a route without a compact endpoint fails with UnsupportedOperation", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: Anthropic.configure({ apiKey: "test" }).model("fixture"), prompt: "hello", }) - expect(LLMClient.canCompact(request)).toBe(false) - const error = yield* LLMClient.compact(request as unknown as Parameters[0]).pipe( - Effect.flip, - ) + expect(ai.canCompact(request)).toBe(false) + const error = yield* ai.compact(request as unknown as Parameters[0]).pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("does not support explicit compaction") if (error.reason._tag === "UnsupportedOperation") { diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index 4e0b579b8023..2c3d96937378 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Ref, Schema } from "effect" import { HttpClientRequest, HttpClientResponse } from "effect/http" import { - LLM, + ai, LLMRequest, Message, ToolCallPart, @@ -63,7 +63,7 @@ describe("request option precedence", () => { }, }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Say hello.", generation: { maxTokens: 30, topP: 0.9, stop: ["request"] }, @@ -87,7 +87,7 @@ describe("request option precedence", () => { it.effect("keeps the last tool definition for duplicate names", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: OpenAIChat.route.model({ id: "gpt-4o-mini" }), prompt: "Use a tool.", }) @@ -117,7 +117,7 @@ describe("request option precedence", () => { it.effect("deduplicates tools within each namespace", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAIResponses.route.model({ id: "gpt-5.4" }), tools: [ ToolDefinition.make({ name: "crm", description: "Top-level CRM tool", inputSchema: {} }), @@ -171,7 +171,7 @@ describe("request option precedence", () => { it.effect("normalizes tool history before protocol lowering", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAIChat.route.model({ id: "gpt-4o-mini" }), messages: [ Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })), @@ -190,7 +190,7 @@ describe("request option precedence", () => { it.effect("applies model HTTP defaults before request HTTP overlays", () => LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, @@ -244,7 +244,7 @@ describe("request option precedence", () => { it.effect("transforms the final HTTP request after serialization and authentication", () => LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") }) .model({ id: "gpt-4o-mini" }), @@ -285,7 +285,7 @@ describe("request option precedence", () => { it.effect("transforms the HTTP response before protocol decoding", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }), @@ -324,7 +324,7 @@ describe("request option precedence", () => { Effect.gen(function* () { const attempts = yield* Ref.make(0) const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") }) .model({ id: "gpt-4o-mini" }), @@ -360,7 +360,7 @@ describe("request option precedence", () => { it.effect("sanitizes outbound JSON without an HTTP overlay", () => LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }), @@ -384,7 +384,7 @@ describe("request option precedence", () => { it.effect("sanitizes unpaired surrogates throughout outbound JSON", () => LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }), @@ -427,7 +427,7 @@ describe("request option precedence", () => { it.effect("applies raw body overlays after protocol lowering", () => LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }), @@ -455,9 +455,9 @@ describe("request option precedence", () => { auth: Auth.header("x-api-key", "test"), }) const model = route.model({ id: "claude-sonnet-4-5" }) - const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) + const withoutMaxTokens = yield* compileRequest(ai.request({ model, prompt: "Say hello.", cache: "none" })) const withMaxTokens = yield* compileRequest( - LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }), + ai.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }), ) expect(withoutMaxTokens.body.max_tokens).toBe(32_000) diff --git a/packages/ai/test/continuation-scenarios.ts b/packages/ai/test/continuation-scenarios.ts index 71852e09df09..7203a11b2664 100644 --- a/packages/ai/test/continuation-scenarios.ts +++ b/packages/ai/test/continuation-scenarios.ts @@ -1,12 +1,12 @@ import { - LLM, + ai, + type ContentPart, + type LanguageModel, Media, Message, ToolCallPart, ToolDefinition, ToolResultPart, - type ContentPart, - type LanguageModel, } from "../src/effect.js" export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const @@ -98,7 +98,7 @@ export function continuationRequest(input: { if (features.has("user-follow-up")) messages.push(Message.user("Continue from this conversation in one short sentence.")) - return LLM.request({ + return ai.request({ id: input.id, model: input.model, system: features.has("system") ? "You are concise. Continue from the provided history." : undefined, diff --git a/packages/ai/test/decision.test.ts b/packages/ai/test/decision.test.ts index c8958b951d27..ab82af5b12e1 100644 --- a/packages/ai/test/decision.test.ts +++ b/packages/ai/test/decision.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { HttpClientRequest } from "effect/http" -import { Decision, DecisionClient, DecisionUsage, Media } from "../src/effect.js" +import { ai, DecisionClient, DecisionUsage, Media } from "../src/effect.js" import { Baseten, CloudflareWorkersAI, @@ -75,7 +75,7 @@ const expectedAnswers = [ describe("Decision", () => { it.effect("decides through System One with TypeSafe image assets", () => Effect.gen(function* () { - const response = yield* Decision.generate({ + const response = yield* ai.decision.generate({ model: TypeSafeAI.configure({ apiKey: "test", baseURL: "https://typesafe.test/v1/", @@ -140,490 +140,527 @@ describe("Decision", () => { ) it.effect("sends a lone input object as the System One state and maps boolean choices back", () => - Decision.generate({ - model: OpenCode.configure({ apiKey: "opencode-key", baseURL: "https://opencode.test/v1" }).decision("jev-1.13"), - input: { ticket: "hello" }, - questions: [ - { - name: "greeting", - type: "choice", - instructions: "Is this a greeting?", - choices: [{ value: true }, { value: false }], - }, - ], - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - const answer = response.answers[0] - if (answer.type !== "choice") throw new Error("expected a choice answer") - expect(answer.choice).toBe(true) - expect(answer.probabilities).toEqual([ - { value: true, probability: 0.99 }, - { value: false, probability: 0.01 }, - ]) - expect(response.usage).toEqual( - new DecisionUsage({ inputTokens: 10, outputTokens: 2, totalTokens: 12, cost: 0.0004 }), - ) - expect(response.providerMetadata).toEqual({ - opencode: { model: "jev-1.13.0", usage: { input_tokens: 10, output_tokens: 2, cost: 0.0004 } }, - }) - }), - ), - Effect.provide( - client((input) => { - expect(input.request.url).toBe("https://opencode.test/v1/systemone") - expect(input.request.headers.authorization).toBe("Bearer opencode-key") - expect(input.request.headers["typesafe-danger-zone-images"]).toBe("true") - expect(JSON.parse(input.text)).toEqual({ - model: "jev-1.13", - state: { ticket: "hello" }, - questions: { - greeting: { type: "choice", instructions: "Is this a greeting?", criteria: { true: null, false: null } }, - }, - }) - return Effect.succeed( - json(input, { - model: "jev-1.13.0", - answers: { - greeting: { type: "choice", choice: "true", probabilities: { false: 0.01, true: 0.99 }, confidence: 1 }, + ai.decision + .generate({ + model: OpenCode.configure({ apiKey: "opencode-key", baseURL: "https://opencode.test/v1" }).decision("jev-1.13"), + input: { ticket: "hello" }, + questions: [ + { + name: "greeting", + type: "choice", + instructions: "Is this a greeting?", + choices: [{ value: true }, { value: false }], + }, + ], + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + const answer = response.answers[0] + if (answer.type !== "choice") throw new Error("expected a choice answer") + expect(answer.choice).toBe(true) + expect(answer.probabilities).toEqual([ + { value: true, probability: 0.99 }, + { value: false, probability: 0.01 }, + ]) + expect(response.usage).toEqual( + new DecisionUsage({ inputTokens: 10, outputTokens: 2, totalTokens: 12, cost: 0.0004 }), + ) + expect(response.providerMetadata).toEqual({ + opencode: { model: "jev-1.13.0", usage: { input_tokens: 10, output_tokens: 2, cost: 0.0004 } }, + }) + }), + ), + Effect.provide( + client((input) => { + expect(input.request.url).toBe("https://opencode.test/v1/systemone") + expect(input.request.headers.authorization).toBe("Bearer opencode-key") + expect(input.request.headers["typesafe-danger-zone-images"]).toBe("true") + expect(JSON.parse(input.text)).toEqual({ + model: "jev-1.13", + state: { ticket: "hello" }, + questions: { + greeting: { + type: "choice", + instructions: "Is this a greeting?", + criteria: { true: null, false: null }, + }, }, - usage: { input_tokens: 10, output_tokens: 2, cost: 0.0004 }, - }), - ) - }), + }) + return Effect.succeed( + json(input, { + model: "jev-1.13.0", + answers: { + greeting: { + type: "choice", + choice: "true", + probabilities: { false: 0.01, true: 0.99 }, + confidence: 1, + }, + }, + usage: { input_tokens: 10, output_tokens: 2, cost: 0.0004 }, + }), + ) + }), + ), ), - ), ) it.effect("rejects a choice with both a boolean and a string of the same text for System One", () => Effect.gen(function* () { - const error = yield* Decision.generate({ - model: TypeSafeAI.decision("jev-latest"), - input: { ticket: "hello" }, - questions: [{ name: "ok", type: "choice", instructions: "OK?", choices: [{ value: true }, { value: "true" }] }], - }).pipe(Effect.flip) + const error = yield* ai.decision + .generate({ + model: TypeSafeAI.decision("jev-latest"), + input: { ticket: "hello" }, + questions: [ + { name: "ok", type: "choice", instructions: "OK?", choices: [{ value: true }, { value: "true" }] }, + ], + }) + .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") expect(error.message).toContain("ok") }).pipe(Effect.provide(offline)), ) it.effect("places media in the OpenRouter state and downloads URL media first", () => - Decision.generate({ - model: OpenRouter.configure({ apiKey: "openrouter-key", baseURL: "https://openrouter.test/api/v1" }).decision( - "cloudflare/clef-omni", - ), - input: [ - { type: "text", text: "Before:" }, - { type: "media", media: Media.url("https://assets.test/before.png", { mediaType: "image/png" }) }, - { type: "media", media: Media.base64("AAAA", "audio/wav") }, - ], - questions: [{ name: "red", type: "predicate", instructions: "Is the image mostly red?" }], - providerOptions: { user: "user-1", session_id: "session-1" }, - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.98 }]) - expect(response.providerMetadata?.openrouter).toEqual({ - model: "cloudflare/clef-omni-20261001", - responseId: "gen-1", - provider: "Cloudflare", - }) - }), - ), - Effect.provide( - client((input) => { - if (input.request.method === "GET") { - expect(input.request.url).toBe("https://assets.test/before.png") - return Effect.succeed( - input.respond(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } }), - ) - } - expect(input.request.url).toBe("https://openrouter.test/api/v1/systemone") - expect(JSON.parse(input.text)).toEqual({ - model: "cloudflare/clef-omni", - user: "user-1", - session_id: "session-1", - state: [ - "Before:", - { type: "image_url", image_url: { url: "data:image/png;base64,AQID" } }, - { type: "input_audio", input_audio: { data: "AAAA", format: "wav" } }, - ], - questions: { red: { type: "noul", instructions: "Is the image mostly red?" } }, - }) - return Effect.succeed( - json(input, { - id: "gen-1", + ai.decision + .generate({ + model: OpenRouter.configure({ apiKey: "openrouter-key", baseURL: "https://openrouter.test/api/v1" }).decision( + "cloudflare/clef-omni", + ), + input: [ + { type: "text", text: "Before:" }, + { type: "media", media: Media.url("https://assets.test/before.png", { mediaType: "image/png" }) }, + { type: "media", media: Media.base64("AAAA", "audio/wav") }, + ], + questions: [{ name: "red", type: "predicate", instructions: "Is the image mostly red?" }], + providerOptions: { user: "user-1", session_id: "session-1" }, + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.98 }]) + expect(response.providerMetadata?.openrouter).toEqual({ model: "cloudflare/clef-omni-20261001", + responseId: "gen-1", provider: "Cloudflare", - answers: { red: { type: "noul", noul: 0.98 } }, - }), - ) - }), + }) + }), + ), + Effect.provide( + client((input) => { + if (input.request.method === "GET") { + expect(input.request.url).toBe("https://assets.test/before.png") + return Effect.succeed( + input.respond(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } }), + ) + } + expect(input.request.url).toBe("https://openrouter.test/api/v1/systemone") + expect(JSON.parse(input.text)).toEqual({ + model: "cloudflare/clef-omni", + user: "user-1", + session_id: "session-1", + state: [ + "Before:", + { type: "image_url", image_url: { url: "data:image/png;base64,AQID" } }, + { type: "input_audio", input_audio: { data: "AAAA", format: "wav" } }, + ], + questions: { red: { type: "noul", instructions: "Is the image mostly red?" } }, + }) + return Effect.succeed( + json(input, { + id: "gen-1", + model: "cloudflare/clef-omni-20261001", + provider: "Cloudflare", + answers: { red: { type: "noul", noul: 0.98 } }, + }), + ) + }), + ), ), - ), ) it.effect("puts media in a user message inside the Baseten state", () => - Decision.generate({ - model: Baseten.configure({ apiKey: "baseten-key" }).decision("decision-models/baseten-mammoth-27b-latest"), - input: [ - { type: "text", text: "Photo:" }, - { type: "media", media: png }, - { type: "json", value: { source: "upload" } }, - ], - questions: [{ name: "red", type: "predicate", instructions: "Is the photo mostly red?" }], - }).pipe( - Effect.tap((response) => - Effect.sync(() => expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.99 }])), + ai.decision + .generate({ + model: Baseten.configure({ apiKey: "baseten-key" }).decision("decision-models/baseten-mammoth-27b-latest"), + input: [ + { type: "text", text: "Photo:" }, + { type: "media", media: png }, + { type: "json", value: { source: "upload" } }, + ], + questions: [{ name: "red", type: "predicate", instructions: "Is the photo mostly red?" }], + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.99 }])), + ), + Effect.provide( + client((input) => { + expect(input.request.url).toBe("https://inference.baseten.co/v1/systemone") + expect(JSON.parse(input.text)).toEqual({ + model: "decision-models/baseten-mammoth-27b-latest", + state: { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Photo:" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AQID" } }, + { type: "text", text: '{"source":"upload"}' }, + ], + }, + ], + }, + questions: { red: { type: "noul", instructions: "Is the photo mostly red?" } }, + }) + return Effect.succeed(json(input, { model: "mammoth", answers: { red: { type: "noul", noul: 0.99 } } })) + }), + ), ), - Effect.provide( - client((input) => { - expect(input.request.url).toBe("https://inference.baseten.co/v1/systemone") - expect(JSON.parse(input.text)).toEqual({ - model: "decision-models/baseten-mammoth-27b-latest", - state: { - messages: [ + ) + + it.effect("decides through OpenAI Decisions", () => + ai.decision + .generate({ + model: OpenAI.configure({ + apiKey: "openai-key", + baseURL: "https://openai.test/v1", + queryParams: { api: "v1" }, + }).decision("gpt-6-luna"), + input: [ + { type: "text", text: "Ticket:" }, + { type: "json", value: { ticket: "Please refund the duplicate charge." } }, + { type: "json", value: 42 }, + { type: "media", media: png }, + { type: "media", media: Media.url("https://assets.test/receipt.jpg", { mediaType: "image/jpeg" }) }, + ], + questions, + providerOptions: { safetyIdentifier: "user-1", promptCacheKey: "support-v1" }, + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.answers).toEqual(expectedAnswers) + expect(response.usage?.inputTokens).toBe(40) + expect(response.providerMetadata).toEqual({ + openai: { model: "gpt-6-luna", usage: { input_tokens: 40, output_tokens: 0, total_tokens: 40 } }, + }) + }), + ), + Effect.provide( + client((input) => { + expect(input.request.url).toBe("https://openai.test/v1/decisions?api=v1") + expect(input.request.headers.authorization).toBe("Bearer openai-key") + expect(JSON.parse(input.text)).toEqual({ + safety_identifier: "user-1", + prompt_cache_key: "support-v1", + model: "gpt-6-luna", + input: [ { role: "user", content: [ - { type: "text", text: "Photo:" }, - { type: "image_url", image_url: { url: "data:image/png;base64,AQID" } }, - { type: "text", text: '{"source":"upload"}' }, + { type: "input_text", text: "Ticket:" }, + { type: "input_json", data: { ticket: "Please refund the duplicate charge." } }, + { type: "input_text", text: "42" }, + { type: "input_image", image_url: "data:image/png;base64,AQID" }, + { type: "input_image", image_url: "https://assets.test/receipt.jpg" }, ], }, ], - }, - questions: { red: { type: "noul", instructions: "Is the photo mostly red?" } }, - }) - return Effect.succeed(json(input, { model: "mammoth", answers: { red: { type: "noul", noul: 0.99 } } })) - }), - ), - ), - ) - - it.effect("decides through OpenAI Decisions", () => - Decision.generate({ - model: OpenAI.configure({ - apiKey: "openai-key", - baseURL: "https://openai.test/v1", - queryParams: { api: "v1" }, - }).decision("gpt-6-luna"), - input: [ - { type: "text", text: "Ticket:" }, - { type: "json", value: { ticket: "Please refund the duplicate charge." } }, - { type: "json", value: 42 }, - { type: "media", media: png }, - { type: "media", media: Media.url("https://assets.test/receipt.jpg", { mediaType: "image/jpeg" }) }, - ], - questions, - providerOptions: { safetyIdentifier: "user-1", promptCacheKey: "support-v1" }, - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.answers).toEqual(expectedAnswers) - expect(response.usage?.inputTokens).toBe(40) - expect(response.providerMetadata).toEqual({ - openai: { model: "gpt-6-luna", usage: { input_tokens: 40, output_tokens: 0, total_tokens: 40 } }, - }) - }), - ), - Effect.provide( - client((input) => { - expect(input.request.url).toBe("https://openai.test/v1/decisions?api=v1") - expect(input.request.headers.authorization).toBe("Bearer openai-key") - expect(JSON.parse(input.text)).toEqual({ - safety_identifier: "user-1", - prompt_cache_key: "support-v1", - model: "gpt-6-luna", - input: [ - { - role: "user", - content: [ - { type: "input_text", text: "Ticket:" }, - { type: "input_json", data: { ticket: "Please refund the duplicate charge." } }, - { type: "input_text", text: "42" }, - { type: "input_image", image_url: "data:image/png;base64,AQID" }, - { type: "input_image", image_url: "https://assets.test/receipt.jpg" }, - ], - }, - ], - questions: [ - { - name: "department", - type: "choice", - instructions: "Which team should handle this?", - choices: [{ value: "billing", description: "Payments and refunds" }, { value: "technical" }], - }, - { - name: "urgency", - type: "score", - instructions: "How urgent is this?", - levels: [ - { label: "Can wait" }, - { label: "Needs attention", description: "Same day" }, - { label: "Blocking" }, - ], - }, - { name: "refund", type: "predicate", instructions: "Is the customer asking for a refund?" }, - ], - }) - // Answers come back out of order; the protocol restores question order by name. - return Effect.succeed( - json(input, { - model: "gpt-6-luna", - answers: [ - { type: "predicate", name: "refund", probability: 0.97 }, + questions: [ { - type: "choice", name: "department", - choice: "billing", - probabilities: [ - { value: "billing", probability: 0.9 }, - { value: "technical", probability: 0.1 }, - ], - confidence: 0.8, + type: "choice", + instructions: "Which team should handle this?", + choices: [{ value: "billing", description: "Payments and refunds" }, { value: "technical" }], }, { - type: "score", name: "urgency", - score: 1.2, - probabilities: [ - { value: 0, label: "Can wait", probability: 0 }, - { value: 1, label: "Needs attention", probability: 0.8 }, - { value: 2, label: "Blocking", probability: 0.2 }, + type: "score", + instructions: "How urgent is this?", + levels: [ + { label: "Can wait" }, + { label: "Needs attention", description: "Same day" }, + { label: "Blocking" }, ], - confidence: 0.6, }, + { name: "refund", type: "predicate", instructions: "Is the customer asking for a refund?" }, ], - usage: { input_tokens: 40, output_tokens: 0, total_tokens: 40 }, - }), - ) - }), + }) + // Answers come back out of order; the protocol restores question order by name. + return Effect.succeed( + json(input, { + model: "gpt-6-luna", + answers: [ + { type: "predicate", name: "refund", probability: 0.97 }, + { + type: "choice", + name: "department", + choice: "billing", + probabilities: [ + { value: "billing", probability: 0.9 }, + { value: "technical", probability: 0.1 }, + ], + confidence: 0.8, + }, + { + type: "score", + name: "urgency", + score: 1.2, + probabilities: [ + { value: 0, label: "Can wait", probability: 0 }, + { value: 1, label: "Needs attention", probability: 0.8 }, + { value: 2, label: "Blocking", probability: 0.2 }, + ], + confidence: 0.6, + }, + ], + usage: { input_tokens: 40, output_tokens: 0, total_tokens: 40 }, + }), + ) + }), + ), ), - ), ) it.effect("returns OpenAI refusals as per-question errors", () => - Decision.generate({ - model: OpenAI.configure({ apiKey: "openai-key" }).decision("gpt-6-luna"), - input: [{ type: "text", text: "hello" }], - questions: [ - { name: "greeting", type: "predicate", instructions: "Is this a greeting?" }, - { name: "unsafe", type: "predicate", instructions: "Something the model declines" }, - { name: "explained", type: "predicate", instructions: "Something the model declines with a reason" }, - ], - }).pipe( - Effect.tap((response) => - Effect.sync(() => - expect(response.answers).toEqual([ - { name: "greeting", type: "predicate", probability: 0.99 }, - { name: "unsafe", type: "error", reason: "refusal" }, - { name: "explained", type: "error", reason: "refusal", message: "I can't help with that." }, - ]), + ai.decision + .generate({ + model: OpenAI.configure({ apiKey: "openai-key" }).decision("gpt-6-luna"), + input: [{ type: "text", text: "hello" }], + questions: [ + { name: "greeting", type: "predicate", instructions: "Is this a greeting?" }, + { name: "unsafe", type: "predicate", instructions: "Something the model declines" }, + { name: "explained", type: "predicate", instructions: "Something the model declines with a reason" }, + ], + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => + expect(response.answers).toEqual([ + { name: "greeting", type: "predicate", probability: 0.99 }, + { name: "unsafe", type: "error", reason: "refusal" }, + { name: "explained", type: "error", reason: "refusal", message: "I can't help with that." }, + ]), + ), ), - ), - Effect.provide( - client((input) => - Effect.succeed( - json(input, { - model: "gpt-6-luna", - answers: [ - { type: "predicate", name: "greeting", probability: 0.99 }, - { type: "refusal", name: "unsafe" }, - { type: "refusal", name: "explained", message: "I can't help with that." }, - ], - }), + Effect.provide( + client((input) => + Effect.succeed( + json(input, { + model: "gpt-6-luna", + answers: [ + { type: "predicate", name: "greeting", probability: 0.99 }, + { type: "refusal", name: "unsafe" }, + { type: "refusal", name: "explained", message: "I can't help with that." }, + ], + }), + ), ), ), ), - ), ) it.effect("rejects audio for OpenAI Decisions before network I/O", () => Effect.gen(function* () { - const error = yield* Decision.generate({ - model: OpenAI.configure({ apiKey: "openai-key" }).decision("gpt-6-luna"), - input: [{ type: "media", media: Media.base64("AAAA", "audio/wav") }], - questions: [{ name: "speech", type: "predicate", instructions: "Is someone speaking?" }], - }).pipe(Effect.flip) + const error = yield* ai.decision + .generate({ + model: OpenAI.configure({ apiKey: "openai-key" }).decision("gpt-6-luna"), + input: [{ type: "media", media: Media.base64("AAAA", "audio/wav") }], + questions: [{ name: "speech", type: "predicate", instructions: "Is someone speaking?" }], + }) + .pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("audio") }).pipe(Effect.provide(offline)), ) it.effect("decides through Vercel AI Gateway's OpenAI Decisions endpoint", () => - Decision.generate({ - model: VercelAIGateway.configure({ apiKey: "gateway-key", baseURL: "https://gateway.test/v1/" }).decision( - "typesafe-ai/jev", - ), - input: [{ type: "text", text: "refund" }], - questions: [{ name: "refund", type: "predicate", instructions: "Is a refund requested?" }], - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.answers).toEqual([{ name: "refund", type: "predicate", probability: 0.98 }]) - expect(response.providerMetadata).toEqual({ - "vercel-ai-gateway": { model: "typesafe-ai/jev", usage: { input_tokens: 10, output_tokens: 0 } }, - }) - }), - ), - Effect.provide( - client((input) => { - expect(input.request.url).toBe("https://gateway.test/v1/decisions") - expect(input.request.headers.authorization).toBe("Bearer gateway-key") - expect(JSON.parse(input.text)).toEqual({ - model: "typesafe-ai/jev", - input: [{ role: "user", content: [{ type: "input_text", text: "refund" }] }], - questions: [{ name: "refund", type: "predicate", instructions: "Is a refund requested?" }], - }) - return Effect.succeed( - json(input, { + ai.decision + .generate({ + model: VercelAIGateway.configure({ apiKey: "gateway-key", baseURL: "https://gateway.test/v1/" }).decision( + "typesafe-ai/jev", + ), + input: [{ type: "text", text: "refund" }], + questions: [{ name: "refund", type: "predicate", instructions: "Is a refund requested?" }], + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.answers).toEqual([{ name: "refund", type: "predicate", probability: 0.98 }]) + expect(response.providerMetadata).toEqual({ + "vercel-ai-gateway": { model: "typesafe-ai/jev", usage: { input_tokens: 10, output_tokens: 0 } }, + }) + }), + ), + Effect.provide( + client((input) => { + expect(input.request.url).toBe("https://gateway.test/v1/decisions") + expect(input.request.headers.authorization).toBe("Bearer gateway-key") + expect(JSON.parse(input.text)).toEqual({ model: "typesafe-ai/jev", - answers: [{ type: "predicate", name: "refund", probability: 0.98 }], - usage: { input_tokens: 10, output_tokens: 0 }, - }), - ) - }), + input: [{ role: "user", content: [{ type: "input_text", text: "refund" }] }], + questions: [{ name: "refund", type: "predicate", instructions: "Is a refund requested?" }], + }) + return Effect.succeed( + json(input, { + model: "typesafe-ai/jev", + answers: [{ type: "predicate", name: "refund", probability: 0.98 }], + usage: { input_tokens: 10, output_tokens: 0 }, + }), + ) + }), + ), ), - ), ) it.effect("puts media in Clef's top-level lists on Cloudflare Run", () => - Decision.generate({ - model: CloudflareWorkersAI.configure({ accountId: "test-account", apiKey: "cloudflare-key" }).decision( - "@cf/cloudflare/clef-omni", - ), - input: [ - { type: "text", text: "Compare:" }, - { type: "media", media: png }, - { type: "media", media: Media.base64("AAAA", "audio/wav") }, - ], - questions: [{ name: "red", type: "predicate", instructions: "Is the image mostly red?" }], - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.9 }]) - expect(response.providerMetadata).toEqual({ "cloudflare-workers-ai": { model: "clef-omni" } }) - }), - ), - Effect.provide( - client((input) => { - expect(input.request.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/run") - expect(JSON.parse(input.text)).toEqual({ - model: "@cf/cloudflare/clef-omni", - input: { - state: "Compare:", - images: ["data:image/png;base64,AQID"], - audio: ["data:audio/wav;base64,AAAA"], - questions: { red: { type: "noul", instructions: "Is the image mostly red?" } }, - }, - }) - return Effect.succeed( - json(input, { result: { model: "clef-omni", answers: { red: { type: "noul", noul: 0.9 } } } }), - ) - }), + ai.decision + .generate({ + model: CloudflareWorkersAI.configure({ accountId: "test-account", apiKey: "cloudflare-key" }).decision( + "@cf/cloudflare/clef-omni", + ), + input: [ + { type: "text", text: "Compare:" }, + { type: "media", media: png }, + { type: "media", media: Media.base64("AAAA", "audio/wav") }, + ], + questions: [{ name: "red", type: "predicate", instructions: "Is the image mostly red?" }], + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.9 }]) + expect(response.providerMetadata).toEqual({ "cloudflare-workers-ai": { model: "clef-omni" } }) + }), + ), + Effect.provide( + client((input) => { + expect(input.request.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/run") + expect(JSON.parse(input.text)).toEqual({ + model: "@cf/cloudflare/clef-omni", + input: { + state: "Compare:", + images: ["data:image/png;base64,AQID"], + audio: ["data:audio/wav;base64,AAAA"], + questions: { red: { type: "noul", instructions: "Is the image mostly red?" } }, + }, + }) + return Effect.succeed( + json(input, { result: { model: "clef-omni", answers: { red: { type: "noul", noul: 0.9 } } } }), + ) + }), + ), ), - ), ) it.effect("surfaces Cloudflare Run errors", () => - Decision.generate({ - model: CloudflareWorkersAI.configure({ accountId: "test-account", apiKey: "cloudflare-key" }).decision( - "@cf/cloudflare/clef", - ), - input: [{ type: "text", text: "refund" }], - questions: [{ name: "refund", type: "predicate", instructions: "Is a refund requested?" }], - }).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.reason._tag).toBe("InvalidRequest") - expect(error.message).toContain("At least one question is required") - }), - ), - Effect.provide( - client((input) => - Effect.succeed( - json( - input, - { - errors: [ - { - message: - "Model execution failed (User Input Error): Validation error at questions: At least one question is required", - code: 7003, - }, - ], - success: false, - result: {}, - messages: [], - }, - { status: 400 }, + ai.decision + .generate({ + model: CloudflareWorkersAI.configure({ accountId: "test-account", apiKey: "cloudflare-key" }).decision( + "@cf/cloudflare/clef", + ), + input: [{ type: "text", text: "refund" }], + questions: [{ name: "refund", type: "predicate", instructions: "Is a refund requested?" }], + }) + .pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("At least one question is required") + }), + ), + Effect.provide( + client((input) => + Effect.succeed( + json( + input, + { + errors: [ + { + message: + "Model execution failed (User Input Error): Validation error at questions: At least one question is required", + code: 7003, + }, + ], + success: false, + result: {}, + messages: [], + }, + { status: 400 }, + ), ), ), ), ), - ), ) it.effect("fails the call when an answer picks an option that wasn't offered", () => - Decision.generate({ - model: OpenAI.configure({ apiKey: "openai-key" }).decision("gpt-6-luna"), - input: [{ type: "text", text: "refund" }], - questions: [{ name: "tone", type: "choice", instructions: "Tone?", choices: [{ value: "calm" }] }], - }).pipe( - Effect.flip, - Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))), - Effect.provide( - client((input) => - Effect.succeed( - json(input, { - model: "gpt-6-luna", - answers: [ - { - type: "choice", - name: "tone", - choice: "angry", - probabilities: [{ value: "angry", probability: 1 }], - confidence: 1, - }, - ], - }), + ai.decision + .generate({ + model: OpenAI.configure({ apiKey: "openai-key" }).decision("gpt-6-luna"), + input: [{ type: "text", text: "refund" }], + questions: [{ name: "tone", type: "choice", instructions: "Tone?", choices: [{ value: "calm" }] }], + }) + .pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))), + Effect.provide( + client((input) => + Effect.succeed( + json(input, { + model: "gpt-6-luna", + answers: [ + { + type: "choice", + name: "tone", + choice: "angry", + probabilities: [{ value: "angry", probability: 1 }], + confidence: 1, + }, + ], + }), + ), ), ), ), - ), ) it.effect("fails the call when System One leaves a question unanswered", () => - Decision.generate({ - model: TypeSafeAI.configure({ apiKey: "test" }).decision("jev-latest"), - input: [{ type: "text", text: "refund" }], - questions: [ - { name: "refund", type: "predicate", instructions: "Refund?" }, - { name: "angry", type: "predicate", instructions: "Angry?" }, - ], - }).pipe( - Effect.flip, - Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))), - Effect.provide( - client((input) => - Effect.succeed(json(input, { model: "jev-1.14.0", answers: { refund: { type: "noul", noul: 0.9 } } })), + ai.decision + .generate({ + model: TypeSafeAI.configure({ apiKey: "test" }).decision("jev-latest"), + input: [{ type: "text", text: "refund" }], + questions: [ + { name: "refund", type: "predicate", instructions: "Refund?" }, + { name: "angry", type: "predicate", instructions: "Angry?" }, + ], + }) + .pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))), + Effect.provide( + client((input) => + Effect.succeed(json(input, { model: "jev-1.14.0", answers: { refund: { type: "noul", noul: 0.9 } } })), + ), ), ), - ), ) it.effect("rejects duplicate question names before network I/O", () => Effect.gen(function* () { - const error = yield* Decision.generate({ - model: TypeSafeAI.decision("jev-latest"), - input: [{ type: "text", text: "hello" }], - questions: [ - { name: "same", type: "predicate", instructions: "One?" }, - { name: "same", type: "predicate", instructions: "Two?" }, - ], - }).pipe(Effect.flip) + const error = yield* ai.decision + .generate({ + model: TypeSafeAI.decision("jev-latest"), + input: [{ type: "text", text: "hello" }], + questions: [ + { name: "same", type: "predicate", instructions: "One?" }, + { name: "same", type: "predicate", instructions: "Two?" }, + ], + }) + .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") }).pipe(Effect.provide(offline)), ) diff --git a/packages/ai/test/decision.types.ts b/packages/ai/test/decision.types.ts index 5f9e59392436..e522a21d85cc 100644 --- a/packages/ai/test/decision.types.ts +++ b/packages/ai/test/decision.types.ts @@ -1,11 +1,5 @@ import { Effect } from "effect" -import { - Decision, - DecisionClient, - DecisionModel, - type DecisionRequestInput, - type DecisionModelRoute, -} from "../src/effect.js" +import { ai, DecisionClient, DecisionModel, type DecisionModelRoute, type DecisionRequestInput } from "../src/effect.js" import type { Service } from "../src/decision-client.js" import { Baseten, OpenAI, OpenCode, OpenRouter, TypeSafeAI } from "../src/providers.js" @@ -14,7 +8,7 @@ type Success = T extends Effect.Effect ? A : nev type Equal = [A, B] extends [B, A] ? true : false type Assert = T -const request = Decision.request({ +const request = ai.decision.request({ model: TypeSafeAI.configure({ apiKey: "test" }).decision("jev-latest"), input: { ticket: "refund" }, questions: [ @@ -35,7 +29,7 @@ void (true satisfies Name) void (true satisfies ClientRequirements) Effect.gen(function* () { - const response = yield* Decision.generate({ + const response = yield* ai.decision.generate({ model: OpenCode.decision("jev-1.13"), input: [{ type: "text", text: "hello" }], questions: [{ name: "greeting", type: "predicate", instructions: "Greeting?" }], @@ -57,7 +51,7 @@ Effect.gen(function* () { declare const route: DecisionModelRoute<{ readonly temperature?: number }> const custom = DecisionModel.make({ id: "custom", provider: "custom", route }) -Decision.generate({ +ai.decision.generate({ model: custom, input: { text: "hello" }, questions: [{ name: "ok", type: "predicate", instructions: "OK?" }], @@ -71,7 +65,7 @@ Decision.generate({ providerOptions: { temperature: "high" }, }) satisfies DecisionRequestInput -Decision.generate({ +ai.decision.generate({ model: OpenAI.decision("gpt-6-luna"), input: { text: "hello" }, questions: [{ name: "ok", type: "predicate", instructions: "OK?" }], @@ -86,7 +80,7 @@ const openai = OpenAI.decision("gpt-6-luna") providerOptions: { safetyIdentifier: 1 }, }) satisfies DecisionRequestInput -Decision.generate({ +ai.decision.generate({ model: OpenRouter.decision("typesafe/jev-1.13"), input: { text: "hello" }, questions: [{ name: "ok", type: "predicate", instructions: "OK?" }], @@ -101,7 +95,7 @@ const openrouter = OpenRouter.decision("typesafe/jev-1.13") providerOptions: { session_id: 1 }, }) satisfies DecisionRequestInput -Decision.generate({ +ai.decision.generate({ model: Baseten.decision("decision-models/baseten-elephant-9b-latest"), input: { text: "hello" }, questions: [{ name: "ok", type: "predicate", instructions: "OK?" }], diff --git a/packages/ai/test/effort-updates.test.ts b/packages/ai/test/effort-updates.test.ts index 73fa3e4d2847..81461fc1f504 100644 --- a/packages/ai/test/effort-updates.test.ts +++ b/packages/ai/test/effort-updates.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { LLM, Message, ToolCallPart } from "../src/effect.js" -import { Auth, LLMClient } from "../src/route.js" +import { ai, Message, ToolCallPart } from "../src/effect.js" +import { Auth } from "../src/route.js" import { compileRequest } from "../src/route/client.js" import { AnthropicMessages } from "../src/protocols/anthropic-messages.js" import { OpenAIResponses } from "../src/protocols/openai-responses.js" @@ -36,10 +36,10 @@ const updates = (body: OpenAIResponses.OpenAIResponsesBody) => describe("applyEffortUpdates", () => { test("keeps the request identity without markers and for protocols that lower them", () => { - const plain = LLM.request({ model: opus5, prompt: "hi" }) + const plain = ai.request({ model: opus5, prompt: "hi" }) expect(applyEffortUpdates(plain)).toBe(plain) - const supported = LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } }) + const supported = ai.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } }) expect(applyEffortUpdates(supported)).toBe(supported) }) @@ -51,9 +51,9 @@ describe("applyEffortUpdates", () => { auth: Auth.header("x-goog-api-key", "test"), }) .model({ id: "gemini-3.5-flash" }) - const withMarkers = yield* compileRequest(LLM.request({ model, messages: conversation })) + const withMarkers = yield* compileRequest(ai.request({ model, messages: conversation })) const withoutMarkers = yield* compileRequest( - LLM.request({ model, messages: [Message.user("Before."), Message.user("After.")] }), + ai.request({ model, messages: [Message.user("Before."), Message.user("After.")] }), ) expect(withMarkers.body).toEqual(withoutMarkers.body) @@ -65,7 +65,7 @@ describe("cache policy", () => { it.effect("walks the tail breakpoint back past a trailing effort marker", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus5, messages: [Message.user("first"), Message.assistant("reply"), Message.user("latest"), lowFromHigh], providerOptions: { effort: "low" }, @@ -87,7 +87,7 @@ describe("Anthropic Messages effort updates", () => { it.effect("lowers markers to per-turn system messages and freezes the top-level effort", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }), + ai.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }), ) expect(prepared.body.output_config).toEqual({ effort: "high" }) @@ -103,7 +103,7 @@ describe("Anthropic Messages effort updates", () => { Effect.gen(function* () { const format = { type: "json_schema" as const, schema: { type: "object" } } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus5, messages: [ Message.user("One."), @@ -131,7 +131,7 @@ describe("Anthropic Messages effort updates", () => { [opus5, true], [anthropic("claude-sonnet-5"), false], ] as const) { - const request = LLM.request({ + const request = ai.request({ model, messages: conversation, providerOptions: { effort: "low" }, @@ -149,7 +149,7 @@ describe("Anthropic Messages effort updates", () => { it.effect("moves a marker between a tool call and its result after the result", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus5, messages: [ Message.user("Weather?"), @@ -174,7 +174,7 @@ describe("Anthropic Messages effort updates", () => { it.effect("releases a held system update next to an effort marker as one valid section", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus5, messages: [ Message.user("Fix it."), @@ -201,10 +201,10 @@ describe("Anthropic Messages effort updates", () => { it.effect("falls back to a plain top-level effort when history drifted from the current effort", () => Effect.gen(function* () { const drifted = yield* compileRequest( - LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }), + ai.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }), ) const plain = yield* compileRequest( - LLM.request({ + ai.request({ model: opus5, messages: [Message.user("Before."), Message.user("After.")], providerOptions: { effort: "medium" }, @@ -237,7 +237,7 @@ describe("Anthropic Messages effort updates", () => { it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }), + ai.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }), ) expect(systemMessages(prepared.body)).toHaveLength(supported ? 1 : 0) @@ -249,14 +249,14 @@ describe("Anthropic Messages effort updates", () => { it.effect("honors the compatibility override in both directions", () => Effect.gen(function* () { const enabled = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropic("claude-sonnet-5", { supportsEffortUpdates: true }), messages: conversation, providerOptions: { effort: "low" }, }), ) const disabled = yield* compileRequest( - LLM.request({ + ai.request({ model: anthropic("claude-opus-5", { supportsEffortUpdates: false }), messages: conversation, providerOptions: { effort: "low" }, @@ -272,7 +272,7 @@ describe("Anthropic Messages effort updates", () => { Effect.gen(function* () { const sonnet = anthropic("claude-sonnet-5-5") const betweenTools = yield* compileRequest( - LLM.request({ + ai.request({ model: sonnet, messages: conversation, providerOptions: { thinking: { type: "between_tools" }, effort: "low" }, @@ -288,14 +288,14 @@ describe("Anthropic Messages effort updates", () => { Effect.gen(function* () { const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" }) const opus50 = yield* compileRequest( - LLM.request({ + ai.request({ model: mantle.messages("anthropic.claude-opus-5"), messages: conversation, providerOptions: { effort: "low" }, }), ) const opus55 = yield* compileRequest( - LLM.request({ + ai.request({ model: mantle.messages("anthropic.claude-opus-5-5"), messages: conversation, providerOptions: { effort: "low" }, @@ -313,14 +313,14 @@ describe("Anthropic Messages effort updates", () => { Effect.gen(function* () { const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }) const opus5 = yield* compileRequest( - LLM.request({ + ai.request({ model: vertex.model("claude-opus-5"), messages: conversation, providerOptions: { effort: "low" }, }), ) const opus48 = yield* compileRequest( - LLM.request({ + ai.request({ model: vertex.model("claude-opus-4-8"), messages: conversation, providerOptions: { effort: "low" }, @@ -339,7 +339,7 @@ describe("OpenAI Responses effort updates", () => { it.effect("lowers markers to configuration_update items and freezes reasoning.effort", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }), + ai.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }), ) expect(prepared.body.reasoning).toEqual({ effort: "high" }) @@ -354,7 +354,7 @@ describe("OpenAI Responses effort updates", () => { it.effect("coalesces consecutive updates so the newest wins", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: astra, messages: [ Message.user("Before."), @@ -378,7 +378,7 @@ describe("OpenAI Responses effort updates", () => { it.effect("omits reasoning.effort for the model default and sends `medium` for a switch back to it", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: astra, messages: [ Message.user("One."), @@ -401,10 +401,10 @@ describe("OpenAI Responses effort updates", () => { it.effect("falls back to a plain top-level effort when history drifted from the current effort", () => Effect.gen(function* () { const drifted = yield* compileRequest( - LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }), + ai.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }), ) const plain = yield* compileRequest( - LLM.request({ + ai.request({ model: astra, messages: [Message.user("Before."), Message.user("After.")], providerOptions: { reasoningEffort: "xhigh" }, @@ -419,7 +419,7 @@ describe("OpenAI Responses effort updates", () => { it.effect("strips markers when automatic context management is enabled", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low", contextManagement: [{ type: "compaction" }] }, @@ -435,7 +435,7 @@ describe("OpenAI Responses effort updates", () => { it.effect("strips markers when the body overlay selects pro reasoning mode", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ apiKey: "fixture", http: { body: { reasoning: { mode: "pro" } } } }).responses( "gpt-6-sol", ), @@ -452,7 +452,7 @@ describe("OpenAI Responses effort updates", () => { it.effect("strips markers when provider options select pro reasoning mode", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-sol"), messages: conversation, providerOptions: { reasoningEffort: "low", reasoningMode: "pro" }, @@ -493,7 +493,7 @@ describe("OpenAI Responses effort updates", () => { it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }), + ai.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }), ) expect(updates(prepared.body)).toHaveLength(supported ? 1 : 0) @@ -505,14 +505,14 @@ describe("OpenAI Responses effort updates", () => { it.effect("honors the compatibility override in both directions", () => Effect.gen(function* () { const enabled = yield* compileRequest( - LLM.request({ + ai.request({ model: openai("gpt-5.5", { supportsEffortUpdates: true }), messages: conversation, providerOptions: { reasoningEffort: "low" }, }), ) const disabled = yield* compileRequest( - LLM.request({ + ai.request({ model: openai("gpt-6-astra", { supportsEffortUpdates: false }), messages: conversation, providerOptions: { reasoningEffort: "low" }, @@ -525,7 +525,7 @@ describe("OpenAI Responses effort updates", () => { ) const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" } - const compactRequest = LLM.request({ + const compactRequest = ai.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-astra"), messages: conversation, providerOptions: { reasoningEffort: "low" }, @@ -549,7 +549,7 @@ describe("OpenAI Responses effort updates", () => { ), ).effect("keeps configuration updates in the checkpoint body", () => Effect.gen(function* () { - const result = yield* LLMClient.compact(compactRequest, { mechanism: "trigger" }) + const result = yield* ai.compact(compactRequest, { mechanism: "trigger" }) expect(result.checkpoint.encrypted).toBe("opaque") }), ) @@ -567,7 +567,7 @@ describe("OpenAI Responses effort updates", () => { ), ).effect("drops markers from the compaction endpoint body", () => Effect.gen(function* () { - const result = yield* LLMClient.compact(compactRequest, { mechanism: "endpoint" }) + const result = yield* ai.compact(compactRequest, { mechanism: "endpoint" }) expect(result.replacement.map((message) => message.content[0]?.type)).toEqual(["compaction"]) }), ) diff --git a/packages/ai/test/endpoint.test.ts b/packages/ai/test/endpoint.test.ts index d171ce5c33d6..4fb4eddef530 100644 --- a/packages/ai/test/endpoint.test.ts +++ b/packages/ai/test/endpoint.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" -import { LLM } from "../src/effect.js" +import { ai } from "../src/effect.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import { Endpoint } from "../src/route.js" import { LanguageModel } from "../src/schema/index.js" const request = () => - LLM.request({ + ai.request({ model: LanguageModel.make({ id: "model-1", provider: "test", diff --git a/packages/ai/test/error-partial.test.ts b/packages/ai/test/error-partial.test.ts new file mode 100644 index 000000000000..5f2533dbdf69 --- /dev/null +++ b/packages/ai/test/error-partial.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { ai, LanguageModel, LLMEvent, ProviderInternalError, tool } from "../src/effect.js" +import { AI, AIError } from "../src/index.js" +import { OpenAIChat } from "../src/protocols/openai-chat.js" +import { TestLLM } from "../src/testing.js" +import { testEffect } from "./lib/effect.js" + +const model = LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }) +const it = testEffect(TestLLM.testLayer()) +const overloaded = new AIError({ reason: new ProviderInternalError({ message: "Overloaded" }) }) + +const textThenFail = TestLLM.failAfter( + overloaded, + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-0" }), + LLMEvent.textDelta({ id: "text-0", text: "Partial answer" }), + LLMEvent.stepFinish({ + index: 0, + reason: { normalized: "stop" }, + usage: { inputTokens: 5, nonCachedInputTokens: 5, outputTokens: 2 }, + }), +) + +describe("AIError.partial", () => { + it.effect("generate keeps the output that arrived before a mid-stream failure", () => + Effect.gen(function* () { + const client = yield* TestLLM.Test + yield* client.push(textThenFail) + + const error = yield* ai.generate({ model, prompt: "Hello" }).pipe(Effect.flip) + + expect(error.reason).toBe(overloaded.reason) + expect(error.partial?.text).toBe("Partial answer") + expect(error.partial?.message.content).toEqual([{ type: "text", text: "Partial answer" }]) + expect(error.partial?.usage?.outputTokens).toBe(2) + expect(error.partial?.finishReason).toEqual({ normalized: "error" }) + }), + ) + + it.effect("generate keeps the same error when the failure comes before any output", () => + Effect.gen(function* () { + const client = yield* TestLLM.Test + yield* client.push(TestLLM.failAfter(overloaded, LLMEvent.stepStart({ index: 0 }))) + + expect(yield* ai.generate({ model, prompt: "Hello" }).pipe(Effect.flip)).toBe(overloaded) + }), + ) + + it.effect("generateObject keeps the raw output when the token limit cuts off the forced tool call", () => + Effect.gen(function* () { + const client = yield* TestLLM.Test + yield* client.push( + TestLLM.complete( + { reason: { normalized: "length" } }, + LLMEvent.textStart({ id: "text-0" }), + LLMEvent.textDelta({ id: "text-0", text: "Thinking about the answer" }), + ), + ) + + const error = yield* ai + .generateObject({ + model, + prompt: "Answer", + schema: Schema.Struct({ answer: Schema.String }), + }) + .pipe(Effect.flip) + + expect(error.message).toContain("token limit") + expect(error.partial?.text).toBe("Thinking about the answer") + expect(error.partial?.finishReason.normalized).toBe("length") + }), + ) + + it.effect("loop reports only the failed step as partial", () => + Effect.gen(function* () { + const client = yield* TestLLM.Test + yield* client.push(TestLLM.toolCall("call-1", "echo", { value: "hi" })) + yield* client.push(textThenFail) + + const echo = tool({ + description: "Echo a value.", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + execute: ({ value }) => Effect.succeed(value), + }) + const error = yield* ai.loop({ model, prompt: "Echo hi.", tools: { echo } }).pipe(Stream.runDrain, Effect.flip) + + expect(error.partial?.text).toBe("Partial answer") + expect(error.partial?.toolCalls).toEqual([]) + }), + ) +}) + +describe("AIError.partial from the promise client", () => { + test("rejects with the root-exported AIError carrying partial", async () => { + const client = AI.make({ clients: TestLLM.testLayer({ fallback: textThenFail }) }) + + const error = await client.generate({ model, prompt: "Hello" }).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(AIError) + expect(error instanceof AIError && error.partial?.text).toBe("Partial answer") + await client.dispose() + }) +}) diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 9725220a7626..94e272456a25 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect" import { Headers, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http" import { Socket } from "effect/socket" -import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError, isRetryable } from "../src/effect.js" +import { ai, AIError, HttpContext, InvalidProviderOutputError, isRetryable, TransportError } from "../src/effect.js" import { LLMClient, RequestExecutor, @@ -697,7 +697,7 @@ describe("RequestExecutor", () => { Effect.gen(function* () { const attempts = yield* Ref.make(0) const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" }) - const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + const error = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe( Effect.provide( dynamicResponse((input) => Ref.update(attempts, (value) => value + 1).pipe( @@ -728,7 +728,7 @@ describe("RequestExecutor", () => { 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 request = ai.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" }), diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index dce07d590055..5c906beacb4f 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -1,24 +1,17 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { + ai, AIClient, AIError, - Decision, DecisionClient, Generation, - Image, ImageClient, LanguageModel, - LLM, LLMClient, Media, - Speech, - SpeechClient, - SpeechEvent, - TranscriptionClient, - Video, - VideoClient, } from "@opencode/ai/effect" +import { Speech, SpeechClient, SpeechEvent, TranscriptionClient, Video, VideoClient } from "@opencode/ai/experimental" import { Route, Protocol, RequestExecutor, WebSocketTransport } from "@opencode/ai/route" import { Alibaba, @@ -71,13 +64,13 @@ describe("public exports", () => { }) test("root exposes app-facing runtime APIs", () => { - expect(LLM.request).toBeFunction() + expect(ai.request).toBeFunction() expect(LLMClient.Service).toBeFunction() expect(LLMClient.layer).toBeDefined() expect(AIError).toBeFunction() expect(LanguageModel.make).toBeFunction() expect(Media.bytes).toBeFunction() - expect(Image.generate).toBeFunction() + expect(ai.image.generate).toBeFunction() expect(Video.start).toBeFunction() expect(Video.resume).toBeFunction() expect(VideoClient.layer).toBeDefined() @@ -88,7 +81,7 @@ describe("public exports", () => { expect(Generation).toBeFunction() expect(TestLLM.testLayer).toBeFunction() expect(TestLLM.Test.of).toBeFunction() - expect(Decision.generate).toBeFunction() + expect(ai.decision.generate).toBeFunction() expect(DecisionClient.layer).toBeDefined() expect(DecisionClient.fetchLayer).toBeDefined() }) diff --git a/packages/ai/test/generate-object.test.ts b/packages/ai/test/generate-object.test.ts index cb4486a6300c..a2acd3417301 100644 --- a/packages/ai/test/generate-object.test.ts +++ b/packages/ai/test/generate-object.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { LLM } from "../src/effect.js" +import { ai } from "../src/effect.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import { Auth } from "../src/route.js" -import { Tool, toDefinitions } from "../src/tool.js" +import { toDefinitions, tool } from "../src/tool.js" import { it } from "./lib/effect.js" import { dynamicResponse } from "./lib/http.js" import { finishChunk, toolCallChunk } from "./lib/openai-chunks.js" @@ -26,14 +26,14 @@ const Json = Schema.fromJsonString(Schema.Unknown) const decodeJson = Schema.decodeUnknownSync(Json) const decodeBody = (text: string): OpenAIChatBody => decodeJson(text) as OpenAIChatBody -describe("Tool.make (dynamic JSON Schema)", () => { +describe("tool (dynamic JSON Schema)", () => { test("forwards JSON Schema and description through toDefinitions", () => { const jsonSchema = { type: "object" as const, properties: { city: { type: "string" } }, required: ["city"], } - const lookup = Tool.make({ + const lookup = tool({ description: "Look up something", jsonSchema, execute: () => Effect.succeed({ ok: true }), @@ -47,7 +47,7 @@ describe("Tool.make (dynamic JSON Schema)", () => { it.effect("execute receives the raw input untouched", () => Effect.gen(function* () { const seen: unknown[] = [] - const tool = Tool.make({ + const echo = tool({ description: "echo", jsonSchema: { type: "object" }, execute: (params) => @@ -56,14 +56,14 @@ describe("Tool.make (dynamic JSON Schema)", () => { return { ok: true } }), }) - const result = yield* tool.execute({ hello: "world" }) + const result = yield* echo.execute({ hello: "world" }) expect(seen).toEqual([{ hello: "world" }]) expect(result).toEqual({ ok: true }) }), ) }) -describe("LLM.generateObject", () => { +describe("ai.generateObject", () => { it.effect("forces a synthetic tool call and decodes the input", () => Effect.gen(function* () { const bodies: OpenAIChatBody[] = [] @@ -80,11 +80,13 @@ describe("LLM.generateObject", () => { }), ) - const response = yield* LLM.generateObject({ - model, - prompt: "Return a structured weather report.", - schema: Schema.Struct({ city: Schema.String, temp: Schema.Number }), - }).pipe(Effect.provide(layer)) + const response = yield* ai + .generateObject({ + model, + prompt: "Return a structured weather report.", + schema: Schema.Struct({ city: Schema.String, temp: Schema.Number }), + }) + .pipe(Effect.provide(layer)) expect(response.object).toEqual({ city: "Paris", temp: 22 }) expect(response.response.toolCalls).toHaveLength(1) @@ -121,15 +123,17 @@ describe("LLM.generateObject", () => { }), ) - const response = yield* LLM.generateObject({ - model, - prompt: "Extract the user.", - jsonSchema: { - type: "object", - properties: { name: { type: "string" }, age: { type: "number" } }, - required: ["name", "age"], - }, - }).pipe(Effect.provide(layer)) + const response = yield* ai + .generateObject({ + model, + prompt: "Extract the user.", + jsonSchema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }, + }) + .pipe(Effect.provide(layer)) expect(response.object).toEqual({ name: "Ada", age: 30 }) expect(bodies[0].tools?.[0]?.function.parameters).toEqual({ @@ -150,11 +154,13 @@ describe("LLM.generateObject", () => { ), ) - const exit = yield* LLM.generateObject({ - model, - prompt: "Return a structured value.", - schema: Schema.Struct({ value: Schema.Number }), - }).pipe(Effect.provide(layer), Effect.exit) + const exit = yield* ai + .generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }) + .pipe(Effect.provide(layer), Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -174,11 +180,13 @@ describe("LLM.generateObject", () => { ), ) - const exit = yield* LLM.generateObject({ - model, - prompt: "Return a structured value.", - schema: Schema.Struct({ value: Schema.Number }), - }).pipe(Effect.provide(layer), Effect.exit) + const exit = yield* ai + .generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }) + .pipe(Effect.provide(layer), Effect.exit) expect(exit._tag).toBe("Failure") }), diff --git a/packages/ai/test/http-timeout.test.ts b/packages/ai/test/http-timeout.test.ts index 08ea1096bb88..923d9f72da7b 100644 --- a/packages/ai/test/http-timeout.test.ts +++ b/packages/ai/test/http-timeout.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber } from "effect" import * as TestClock from "effect/testing/TestClock" -import { HttpOptions, LLM } from "../src/effect.js" +import { ai, HttpOptions } from "../src/effect.js" import { LLMClient, mergeHttpOptions } from "../src/route.js" import { configure } from "../src/providers/openai.js" import { dynamicResponse } from "./lib/http.js" @@ -52,7 +52,7 @@ const stalledServer = Effect.gen(function* () { describe("HTTP transport timeouts", () => { it.effect("fails when response headers take longer than five minutes", () => Effect.gen(function* () { - const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const fiber = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true }), @@ -72,7 +72,7 @@ describe("HTTP transport timeouts", () => { it.effect("fails when the response body stalls for fifteen minutes", () => Effect.gen(function* () { const server = yield* stalledServer - const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const fiber = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide(server.layer), Effect.flip, Effect.forkChild({ startImmediately: true }), @@ -90,7 +90,7 @@ describe("HTTP transport timeouts", () => { it.effect("applies a configured header timeout", () => Effect.gen(function* () { const fiber = yield* LLMClient.generate( - LLM.request({ model, prompt: "Hello", http: { headerTimeout: 1_000 } }), + ai.request({ model, prompt: "Hello", http: { headerTimeout: 1_000 } }), ).pipe(Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true })) yield* TestClock.adjust("1 second") const error = yield* Fiber.join(fiber) @@ -102,7 +102,7 @@ describe("HTTP transport timeouts", () => { it.effect("disables the header timeout with false", () => Effect.gen(function* () { const fiber = yield* LLMClient.generate( - LLM.request({ model, prompt: "Hello", http: { headerTimeout: false } }), + ai.request({ model, prompt: "Hello", http: { headerTimeout: false } }), ).pipe(Effect.provide(slowHeadersServer), Effect.forkChild({ startImmediately: true })) yield* TestClock.adjust("10 minutes") const response = yield* Fiber.join(fiber) @@ -115,7 +115,7 @@ describe("HTTP transport timeouts", () => { Effect.gen(function* () { const server = yield* stalledServer const fiber = yield* LLMClient.generate( - LLM.request({ model, prompt: "Hello", http: { chunkTimeout: false } }), + ai.request({ model, prompt: "Hello", http: { chunkTimeout: false } }), ).pipe(Effect.provide(server.layer), Effect.forkChild({ startImmediately: true })) yield* Deferred.await(server.stalled) yield* Effect.yieldNow @@ -131,7 +131,7 @@ describe("HTTP transport timeouts", () => { Effect.gen(function* () { const server = yield* stalledServer const fiber = yield* LLMClient.generate( - LLM.request({ model, prompt: "Hello", http: { timeout: 60_000, chunkTimeout: false } }), + ai.request({ model, prompt: "Hello", http: { timeout: 60_000, chunkTimeout: false } }), ).pipe(Effect.provide(server.layer), Effect.flip, Effect.forkChild({ startImmediately: true })) yield* Deferred.await(server.stalled) yield* Effect.yieldNow @@ -145,7 +145,7 @@ describe("HTTP transport timeouts", () => { it.effect("bounds the header wait by a shorter whole-request timeout", () => Effect.gen(function* () { const fiber = yield* LLMClient.generate( - LLM.request({ model, prompt: "Hello", http: { timeout: 1_000, headerTimeout: false } }), + ai.request({ model, prompt: "Hello", http: { timeout: 1_000, headerTimeout: false } }), ).pipe(Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true })) yield* TestClock.adjust("1 second") const error = yield* Fiber.join(fiber) diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index fe49cfb341cc..1a8f1f5aca44 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Stream } from "effect" import { HttpClientRequest } from "effect/http" -import { Image, ImageClient, Media } from "../src/effect.js" +import { ai, ImageClient, Media } from "../src/effect.js" import { Google, OpenAI, OpenAICompatible, OpenRouter, VercelAIGateway, XAI, ZAI } from "../src/providers.js" import { it } from "./lib/effect.js" import { dynamicResponse, json } from "./lib/http.js" @@ -15,7 +15,7 @@ describe("Image", () => { for (const body of ['{"data":42,"candidates":42,"opaque":{"nested":[1,2]},"trace":"outer"}', '{"invalid":']) { it.effect(`retains ${model.provider} image response body and decode cause: ${body}`, () => Effect.gen(function* () { - const error = yield* Image.generate({ model, prompt: "hello" }).pipe(Effect.flip) + const error = yield* ai.image.generate({ model, prompt: "hello" }).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.message).toContain("invalid response") expect(error.reason.body).toBe(body) @@ -43,7 +43,7 @@ describe("Image", () => { it.effect("generates images through the OpenAI Images API", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1", @@ -132,7 +132,7 @@ describe("Image", () => { Effect.gen(function* () { const openai = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }) expect(openai.image("gpt-image-2").route.id).toBe("openai-images") - const response = yield* Image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }).pipe( + const response = yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }).pipe( Effect.provide( ImageClient.layer.pipe( Layer.provide( @@ -165,10 +165,10 @@ describe("Image", () => { const compatible = OpenAICompatible.configure({ apiKey: "test", baseURL: "https://compatible.test/v1" }) const xai = XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }) const generate = ( - model: Parameters[0]["model"], + model: Parameters[0]["model"], providerOptions?: Record, ) => - Image.generate({ model, prompt: "A lighthouse", providerOptions }).pipe( + ai.image.generate({ model, prompt: "A lighthouse", providerOptions }).pipe( Effect.provide( layer((input) => { bodies.push(JSON.parse(input.text)) @@ -197,10 +197,12 @@ describe("Image", () => { describe("OpenAI-style url results", () => { const generate = (data: unknown) => - Image.generate({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("dall-e-3"), - prompt: "A lighthouse", - }).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { data }))))) + ai.image + .generate({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("dall-e-3"), + prompt: "A lighthouse", + }) + .pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { data }))))) it.effect("keeps an http url as a URL asset", () => Effect.gen(function* () { @@ -239,10 +241,12 @@ describe("Image", () => { it.effect("classifies an OpenAI stream moderation error event as a content policy rejection", () => Effect.gen(function* () { - const error = yield* Image.stream({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-1-mini"), - prompt: "A lighthouse", - }).pipe(Stream.runDrain, Effect.flip) + const error = yield* ai.image + .stream({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-1-mini"), + prompt: "A lighthouse", + }) + .pipe(Stream.runDrain, Effect.flip) expect(error.reason._tag).toBe("ContentPolicy") expect(error.message).toContain("rejected by the safety system") }).pipe( @@ -271,10 +275,12 @@ describe("Image", () => { it.effect("ignores unknown OpenAI image stream events", () => Effect.gen(function* () { - const events = yield* Image.stream({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-1-mini"), - prompt: "A lighthouse", - }).pipe(Stream.runCollect) + const events = yield* ai.image + .stream({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-1-mini"), + prompt: "A lighthouse", + }) + .pipe(Stream.runCollect) expect(Array.from(events).map((event) => event.type)).toEqual(["image", "finish"]) }).pipe( Effect.provide( @@ -302,17 +308,19 @@ describe("Image", () => { it.effect("lowers aspect to the nearest OpenAI size", () => Effect.gen(function* () { const openai = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }) - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openai.image("gpt-image-1.5"), prompt: "A lighthouse", aspect: "16:9", }) - const conflict = yield* Image.generate({ - model: openai.image("gpt-image-2"), - prompt: "A lighthouse", - aspect: "16:9", - providerOptions: { size: "1024x1024" }, - }).pipe(Effect.flip) + const conflict = yield* ai.image + .generate({ + model: openai.image("gpt-image-2"), + prompt: "A lighthouse", + aspect: "16:9", + providerOptions: { size: "1024x1024" }, + }) + .pipe(Effect.flip) expect(conflict.reason._tag).toBe("InvalidRequest") }).pipe( Effect.provide( @@ -326,7 +334,7 @@ describe("Image", () => { it.effect("computes a free size for gateway-hosted models", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: VercelAIGateway.configure({ apiKey: "test", baseURL: "https://gateway.test/v1" }).image( "bfl/flux-2-klein-4b", ), @@ -357,176 +365,191 @@ describe("Image", () => { const openrouter = OpenRouter.configure({ apiKey: "test", baseURL: "https://openrouter.test/v1" }) it.effect("sends OpenRouter an aspect ratio and resolution tier instead of pixels", () => - Image.generate({ - model: openrouter.image("google/gemini-3.1-flash-image"), - prompt, - aspect: "16:9", - resolution: "0.5K", - }).pipe( - Effect.provide( - sent({ model: "google/gemini-3.1-flash-image", prompt, aspect_ratio: "16:9", resolution: "512" }), + ai.image + .generate({ + model: openrouter.image("google/gemini-3.1-flash-image"), + prompt, + aspect: "16:9", + resolution: "0.5K", + }) + .pipe( + Effect.provide( + sent({ model: "google/gemini-3.1-flash-image", prompt, aspect_ratio: "16:9", resolution: "512" }), + ), ), - ), ) it.effect("leaves out a resolution OpenRouter doesn't list", () => - Image.generate({ model: openrouter.image("x/y"), prompt, aspect: "1:1", resolution: "8K" }).pipe( - Effect.provide(sent({ model: "x/y", prompt, aspect_ratio: "1:1" })), - ), + ai.image + .generate({ model: openrouter.image("x/y"), prompt, aspect: "1:1", resolution: "8K" }) + .pipe(Effect.provide(sent({ model: "x/y", prompt, aspect_ratio: "1:1" }))), ) for (const modelID of ["spacexai/grok-imagine-image", "xai/grok-imagine-image"]) it.effect(`sizes Vercel AI Gateway ${modelID} through xAI provider options`, () => - Image.generate({ - model: VercelAIGateway.configure({ apiKey: "test", baseURL: "https://gateway.test/v1" }).image(modelID), - prompt, - aspect: "16:9", - resolution: "2K", - }).pipe( - Effect.provide( - sent({ model: modelID, prompt, providerOptions: { xai: { aspect_ratio: "16:9", resolution: "2k" } } }), + ai.image + .generate({ + model: VercelAIGateway.configure({ apiKey: "test", baseURL: "https://gateway.test/v1" }).image(modelID), + prompt, + aspect: "16:9", + resolution: "2K", + }) + .pipe( + Effect.provide( + sent({ model: modelID, prompt, providerOptions: { xai: { aspect_ratio: "16:9", resolution: "2k" } } }), + ), ), - ), ) it.effect("names Google's half tier 512", () => - Image.generate({ - model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image( - "gemini-3.1-flash-image", - ), - prompt, - aspect: "1:1", - resolution: "0.5K", - }).pipe( - Effect.provide( - layer((input) => { - expect(JSON.parse(input.text).generationConfig.imageConfig).toEqual({ - aspectRatio: "1:1", - imageSize: "512", - }) - return Effect.succeed( - json(input, { - candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }], - }), - ) - }), - ), - ), - ) - }) - - it.effect("preserves native snake_case and unknown request options", () => - Image.generate({ - model: OpenAI.configure({ - apiKey: "test", - baseURL: "https://api.openai.test/v1", - }).image("future-image-model"), - prompt: "A lighthouse in fog", - providerOptions: { - outputFormat: "jpeg", - output_format: "avif", - outputCompression: 30, - output_compression: 40, - provider_future_option: { enabled: true }, - }, - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.image.mediaType).toBe("image/avif") - }), - ), - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toEqual({ - model: "future-image-model", - prompt: "A lighthouse in fog", - output_format: "avif", - output_compression: 40, - provider_future_option: { enabled: true }, + ai.image + .generate({ + model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image( + "gemini-3.1-flash-image", + ), + prompt, + aspect: "1:1", + resolution: "0.5K", + }) + .pipe( + Effect.provide( + layer((input) => { + expect(JSON.parse(input.text).generationConfig.imageConfig).toEqual({ + aspectRatio: "1:1", + imageSize: "512", }) return Effect.succeed( - input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }], output_format: "avif" }), { - headers: { "content-type": "application/json" }, + json(input, { + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }], }), ) }), ), ), + ) + }) + + it.effect("preserves native snake_case and unknown request options", () => + ai.image + .generate({ + model: OpenAI.configure({ + apiKey: "test", + baseURL: "https://api.openai.test/v1", + }).image("future-image-model"), + prompt: "A lighthouse in fog", + providerOptions: { + outputFormat: "jpeg", + output_format: "avif", + outputCompression: 30, + output_compression: 40, + provider_future_option: { enabled: true }, + }, + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.image.mediaType).toBe("image/avif") + }), + ), + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toEqual({ + model: "future-image-model", + prompt: "A lighthouse in fog", + output_format: "avif", + output_compression: 40, + provider_future_option: { enabled: true }, + }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }], output_format: "avif" }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), + ), + ), ), - ), ) it.effect("routes OpenAI byte inputs and masks through multipart edits", () => - Image.generate({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"), - prompt: "Combine these images", - images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), Media.fromDataUrl("data:image/jpeg;base64,BAUG")], - providerOptions: { - mask: Media.bytes(Uint8Array.from([7, 8, 9]), "image/png"), - quality: "high", - future_option: true, - }, - http: { - body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" }, - headers: { "content-type": "application/json" }, - }, - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(request.url).toBe("https://api.openai.test/v1/images/edits") - expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=") - expect(input.text).toContain('name="model"\r\n\r\nfuture-model') - expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images') - expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2) - expect(input.text).toContain('name="mask"') - expect(input.text).toContain('name="quality"\r\n\r\nlow') - expect(input.text).not.toContain("corrupt") - return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), { - headers: { "content-type": "application/json" }, - }) - }), + ai.image + .generate({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"), + prompt: "Combine these images", + images: [ + Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), + Media.fromDataUrl("data:image/jpeg;base64,BAUG"), + ], + providerOptions: { + mask: Media.bytes(Uint8Array.from([7, 8, 9]), "image/png"), + quality: "high", + future_option: true, + }, + http: { + body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" }, + headers: { "content-type": "application/json" }, + }, + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe("https://api.openai.test/v1/images/edits") + expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=") + expect(input.text).toContain('name="model"\r\n\r\nfuture-model') + expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images') + expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2) + expect(input.text).toContain('name="mask"') + expect(input.text).toContain('name="quality"\r\n\r\nlow') + expect(input.text).not.toContain("corrupt") + return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), { + headers: { "content-type": "application/json" }, + }) + }), + ), ), ), ), ), - ), ) it.effect("routes OpenAI URL and file inputs through JSON edits", () => - Image.generate({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"), - prompt: "Combine these images", - images: [Media.url("https://example.test/source.png"), Media.ref("openai", "file_123")], - providerOptions: { mask: Media.ref("openai", "file_mask") }, - http: { body: { future_option: true } }, - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toEqual({ - model: "future-model", - prompt: "Combine these images", - images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }], - mask: { file_id: "file_mask" }, - future_option: true, - }) - return Effect.succeed( - input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), { - headers: { "content-type": "application/json" }, - }), - ) - }), + ai.image + .generate({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"), + prompt: "Combine these images", + images: [Media.url("https://example.test/source.png"), Media.ref("openai", "file_123")], + providerOptions: { mask: Media.ref("openai", "file_mask") }, + http: { body: { future_option: true } }, + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toEqual({ + model: "future-model", + prompt: "Combine these images", + images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }], + mask: { file_id: "file_mask" }, + future_option: true, + }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), ), ), ), - ), ) describe("OpenRouter image inputs", () => { @@ -543,40 +566,44 @@ describe("Image", () => { ) it.effect("sends a plain generation unchanged", () => - Image.generate({ model: openrouter.image("openai/gpt-image-1-mini"), prompt: "A lighthouse" }).pipe( - Effect.provide(sent({ model: "openai/gpt-image-1-mini", prompt: "A lighthouse" })), - ), + ai.image + .generate({ model: openrouter.image("openai/gpt-image-1-mini"), prompt: "A lighthouse" }) + .pipe(Effect.provide(sent({ model: "openai/gpt-image-1-mini", prompt: "A lighthouse" }))), ) it.effect("sends input images as input references on the generations endpoint", () => - Image.generate({ - model: openrouter.image("openai/gpt-image-1-mini"), - prompt: "Combine these images", - images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), Media.url("https://example.test/source.png")], - providerOptions: { quality: "low" }, - }).pipe( - Effect.provide( - sent({ - model: "openai/gpt-image-1-mini", - prompt: "Combine these images", - input_references: [ - { type: "image_url", image_url: { url: "data:image/png;base64,AQID" } }, - { type: "image_url", image_url: { url: "https://example.test/source.png" } }, - ], - quality: "low", - }), + ai.image + .generate({ + model: openrouter.image("openai/gpt-image-1-mini"), + prompt: "Combine these images", + images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), Media.url("https://example.test/source.png")], + providerOptions: { quality: "low" }, + }) + .pipe( + Effect.provide( + sent({ + model: "openai/gpt-image-1-mini", + prompt: "Combine these images", + input_references: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AQID" } }, + { type: "image_url", image_url: { url: "https://example.test/source.png" } }, + ], + quality: "low", + }), + ), ), - ), ) it.effect("rejects a mask", () => Effect.gen(function* () { - const error = yield* Image.generate({ - model: openrouter.image("openai/gpt-image-1-mini"), - prompt: "Paint the masked area", - images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")], - providerOptions: { mask: Media.bytes(Uint8Array.from([7, 8, 9]), "image/png") }, - }).pipe(Effect.flip) + const error = yield* ai.image + .generate({ + model: openrouter.image("openai/gpt-image-1-mini"), + prompt: "Paint the masked area", + images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")], + providerOptions: { mask: Media.bytes(Uint8Array.from([7, 8, 9]), "image/png") }, + }) + .pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("openrouter image edits take no mask") }).pipe(Effect.provide(layer(() => Effect.die("no request expected")))), @@ -584,93 +611,99 @@ describe("Image", () => { }) it.effect("sends OpenAI edits as JSON to the ChatGPT backend, which rejects multipart", () => - Image.generate({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://chatgpt.com/backend-api/codex" }).image( - "gpt-image-2", - ), - prompt: "Add a red cap", - images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")], - }).pipe( - Effect.provide( - layer((input) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(request.url).toBe("https://chatgpt.com/backend-api/codex/images/edits") - expect(request.headers.get("content-type")).toBe("application/json") - expect(JSON.parse(input.text)).toEqual({ - model: "gpt-image-2", - prompt: "Add a red cap", - images: [{ image_url: "data:image/png;base64,AQID" }], - }) - return json(input, { data: [{ b64_json: "AQID" }] }) - }), + ai.image + .generate({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://chatgpt.com/backend-api/codex" }).image( + "gpt-image-2", + ), + prompt: "Add a red cap", + images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")], + }) + .pipe( + Effect.provide( + layer((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe("https://chatgpt.com/backend-api/codex/images/edits") + expect(request.headers.get("content-type")).toBe("application/json") + expect(JSON.parse(input.text)).toEqual({ + model: "gpt-image-2", + prompt: "Add a red cap", + images: [{ image_url: "data:image/png;base64,AQID" }], + }) + return json(input, { data: [{ b64_json: "AQID" }] }) + }), + ), ), ), - ), ) it.effect("routes ordered xAI image inputs through JSON edits", () => - Image.generate({ - model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"), - prompt: "Combine these images", - images: [ - Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), - Media.url("https://example.test/source.jpg"), - Media.ref("xai", "file_123"), - ], - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toEqual({ - model: "future-model", - prompt: "Combine these images", - images: [ - { url: "data:image/png;base64,AQID", type: "image_url" }, - { url: "https://example.test/source.jpg", type: "image_url" }, - { file_id: "file_123" }, - ], - response_format: "b64_json", - }) - return Effect.succeed( - input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), { - headers: { "content-type": "application/json" }, - }), - ) - }), + ai.image + .generate({ + model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"), + prompt: "Combine these images", + images: [ + Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), + Media.url("https://example.test/source.jpg"), + Media.ref("xai", "file_123"), + ], + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toEqual({ + model: "future-model", + prompt: "Combine these images", + images: [ + { url: "data:image/png;base64,AQID", type: "image_url" }, + { url: "https://example.test/source.jpg", type: "image_url" }, + { file_id: "file_123" }, + ], + response_format: "b64_json", + }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), ), ), ), - ), ) it.effect("uses xAI's singular image field for one input", () => - Image.generate({ - model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"), - prompt: "Edit this image", - images: [Media.ref("xai", "file_123")], - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toEqual({ - model: "future-model", - prompt: "Edit this image", - image: { file_id: "file_123" }, - response_format: "b64_json", - }) - return Effect.succeed( - input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), { - headers: { "content-type": "application/json" }, - }), - ) - }), + ai.image + .generate({ + model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"), + prompt: "Edit this image", + images: [Media.ref("xai", "file_123")], + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toEqual({ + model: "future-model", + prompt: "Edit this image", + image: { file_id: "file_123" }, + response_format: "b64_json", + }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), ), ), ), - ), ) it.effect("decodes URL images and rejects items with neither data nor a URL", () => @@ -680,65 +713,69 @@ describe("Image", () => { layer((input) => Effect.succeed(input.respond(JSON.stringify({ data }), { headers: { "content-type": "application/json" } })), ) - const response = yield* Image.generate({ model, prompt: "A kite" }).pipe( - Effect.provide(respond([{ url: "https://xai.test/a.png", mime_type: "image/png" }])), - ) + const response = yield* ai.image + .generate({ model, prompt: "A kite" }) + .pipe(Effect.provide(respond([{ url: "https://xai.test/a.png", mime_type: "image/png" }]))) expect(response.images[0].source).toEqual({ type: "url", url: "https://xai.test/a.png", mediaType: "image/png" }) - const error = yield* Image.generate({ model, prompt: "A kite" }).pipe(Effect.provide(respond([{}])), Effect.flip) + const error = yield* ai.image + .generate({ model, prompt: "A kite" }) + .pipe(Effect.provide(respond([{}])), Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.message).toContain("xAI Images result 0 has neither image data nor a URL") }), ) it.effect("lowers ordered Google image inputs into generateContent parts", () => - Image.generate({ - model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"), - prompt: "Combine these images", - images: [ - Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), - Media.fromDataUrl("data:image/jpeg;base64,BAUG"), - Media.ref("google", "https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"), - ], - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text).contents[0].parts).toEqual([ - { text: "Combine these images" }, - { inlineData: { mimeType: "image/png", data: "AQID" } }, - { inlineData: { mimeType: "image/jpeg", data: "BAUG" } }, - { - fileData: { - mimeType: "image/webp", - fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123", + ai.image + .generate({ + model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"), + prompt: "Combine these images", + images: [ + Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), + Media.fromDataUrl("data:image/jpeg;base64,BAUG"), + Media.ref("google", "https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"), + ], + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text).contents[0].parts).toEqual([ + { text: "Combine these images" }, + { inlineData: { mimeType: "image/png", data: "AQID" } }, + { inlineData: { mimeType: "image/jpeg", data: "BAUG" } }, + { + fileData: { + mimeType: "image/webp", + fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123", + }, }, - }, - ]) - return Effect.succeed( - input.respond( - JSON.stringify({ - candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }], - }), - { headers: { "content-type": "application/json" } }, - ), - ) - }), + ]) + return Effect.succeed( + input.respond( + JSON.stringify({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }], + }), + { headers: { "content-type": "application/json" } }, + ), + ) + }), + ), ), ), ), - ), ) it.effect("rejects unsupported provider inputs before sending", () => Effect.gen(function* () { const cases = [ - Image.generate({ + ai.image.generate({ model: Google.configure({ apiKey: "test" }).image("model"), prompt: "edit", images: [Media.url("https://example.test/image.png")], }), - Image.generate({ + ai.image.generate({ model: ZAI.configure({ apiKey: "test" }).image("model"), prompt: "edit", images: [Media.bytes(Uint8Array.from([1]), "image/png")], @@ -763,7 +800,7 @@ describe("Image", () => { it.effect("generates images through the Google generateContent API", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta/", @@ -952,101 +989,105 @@ describe("Image", () => { ) it.effect("returns the image next to filtered Google candidates", () => - Image.generate({ - model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image( - "gemini-3.1-flash-image", - ), - prompt: "A robot tending a rooftop garden", - }).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.images).toHaveLength(1) - expect(response.providerMetadata?.google).toMatchObject({ promptFeedback: { blockReason: "OTHER" } }) - }), - ), - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => - Effect.succeed( - input.respond( - JSON.stringify({ - promptFeedback: { blockReason: "OTHER" }, - candidates: [ - { - content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] }, - finishReason: "STOP", - }, - { - index: 1, - content: { parts: [{ text: "blocked" }] }, - finishReason: "IMAGE_SAFETY", - finishMessage: "Blocked.", - safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }], - }, - ], - }), - { headers: { "content-type": "application/json" } }, + ai.image + .generate({ + model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image( + "gemini-3.1-flash-image", + ), + prompt: "A robot tending a rooftop garden", + }) + .pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.images).toHaveLength(1) + expect(response.providerMetadata?.google).toMatchObject({ promptFeedback: { blockReason: "OTHER" } }) + }), + ), + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.succeed( + input.respond( + JSON.stringify({ + promptFeedback: { blockReason: "OTHER" }, + candidates: [ + { + content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] }, + finishReason: "STOP", + }, + { + index: 1, + content: { parts: [{ text: "blocked" }] }, + finishReason: "IMAGE_SAFETY", + finishMessage: "Blocked.", + safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }], + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ), ), ), ), ), ), ), - ), ) it.effect("reports a Google safety stop as a content policy refusal, with its diagnostics", () => - Image.generate({ - model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image( - "gemini-3.1-flash-image", - ), - prompt: "A robot tending a rooftop garden", - }).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.reason._tag).toBe("ContentPolicy") - if (error.reason._tag !== "ContentPolicy") return - expect(error.message).toContain("finish reasons: IMAGE_SAFETY") - expect(JSON.parse(error.reason.body ?? "")).toEqual({ - promptFeedback: { blockReason: "SAFETY" }, - candidates: [ - { - finishReason: "IMAGE_SAFETY", - finishMessage: "The generated image was blocked by safety filters.", - safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }], - content: { parts: [{ text: "blocked", thought: false }] }, - }, - ], - }) - }), - ), - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => - Effect.succeed( - input.respond( - JSON.stringify({ - candidates: [ - { - content: { parts: [{ text: "blocked", thought: false }] }, - finishReason: "IMAGE_SAFETY", - finishMessage: "The generated image was blocked by safety filters.", - safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }], - }, - ], - promptFeedback: { blockReason: "SAFETY" }, - }), - { headers: { "content-type": "application/json" } }, + ai.image + .generate({ + model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image( + "gemini-3.1-flash-image", + ), + prompt: "A robot tending a rooftop garden", + }) + .pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason._tag).toBe("ContentPolicy") + if (error.reason._tag !== "ContentPolicy") return + expect(error.message).toContain("finish reasons: IMAGE_SAFETY") + expect(JSON.parse(error.reason.body ?? "")).toEqual({ + promptFeedback: { blockReason: "SAFETY" }, + candidates: [ + { + finishReason: "IMAGE_SAFETY", + finishMessage: "The generated image was blocked by safety filters.", + safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }], + content: { parts: [{ text: "blocked", thought: false }] }, + }, + ], + }) + }), + ), + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.succeed( + input.respond( + JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: "blocked", thought: false }] }, + finishReason: "IMAGE_SAFETY", + finishMessage: "The generated image was blocked by safety filters.", + safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }], + }, + ], + promptFeedback: { blockReason: "SAFETY" }, + }), + { headers: { "content-type": "application/json" } }, + ), ), ), ), ), ), ), - ), ) it.effect("rejects what a route cannot honor before sending anything", () => @@ -1055,9 +1096,13 @@ describe("Image", () => { const prompt = "A lighthouse" const errors = yield* Effect.all( [ - Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }), - Image.generate({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt, n: 2 }), - Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })), + ai.image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }), + ai.image.generate({ + model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), + prompt, + n: 2, + }), + Stream.runCollect(ai.image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })), ].map((effect) => Effect.flip(effect)), ) expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual( diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts index 9a096918b002..54f2fce168be 100644 --- a/packages/ai/test/image.types.ts +++ b/packages/ai/test/image.types.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import { - Image, + ai, ImageClient, ImageModel, - Media, type ImageModelOptions, type ImageOptions, type ImageRequestFor, + Media, } from "../src/effect.js" import type { Service } from "../src/image-client.js" import { Anthropic, Google, OpenAI, XAI, ZAI } from "../src/providers.js" @@ -25,7 +25,7 @@ declare const google: ImageModel const invalidGoogleOptions: ImageModelOptions = { imageSize: "8K" } void invalidGoogleOptions -Image.generate({ +ai.image.generate({ model: google, prompt: "A lighthouse", images: [ @@ -39,7 +39,7 @@ Image.generate({ }) const googleProvider = Google.configure({ apiKey: "test" }).image("any-model-id") -Image.generate({ +ai.image.generate({ model: googleProvider, prompt: "A lighthouse", aspect: "16:9", @@ -51,7 +51,7 @@ Image.generate({ futureOption: true, }, }) -Image.generate({ +ai.image.generate({ model: googleProvider, prompt: "A lighthouse", resolution: "8K", @@ -60,20 +60,20 @@ Image.generate({ // @ts-expect-error Image generation options are request-scoped, not provider configuration. Google.configure({ image: { providerOptions: { thinkingLevel: "HIGH" } } }) // @ts-expect-error Known Google string options retain their value kind. -Image.generate({ model: googleProvider, prompt: "A lighthouse", providerOptions: { thinkingLevel: 2 } }) +ai.image.generate({ model: googleProvider, prompt: "A lighthouse", providerOptions: { thinkingLevel: 2 } }) // @ts-expect-error Known Google numeric options retain their value kind. -Image.generate({ model: googleProvider, prompt: "A lighthouse", providerOptions: { seed: "7" } }) +ai.image.generate({ model: googleProvider, prompt: "A lighthouse", providerOptions: { seed: "7" } }) // @ts-expect-error Resolution tiers are strings. -Image.generate({ model: googleProvider, prompt: "A lighthouse", resolution: 2 }) +ai.image.generate({ model: googleProvider, prompt: "A lighthouse", resolution: 2 }) // @ts-expect-error Known Google boolean options retain their value kind. -Image.generate({ model: googleProvider, prompt: "A lighthouse", providerOptions: { includeThoughts: "yes" } }) +ai.image.generate({ model: googleProvider, prompt: "A lighthouse", providerOptions: { includeThoughts: "yes" } }) const openai = OpenAI.image("gpt-image-2") // @ts-expect-error Image generation options are request-scoped, not provider configuration. OpenAI.configure({ image: { providerOptions: { quality: "medium" } } }) const futureOpenAIOptions: ImageModelOptions = { quality: "future-quality" } void futureOpenAIOptions -Image.generate({ +ai.image.generate({ model: openai, prompt: "A lighthouse", images: [Media.url("https://example.com/source.png"), Media.ref("openai", "file_123")], @@ -87,34 +87,34 @@ Image.generate({ future_option: true, }, }) -Image.generate({ +ai.image.generate({ model: openai, prompt: "A lighthouse", aspect: "16:9", resolution: "4K", providerOptions: { quality: "future-quality" }, }) -Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { outputFormat: "future-format" } }) -Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { native_future_option: true } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { outputFormat: "future-format" } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { native_future_option: true } }) // @ts-expect-error Known OpenAI string options retain their value kind. -Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { quality: 1 } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { quality: 1 } }) // @ts-expect-error Known OpenAI numeric options retain their value kind. -Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { outputCompression: "80" } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { outputCompression: "80" } }) // @ts-expect-error Partial image counts are numeric. -Image.stream({ model: openai, prompt: "A lighthouse", providerOptions: { partialImages: "1" } }) +ai.image.stream({ model: openai, prompt: "A lighthouse", providerOptions: { partialImages: "1" } }) OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" }) // @ts-expect-error Hosted image generation numeric options retain their value kind. OpenAI.imageGeneration({ partialImages: "2" }) // @ts-expect-error Known Google-like options are inferred from the selected model. -Image.generate({ model: google, prompt: "A lighthouse", providerOptions: { imageSize: "8K" } }) +ai.image.generate({ model: google, prompt: "A lighthouse", providerOptions: { imageSize: "8K" } }) // @ts-expect-error Language models cannot be used for image requests. -Image.generate({ model: Anthropic.configure({ apiKey: "test" }).model("claude-sonnet-4-5"), prompt: "A lighthouse" }) +ai.image.generate({ model: Anthropic.configure({ apiKey: "test" }).model("claude-sonnet-4-5"), prompt: "A lighthouse" }) const xai = XAI.configure({ apiKey: "test" }).image("any-model-id") // @ts-expect-error Image generation options are request-scoped, not provider configuration. XAI.configure({ image: { providerOptions: { responseFormat: "b64_json" } } }) -Image.generate({ +ai.image.generate({ model: xai, prompt: "A lighthouse", images: [Media.fromDataUrl("data:image/png;base64,AQID"), Media.ref("xai", "file_123")], @@ -126,45 +126,45 @@ Image.generate({ future_option: true, }, }) -Image.generate({ +ai.image.generate({ model: xai, prompt: "A lighthouse", providerOptions: { response_format: "b64_json", native_future_option: true }, }) // @ts-expect-error Common count is numeric. -Image.generate({ model: xai, prompt: "A lighthouse", n: "2" }) +ai.image.generate({ model: xai, prompt: "A lighthouse", n: "2" }) // @ts-expect-error Known xAI string options retain their value kind. -Image.generate({ model: xai, prompt: "A lighthouse", providerOptions: { responseFormat: 2 } }) +ai.image.generate({ model: xai, prompt: "A lighthouse", providerOptions: { responseFormat: 2 } }) const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id") // @ts-expect-error Image generation options are request-scoped, not provider configuration. ZAI.configure({ image: { providerOptions: { quality: "hd" } } }) -Image.generate({ +ai.image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { quality: "future-quality", userID: "user-123", future_option: true }, }) -Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { user_id: "raw-user" } }) +ai.image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { user_id: "raw-user" } }) // @ts-expect-error Known Z.ai string options retain their value kind. -Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { quality: 1 } }) +ai.image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { quality: 1 } }) // @ts-expect-error Known Z.ai user IDs retain their value kind. -Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { userID: 1 } }) +ai.image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { userID: 1 } }) declare const generic: ImageModel const widenImage = (model: ImageModel): ImageModel => model void widenImage -Image.generate({ model: generic, prompt: "A lighthouse", providerOptions: { arbitrary: true } }) +ai.image.generate({ model: generic, prompt: "A lighthouse", providerOptions: { arbitrary: true } }) const explicitAsset: Media.Asset = Media.url("https://example.com/image.png") void explicitAsset // @ts-expect-error Raw strings are ambiguous and are not media assets. -Image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] }) +ai.image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] }) // @ts-expect-error Plain source objects must be lifted into `Media.Asset` first. -Image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] }) +ai.image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] }) // @ts-expect-error Masks are media assets, not strings. -Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { mask: "https://example.com/mask.png" } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { mask: "https://example.com/mask.png" } }) -const request = Image.request({ +const request = ai.image.request({ model: google, prompt: "A lighthouse", providerOptions: { imageSize: "1K", futureOption: true }, @@ -176,12 +176,12 @@ type GenerateRequirements = Assert, Service void (true satisfies GenerateRequirements) // @ts-expect-error Image requests use `n`, not `count`. -Image.generate({ model: openai, prompt: "A lighthouse", count: 2 }) +ai.image.generate({ model: openai, prompt: "A lighthouse", count: 2 }) // @ts-expect-error Exact sizes are provider options, not common fields. -Image.generate({ model: openai, prompt: "A lighthouse", size: "1024x1024" }) +ai.image.generate({ model: openai, prompt: "A lighthouse", size: "1024x1024" }) // @ts-expect-error Aspect ratios are `${w}:${h}` strings. -Image.generate({ model: openai, prompt: "A lighthouse", aspect: "wide" }) +ai.image.generate({ model: openai, prompt: "A lighthouse", aspect: "wide" }) // @ts-expect-error Image requests do not expose metadata. -Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } }) // @ts-expect-error `options` was renamed to `providerOptions`. -Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "hd" } }) +ai.image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "hd" } }) diff --git a/packages/ai/test/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts index 649b62184896..43bb18cc19f7 100644 --- a/packages/ai/test/llm-option-types.types.ts +++ b/packages/ai/test/llm-option-types.types.ts @@ -1,12 +1,12 @@ import { Effect, Schema, Stream } from "effect" import { - LLM, - type LLMClientService, + ai, type LanguageModel, type LanguageModelProviderOptions, + type LLMClientService, type ProviderOptions, } from "../src/effect.js" -import { ai } from "../src/index.js" +import { AI } from "../src/index.js" import { OpenAIChat } from "../src/protocols.js" interface ExampleOptions { @@ -25,19 +25,19 @@ type StreamRequirements = T extends Stream.Stream = [A, B] extends [B, A] ? true : false type Assert = T -LLM.request({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } }) +ai.request({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) +ai.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } }) -const generated = LLM.generate(LLM.request({ model, prompt: "Hello" })) +const generated = ai.generate(ai.request({ model, prompt: "Hello" })) type GenerateRequirements = Assert, LLMClientService>> -const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" })) +const streamed = ai.stream(ai.request({ model, prompt: "Hello" })) type StreamClientRequirements = Assert, LLMClientService>> -const generatedFromInput = LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) +const generatedFromInput = ai.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) type InputGenerateRequirements = Assert, LLMClientService>> -const streamedFromInput = LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } }) +const streamedFromInput = ai.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } }) type InputStreamRequirements = Assert, LLMClientService>> -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Known provider options preserve their value types. @@ -45,11 +45,11 @@ LLM.request({ }) // @ts-expect-error Direct input keeps the selected model's provider option types. -LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) +ai.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) // @ts-expect-error Stream input keeps the selected model's provider option types. -LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) +ai.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) -const generatedObject = LLM.generateObject({ +const generatedObject = ai.generateObject({ model, prompt: "Hello", schema: Schema.Struct({ answer: Schema.String }), @@ -57,14 +57,14 @@ const generatedObject = LLM.generateObject({ }) type GenerateObjectRequirements = Assert, LLMClientService>> -const generatedDynamicObject = LLM.generateObject({ +const generatedDynamicObject = ai.generateObject({ model, prompt: "Hello", jsonSchema: { type: "object" }, }) type GenerateDynamicObjectRequirements = Assert, LLMClientService>> -LLM.generateObject({ +ai.generateObject({ model, prompt: "Hello", jsonSchema: { type: "object" }, @@ -73,7 +73,7 @@ LLM.generateObject({ }) declare const generic: LanguageModel -LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } }) +ai.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } }) const options: LanguageModelProviderOptions = { mode: "fast" } void (options satisfies LanguageModelProviderOptions) @@ -84,11 +84,11 @@ void (true satisfies InputStreamRequirements) void (true satisfies GenerateObjectRequirements) void (true satisfies GenerateDynamicObjectRequirements) -void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) -void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } }) -void ai.llm.generate(ai.llm.request({ model, prompt: "Hello" })) -void ai.llm.stream(ai.llm.request({ model, prompt: "Hello" })) +void AI.ai.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) +void AI.ai.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } }) +void AI.ai.generate(AI.ai.request({ model, prompt: "Hello" })) +void AI.ai.stream(AI.ai.request({ model, prompt: "Hello" })) // @ts-expect-error Promise direct input keeps the selected model's provider option types. -void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) +void AI.ai.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) // @ts-expect-error Promise stream input keeps the selected model's provider option types. -void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) +void AI.ai.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) diff --git a/packages/ai/test/llm.test.ts b/packages/ai/test/llm.test.ts index 2c8bf162804d..10d4d6d0c4ed 100644 --- a/packages/ai/test/llm.test.ts +++ b/packages/ai/test/llm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { CacheHint, LLM, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/effect.js" +import { ai, CacheHint, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/effect.js" import { OpenAI } from "../src/providers.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIResponses from "../src/protocols/openai-responses.js" @@ -22,7 +22,7 @@ const responsesRoute = OpenAIResponses.route describe("llm constructors", () => { test("normalizes recursive tool namespaces", () => { - const request = LLM.request({ + const request = ai.request({ model: LanguageModel.make({ id: "fake-model", provider: "fake", route: responsesRoute }), tools: [ { @@ -68,7 +68,7 @@ describe("llm constructors", () => { }) test("builds canonical schema classes from ergonomic input", () => { - const request = LLM.request({ + const request = ai.request({ id: "req_1", model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }), system: "You are concise.", @@ -85,7 +85,7 @@ describe("llm constructors", () => { }) test("updates requests without spreading schema class instances", () => { - const base = LLM.request({ + const base = ai.request({ id: "req_1", model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }), prompt: "Say hello.", @@ -103,7 +103,7 @@ describe("llm constructors", () => { }) test("keeps request options separate from route defaults", () => { - const request = LLM.request({ + const request = ai.request({ model: LanguageModel.make({ id: "fake-model", provider: "fake", @@ -129,7 +129,7 @@ describe("llm constructors", () => { }) test("updates canonical requests from the request datatype", () => { - const base = LLM.request({ + const base = ai.request({ id: "req_1", model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }), prompt: "Say hello.", @@ -177,7 +177,7 @@ describe("llm constructors", () => { }, compatibility: { sanitizer: "moonshot" }, }) - const request = LLM.request({ model, prompt: "Say hello." }) + const request = ai.request({ model, prompt: "Say hello." }) expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false }) @@ -202,7 +202,7 @@ describe("llm constructors", () => { expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" })) expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" })) expect( - LLM.request({ + ai.request({ model: LanguageModel.make({ id: "fake-model", provider: "fake", @@ -228,7 +228,7 @@ describe("llm constructors", () => { const update = Message.system([ { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) }, ]) - const request = LLM.request({ + const request = ai.request({ model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }), system: "Initial operator prompt.", messages: [Message.user("Review this."), update], @@ -249,15 +249,15 @@ describe("llm constructors", () => { sseEvents({ choices: [{ delta: { content: "Hello" } }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }), ) const input = { model, prompt: "Say hello." } - const request = LLM.request(input) - const generated = await Effect.runPromise(LLM.generate(input).pipe(Effect.provide(layer))) - const generatedFromRequest = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(layer))) + const request = ai.request(input) + const generated = await Effect.runPromise(ai.generate(input).pipe(Effect.provide(layer))) + const generatedFromRequest = await Effect.runPromise(ai.generate(request).pipe(Effect.provide(layer))) expect(generated.text).toBe("Hello") expect(generatedFromRequest.text).toBe(generated.text) - const streamed = await Effect.runPromise(LLM.stream(input).pipe(Stream.runCollect, Effect.provide(layer))) + const streamed = await Effect.runPromise(ai.stream(input).pipe(Stream.runCollect, Effect.provide(layer))) const streamedFromRequest = await Effect.runPromise( - LLM.stream(request).pipe(Stream.runCollect, Effect.provide(layer)), + ai.stream(request).pipe(Stream.runCollect, Effect.provide(layer)), ) expect(Array.from(streamed).some(LLMEvent.is.textDelta)).toBe(true) expect(streamedFromRequest).toEqual(streamed) diff --git a/packages/ai/test/loop-tools.types.ts b/packages/ai/test/loop-tools.types.ts new file mode 100644 index 000000000000..fee2cd0b84b8 --- /dev/null +++ b/packages/ai/test/loop-tools.types.ts @@ -0,0 +1,57 @@ +import { Effect, Schema } from "effect" +import { ai, tool } from "../src/effect.js" +import * as OpenAIChat from "../src/protocols/openai-chat.js" +import { Auth } from "../src/route.js" +import { AI } from "../src/index.js" + +const model = OpenAIChat.route.with({ auth: Auth.bearer("fixture") }).model({ id: "gpt-4o-mini" }) + +const add = AI.tool({ + description: "Add two numbers.", + parameters: Schema.Struct({ a: Schema.Number, b: Schema.Number }), + execute: (input) => input.a + input.b, +}) + +const echo = AI.tool({ + description: "Echo text.", + parameters: Schema.String, + execute: (input) => input.toUpperCase(), +}) + +const weather = tool({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: input.city }), +}) + +const shout = tool({ + description: "Shout text.", + parameters: Schema.String, + success: Schema.String, + execute: (input) => Effect.succeed(input.toUpperCase()), +}) + +const schemaOnly = tool({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), +}) + +AI.ai.loop({ model, prompt: "Add 2 and 3.", tools: { add } }) +AI.ai.loop({ model, prompt: "Get weather.", tools: { weather } }) +AI.ai.loop({ model, prompt: "Use any tool.", tools: { add, echo, weather, shout } }) +ai.loop({ model, prompt: "Add 2 and 3.", tools: { add } }) +ai.loop({ model, prompt: "Get weather.", tools: { weather } }) +ai.loop({ model, prompt: "Use any tool.", tools: { add, echo, weather, shout } }) + +// @ts-expect-error The loop runs tools, so each one needs an execute handler. +ai.loop({ model, prompt: "Get weather.", tools: { schemaOnly } }) + +const unavailable = () => { + throw new Error("Unavailable.") +} +const fail = AI.tool({ description: "Fail.", parameters: Schema.String, execute: unavailable }) +const parse = AI.tool({ description: "Parse.", parameters: Schema.String, execute: (text) => JSON.parse(text) }) +const inspect = tool({ description: "Inspect.", parameters: Schema.String, execute: () => Effect.succeed(1) }) +AI.ai.loop({ model, prompt: "Fail.", tools: { fail, parse, inspect } }) diff --git a/packages/ai/test/loop.test.ts b/packages/ai/test/loop.test.ts index e78e68952b67..88c930ad9f18 100644 --- a/packages/ai/test/loop.test.ts +++ b/packages/ai/test/loop.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LanguageModel, LLM, LLMEvent, Tool, ToolFailure } from "../src/effect.js" +import { ai, LanguageModel, type LLM, LLMEvent, tool, ToolFailure } from "../src/effect.js" +import { AI } from "../src/index.js" import { OpenAIChat } from "../src/protocols/openai-chat.js" import { TestLLM } from "../src/testing.js" import { testEffect } from "./lib/effect.js" @@ -8,7 +9,7 @@ import { testEffect } from "./lib/effect.js" const model = LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }) const it = testEffect(TestLLM.testLayer()) -const read_file = Tool.make({ +const read_file = tool({ description: "Read a file.", parameters: Schema.Struct({ path: Schema.String }), success: Schema.String, @@ -17,12 +18,12 @@ const read_file = Tool.make({ }) const collect = (until?: LLM.LoopInput["until"]) => - LLM.loop({ model, prompt: "Read the file.", tools: { read_file }, until }).pipe( + ai.loop({ model, prompt: "Read the file.", tools: { read_file }, until }).pipe( Stream.runCollect, Effect.map((events) => Array.from(events)), ) -describe("LLM.loop", () => { +describe("ai.loop", () => { it.effect("runs tool calls and sends the full assistant turn back to the model", () => Effect.gen(function* () { const client = yield* TestLLM.Test @@ -61,7 +62,7 @@ describe("LLM.loop", () => { Effect.gen(function* () { const client = yield* TestLLM.Test yield* client.push( - TestLLM.tool("call-1", "read_file", { path: "missing.txt" }), + TestLLM.toolCall("call-1", "read_file", { path: "missing.txt" }), TestLLM.text("That file does not exist.", "text-1"), ) @@ -78,10 +79,39 @@ describe("LLM.loop", () => { }), ) + it.effect("runs Promise tools, validating success and projecting toModelOutput", () => + Effect.gen(function* () { + const client = yield* TestLLM.Test + yield* client.push( + TestLLM.toolCalls( + LLMEvent.toolCall({ id: "call-1", name: "add", input: { a: 2, b: 3 } }), + LLMEvent.toolCall({ id: "call-2", name: "add", input: { a: -1, b: 0 } }), + ), + TestLLM.text("Done.", "text-1"), + ) + const add = AI.tool({ + description: "Add two numbers.", + parameters: Schema.Struct({ a: Schema.Number, b: Schema.Number }), + success: Schema.Number, + execute: async ({ a, b }) => (a < 0 ? "negative" : a + b) as number, + toModelOutput: ({ output }) => [{ type: "text", text: `sum=${output}` }], + }) + + const events = yield* ai.loop({ model, prompt: "Add.", tools: { add } }).pipe(Stream.runCollect) + + expect(Array.from(events).filter(LLMEvent.is.toolResult)).toEqual([ + expect.objectContaining({ id: "call-1", result: { type: "text", value: "sum=5" } }), + expect.objectContaining({ + result: { type: "error", value: expect.stringContaining("invalid value for its success schema") }, + }), + ]) + }), + ) + it.effect("stops when until returns true without running that step's tools", () => Effect.gen(function* () { const client = yield* TestLLM.Test - yield* client.always(TestLLM.tool("call-1", "read_file", { path: "a.txt" })) + yield* client.always(TestLLM.toolCall("call-1", "read_file", { path: "a.txt" })) const seen: number[] = [] const events = yield* collect(({ steps }) => { diff --git a/packages/ai/test/media.test.ts b/packages/ai/test/media.test.ts index 5bd2891817fd..2bb6ab495594 100644 --- a/packages/ai/test/media.test.ts +++ b/packages/ai/test/media.test.ts @@ -3,7 +3,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { Effect, Ref, Schema } from "effect" import { FileSystem } from "effect" import { HttpClientRequest } from "effect/http" -import { AIError, LLM, Media, Message, ToolCallPart } from "../src/effect.js" +import { ai, AIError, Media, Message, ToolCallPart } from "../src/effect.js" import { OpenAI } from "../src/providers.js" import { compileRequest } from "../src/route/client.js" import { it } from "./lib/effect.js" @@ -51,12 +51,12 @@ describe("Media", () => { Media.ref("openai", "file:///Users/example/clip.mp4", "video/mp4"), restored, ]) { - const error = yield* compileRequest(LLM.request({ model, prompt: [Message.media(asset)] })).pipe(Effect.flip) + const error = yield* compileRequest(ai.request({ model, prompt: [Message.media(asset)] })).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") expect(error.message).toContain("Media.file(path)") } const error = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "read", name: "read", input: {} })]), diff --git a/packages/ai/test/promise-parity.types.ts b/packages/ai/test/promise-parity.types.ts new file mode 100644 index 000000000000..9107d3438c09 --- /dev/null +++ b/packages/ai/test/promise-parity.types.ts @@ -0,0 +1,72 @@ +import type { Effect, Stream } from "effect" +import type { Client, GenerationHandle } from "../src/index.js" +import type { Generation, ai } from "../src/effect.js" + +// The Promise `ai` mirrors the Effect `ai`: same namespaces, methods, arguments and results. The only allowed differences +// are `signal` on the options bag, Promise for Effect, AsyncIterable for Stream and GenerationHandle for Generation. + +type Equal = [A, B] extends [B, A] ? true : false +type Assert> = T + +type PromiseAI = Omit +type EffectAI = typeof ai +type Helpers = "run" | "bytes" | "base64" | "materialize" | "file" | "write" +type Namespaces = "image" | "decision" | "experimental" + +type Signatures = F extends { + (...args: infer A1): infer R1 + (...args: infer A2): infer R2 + (...args: infer A3): infer R3 +} + ? [A1, R1] | [A2, R2] | [A3, R3] + : F extends { (...args: infer A1): infer R1; (...args: infer A2): infer R2 } + ? [A1, R1] | [A2, R2] + : F extends (...args: infer A) => infer R + ? [A, R] + : never + +type Positional

= P extends readonly [infer A, ...infer Rest] ? [WithoutSignal, ...Positional] : [] +// Paired with its keys: an options bag of optional fields is otherwise assignable to `{}` and back. +type Options

= P extends readonly [unknown, ...infer Rest] + ? Options + : P extends readonly [(infer O)?] + ? Keyed>> + : Keyed<{}> +type Keyed = [keyof T, T] +type WithoutSignal = T extends { readonly signal?: AbortSignal } ? Omit : T + +type Promised = + R extends Effect.Effect, infer _E, infer _R> + ? Promise> + : R extends Effect.Effect + ? Promise + : R extends Stream.Stream + ? AsyncIterable + : R + +type EffectShape = + Signatures extends infer S ? (S extends [infer A, infer R] ? [Positional, Options, Promised] : never) : never +type PromiseShape = + Signatures extends infer S ? (S extends [infer A, infer R] ? [Positional, Options, R] : never) : never + +export type Drift = { + readonly [K in keyof Promise | keyof Effect]: K extends keyof Promise + ? K extends keyof Effect + ? Equal, EffectShape> + : "missing in Effect" + : "missing in Promise" +} + +type Same = [Exclude | Exclude] extends [never] ? true : { readonly drift: Exclude | Exclude } + +export type Checks = [ + Assert<{ handle: Same, Exclude, "route">> }>, + Assert<{ keys: Same, keyof EffectAI> }>, + Assert<{ experimental: Same }>, + Assert, Omit>>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, +] diff --git a/packages/ai/test/promise.test.ts b/packages/ai/test/promise.test.ts index 5ce3beb85bc8..2485d9541b42 100644 --- a/packages/ai/test/promise.test.ts +++ b/packages/ai/test/promise.test.ts @@ -4,8 +4,9 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { Effect, Layer, Schema } from "effect" import { HttpClientRequest } from "effect/http" -import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/effect.js" -import { RequestExecutor } from "../src/route.js" +import { AIError, LLMEvent, Media } from "../src/effect.js" +import { SpeechEvent, TranscriptionEvent } from "../src/experimental.js" +import { RequestExecutor, type StreamOptions } from "../src/route.js" import { AI } from "../src/index.js" import { AssemblyAI, OpenAI, Runway } from "../src/providers.js" import { handlerLayer, json } from "./lib/http.js" @@ -123,10 +124,10 @@ describe("AI promise client", () => { const seen: Array = [] const ai = AI.make({ layer: executor(seen) }) - const request = ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." }) - const text = await ai.llm.generate(request) + const request = ai.request({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." }) + const text = await ai.generate(request) expect(text.text).toBe("Hello world") - expect((await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })).text).toBe( + expect((await ai.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })).text).toBe( "Hello world", ) @@ -140,12 +141,12 @@ describe("AI promise client", () => { expect(requested.image.mediaType).toBe("image/png") const deltas: Array = [] - for await (const event of ai.llm.stream(request)) { + for await (const event of ai.stream(request)) { if (LLMEvent.is.textDelta(event)) deltas.push(event.text) } expect(deltas).toEqual(["Hello", " world"]) const directDeltas: Array = [] - for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) { + for await (const event of ai.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) { if (LLMEvent.is.textDelta(event)) directDeltas.push(event.text) } expect(directDeltas).toEqual(deltas) @@ -174,7 +175,7 @@ describe("AI promise client", () => { const ai = AI.make({ layer: executor(seen) }) const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5") - const generation = await ai.video.start({ model, prompt: "A kite" }) + const generation = await ai.experimental.video.start({ model, prompt: "A kite" }) expect(generation.id).toBe("task_1") expect(generation.status).toBe("queued") expect(generation.token).toEqual({ taskID: "task_1" }) @@ -188,12 +189,12 @@ describe("AI promise client", () => { expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" }) expect(response.usage).toEqual({ type: "credits", credits: 5 }) - const resumed = await ai.video.resume(model, JSON.parse(JSON.stringify(generation.token))) + const resumed = await ai.experimental.video.resume(model, JSON.parse(JSON.stringify(generation.token))) expect(resumed.status).toBe("completed") expect((await resumed.await()).videos).toHaveLength(1) const events: Array = [] - for await (const event of ai.video.stream({ model, prompt: "A kite" }, { poll: { interval: 10 } })) { + for await (const event of ai.experimental.video.stream({ model, prompt: "A kite" }, { poll: { interval: 10 } })) { events.push(event.type) } expect(events).toEqual(["video", "finish"]) @@ -203,10 +204,45 @@ describe("AI promise client", () => { await ai.dispose() }) + test("forwards per-call options to generate, stream, and loop, and checks compaction support", async () => { + const seen: Array = [] + const ai = AI.make({ layer: executor([]) }) + const options: StreamOptions = { + http: (request, handler) => { + seen.push(request.url) + return handler(request) + }, + } + const request = { model: openai.chat("gpt-4o-mini"), prompt: "Say hello." } + + expect((await ai.generate(request, options)).text).toBe("Hello world") + await Array.fromAsync(ai.stream(request, options)) + await Array.fromAsync(ai.loop({ ...request, tools: {} }, options)) + expect(seen).toEqual(Array(3).fill("https://openai.test/v1/chat/completions")) + + const responses = ai.request({ model: openai.responses("gpt-5"), prompt: "Hello" }) + expect(ai.canCompact(responses)).toBe(true) + expect(ai.canCompact(responses, { mechanism: "trigger" })).toBe(true) + expect(ai.canCompact(ai.request(request))).toBe(false) + await ai.dispose() + }) + + test("exposes a generation's snapshot and terminal state", async () => { + const ai = AI.make({ layer: executor([]) }) + const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5") + const generation = await ai.experimental.video.start({ model, prompt: "A kite" }) + expect(generation.snapshot).toEqual({ id: "task_1", status: "queued", progress: undefined, position: undefined }) + expect(generation.terminal).toBe(false) + const refreshed = await generation.refresh() + expect(refreshed.terminal).toBe(false) + expect((await refreshed.refresh()).terminal).toBe(true) + await ai.dispose() + }) + test("observes a started generation's events and fetches its result", async () => { const ai = AI.make({ layer: executor([]) }) const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5") - const generation = await ai.video.start({ model, prompt: "A kite" }) + const generation = await ai.experimental.video.start({ model, prompt: "A kite" }) const events: Array = [] for await (const event of generation.events({ poll: { interval: 10 } })) events.push(event.type) @@ -239,11 +275,16 @@ describe("AI promise client", () => { const bytes = Media.bytes(Uint8Array.from([0x49, 0x44, 0x33]), "audio/mpeg") expect( - (await ai.transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio: bytes })).text, + ( + await ai.experimental.transcription.generate({ + model: openai.transcription("gpt-4o-mini-transcribe"), + audio: bytes, + }) + ).text, ).toBe("Hello there.") const deltas: Array = [] - for await (const event of ai.transcription.stream({ + for await (const event of ai.experimental.transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio: bytes, })) @@ -253,7 +294,7 @@ describe("AI promise client", () => { const model = AssemblyAI.configure({ apiKey: "test", baseURL: "https://assemblyai.test" }).transcription( "universal-2", ) - const generation = await ai.transcription.start({ model, audio }) + const generation = await ai.experimental.transcription.start({ model, audio }) expect(generation.token).toEqual({ transcriptID: "tr_1" }) expect((await generation.await({ poll: { interval: 10 } })).text).toBe("Hello there.") await ai.dispose() @@ -263,11 +304,12 @@ describe("AI promise client", () => { const ai = AI.make({ layer: executor([]) }) const model = openai.speech("gpt-4o-mini-tts") - const response = await ai.speech.generate({ model, text: "Hello", voice: "coral", format: "pcm" }) + const response = await ai.experimental.speech.generate({ model, text: "Hello", voice: "coral", format: "pcm" }) expect(await ai.run(response.audio.bytes())).toEqual(Uint8Array.from([1, 2, 3])) const events: Array = [] - for await (const event of ai.speech.stream({ model, text: "Hello", voice: "coral" })) events.push(event) + for await (const event of ai.experimental.speech.stream({ model, text: "Hello", voice: "coral" })) + events.push(event) expect(events.map((event) => event.type)).toEqual(["audio-delta", "audio-delta", "finish"]) const finish = events.find(SpeechEvent.is.finish) expect(await ai.run(finish!.audio.bytes())).toEqual(Uint8Array.from([1, 2, 3])) @@ -277,14 +319,14 @@ describe("AI promise client", () => { test("rethrows AIError unchanged and honors abort signals", async () => { const ai = AI.make({ layer: executor([]) }) - const failure = await ai.llm - .generate(ai.llm.request({ model: openai.responses("gpt-5"), prompt: "Hello" })) + const failure = await ai + .generate(ai.request({ model: openai.responses("gpt-5"), prompt: "Hello" })) .then(() => undefined) .catch((error: unknown) => error) expect(failure).toBeInstanceOf(AIError) expect(failure instanceof AIError && failure.reason.http?.status).toBe(404) - const invalidLLM = await ai.llm + const invalidLLM = await ai // @ts-expect-error Invalid input must reject with AIError instead of throwing synchronously. .generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] }) .catch((error: unknown) => error) @@ -297,8 +339,8 @@ describe("AI promise client", () => { const controller = new AbortController() controller.abort() - const aborted = await ai.llm - .generate(ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }), { signal: controller.signal }) + const aborted = await ai + .generate(ai.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }), { signal: controller.signal }) .then(() => "completed") .catch(() => "aborted") expect(aborted).toBe("aborted") @@ -320,13 +362,16 @@ describe("AI promise client", () => { const inFlight = new AbortController() setTimeout(() => inFlight.abort(reason), 10) expect( - await ai.llm + await ai .generate({ model: slow, prompt: "Hello" }, { signal: inFlight.signal }) .catch((error: unknown) => error), ).toBe(reason) const preAborted = await Array.fromAsync( - ai.speech.stream({ model: openai.speech("gpt-4o-mini-tts"), text: "Hello" }, { signal: aborted.signal }), + ai.experimental.speech.stream( + { model: openai.speech("gpt-4o-mini-tts"), text: "Hello" }, + { signal: aborted.signal }, + ), ).catch((error: unknown) => error) expect(preAborted).toBe(aborted.signal.reason) expect(preAborted).toMatchObject({ name: "AbortError" }) @@ -334,7 +379,7 @@ describe("AI promise client", () => { const midStream = new AbortController() const deltas: Array = [] const midStreamFailure = await Array.fromAsync( - ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: midStream.signal }), + ai.stream({ model: slow, prompt: "Hello" }, { signal: midStream.signal }), (event) => { if (!LLMEvent.is.textDelta(event)) return deltas.push(event.text) @@ -346,7 +391,7 @@ describe("AI promise client", () => { expect(midStreamFailure).toMatchObject({ name: "AbortError" }) const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5") - const generation = await ai.video.start({ model, prompt: "A kite" }) + const generation = await ai.experimental.video.start({ model, prompt: "A kite" }) const polling = new AbortController() const events: Array = [] const eventsFailure = await Array.fromAsync( @@ -367,7 +412,7 @@ describe("AI promise client", () => { const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini") const controller = new AbortController() const deltas: Array = [] - for await (const event of ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: controller.signal })) { + for await (const event of ai.stream({ model: slow, prompt: "Hello" }, { signal: controller.signal })) { if (!LLMEvent.is.textDelta(event)) continue deltas.push(event.text) break @@ -412,7 +457,7 @@ describe("AI promise client", () => { }) const events = await Array.fromAsync( - ai.llm.loop({ model: openai.chat("gpt-4o-mini"), prompt: "Read the files.", tools: { read_file } }), + ai.loop({ model: openai.chat("gpt-4o-mini"), prompt: "Read the files.", tools: { read_file } }), ) expect(events.filter(LLMEvent.is.toolResult).map((event) => event.result)).toEqual([ @@ -448,7 +493,7 @@ describe("AI promise client", () => { ), }) - const response = await ai.llm.generateObject({ + const response = await ai.generateObject({ model: openai.chat("gpt-4o-mini"), prompt: "Pick a city.", schema: Schema.Struct({ city: Schema.String }), @@ -484,33 +529,32 @@ describe("AI promise client", () => { ), ), }) - const request = ai.llm.request({ model: openai.responses("gpt-5"), prompt: "Hello" }) + const request = ai.request({ model: openai.responses("gpt-5"), prompt: "Hello" }) - const compacted = await ai.llm.compact(request) + const compacted = await ai.compact(request) expect(compacted.replacement.flatMap((message) => message.content.map((part) => part.type))).toEqual([ "compaction", ]) expect(compacted.usage?.totalTokens).toBe(110) - const triggered = await ai.llm.compact(request, { mechanism: "trigger" }) + const triggered = await ai.compact(request, { mechanism: "trigger" }) expect(triggered.checkpoint).toMatchObject({ id: "cmp_1", encrypted: "opaque" }) expect(triggered.responseID).toBe("resp_1") expect(paths).toEqual(["/v1/responses/compact", "/v1/responses"]) - const unsupported = await ai.llm + const unsupported = await ai // @ts-expect-error Untyped callers on routes without a compact endpoint must reject with AIError. - .compact(ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" })) + .compact(ai.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" })) .catch((error: unknown) => error) expect(unsupported instanceof AIError && unsupported.reason._tag).toBe("UnsupportedOperation") expect(paths).toHaveLength(2) await ai.dispose() }) - test("the default client is created lazily and can be disposed", async () => { - expect(typeof AI.ai.llm.generate).toBe("function") + test("the default client is created lazily", () => { + expect(typeof AI.ai.generate).toBe("function") expect(typeof AI.ai.image.generate).toBe("function") - expect(typeof AI.ai.video.start).toBe("function") - expect(typeof AI.ai.speech.generate).toBe("function") - await AI.ai.dispose() + expect(typeof AI.ai.experimental.video.start).toBe("function") + expect(typeof AI.ai.experimental.speech.generate).toBe("function") }) }) diff --git a/packages/ai/test/provider-options/alibaba.types.ts b/packages/ai/test/provider-options/alibaba.types.ts index 414c536f66e6..a35d3f856e61 100644 --- a/packages/ai/test/provider-options/alibaba.types.ts +++ b/packages/ai/test/provider-options/alibaba.types.ts @@ -1,38 +1,38 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Alibaba } from "../../src/providers.js" const provider = Alibaba.configure({ region: "ap-southeast-1" }) Alibaba.configure({ baseURL: "https://gateway.example/v1" }) Alibaba.configure({ region: "eu-central-1", workspaceID: "llm-workspace" }) -LLM.request({ +ai.request({ model: provider.chat("qwen3.8-max"), providerOptions: { reasoningEffort: "future", enableThinking: true, preserveThinking: false, toolStream: true }, }) -LLM.request({ +ai.request({ model: provider.messages("qwen3.8-max"), providerOptions: { effort: "xhigh", thinking: { type: "enabled" } }, }) -LLM.request({ +ai.request({ model: provider.messages("qwen3.7-plus"), providerOptions: { thinking: { type: "enabled", budgetTokens: 512 } }, }) -LLM.request({ +ai.request({ model: provider.responses("qwen3.8-max"), providerOptions: { reasoningEffort: "low", enableThinking: true, store: true, previousResponseId: "resp_previous" }, }) // @ts-expect-error Region or complete base URL is required. Alibaba.configure({ apiKey: "fixture" }) -LLM.request({ +ai.request({ model: provider.chat("qwen3.8-max"), // @ts-expect-error Thinking toggle is a boolean. providerOptions: { enableThinking: "true" }, }) -LLM.request({ +ai.request({ model: provider.messages("qwen3.8-max"), // @ts-expect-error Messages uses effort. providerOptions: { reasoningEffort: "high" }, }) -LLM.request({ +ai.request({ model: provider.responses("qwen3.8-max"), // @ts-expect-error Responses does not use Chat thinking budgets. providerOptions: { thinkingBudget: 512 }, diff --git a/packages/ai/test/provider-options/amazon-bedrock.types.ts b/packages/ai/test/provider-options/amazon-bedrock.types.ts index 1c40584e7b75..be975d1ed116 100644 --- a/packages/ai/test/provider-options/amazon-bedrock.types.ts +++ b/packages/ai/test/provider-options/amazon-bedrock.types.ts @@ -1,9 +1,9 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { AmazonBedrock } from "../../src/providers.js" const model = AmazonBedrock.provider.model("us.anthropic.claude-sonnet-4-6") -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { @@ -18,14 +18,14 @@ LLM.request({ AmazonBedrock.model("us.amazon.nova-2-lite-v1:0", { effort: "low", serviceTier: "flex" }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Bedrock thinking modes are a fixed union. providerOptions: { thinking: { type: "automatic" } }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Converse request metadata values are strings. diff --git a/packages/ai/test/provider-options/anthropic-compatible.types.ts b/packages/ai/test/provider-options/anthropic-compatible.types.ts index d6b0feb65f2f..3b11b5582159 100644 --- a/packages/ai/test/provider-options/anthropic-compatible.types.ts +++ b/packages/ai/test/provider-options/anthropic-compatible.types.ts @@ -1,11 +1,11 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { AnthropicCompatible } from "../../src/providers.js" const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude") -LLM.request({ model, prompt: "Hello", providerOptions: { effort: "high" } }) +ai.request({ model, prompt: "Hello", providerOptions: { effort: "high" } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Anthropic effort must be a string. diff --git a/packages/ai/test/provider-options/anthropic.types.ts b/packages/ai/test/provider-options/anthropic.types.ts index 92954fd2e5fa..5dd5d3c3b895 100644 --- a/packages/ai/test/provider-options/anthropic.types.ts +++ b/packages/ai/test/provider-options/anthropic.types.ts @@ -1,10 +1,10 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Anthropic } from "../../src/providers.js" const model = Anthropic.provider.model("claude-sonnet-4-5") -LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } }) -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } }) +ai.request({ model, prompt: "Hello", providerOptions: { @@ -13,7 +13,7 @@ LLM.request({ cacheControl: { type: "ephemeral", ttl: "30m" }, }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { @@ -22,7 +22,7 @@ LLM.request({ }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Anthropic thinking modes are a fixed union. diff --git a/packages/ai/test/provider-options/azure.types.ts b/packages/ai/test/provider-options/azure.types.ts index fb52ef428bbd..3a12b3cec31a 100644 --- a/packages/ai/test/provider-options/azure.types.ts +++ b/packages/ai/test/provider-options/azure.types.ts @@ -1,11 +1,11 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Azure } from "../../src/providers.js" const model = Azure.configure({ resourceName: "example" }).responses("deployment") -LLM.request({ model, prompt: "Hello", providerOptions: { store: false } }) +ai.request({ model, prompt: "Hello", providerOptions: { store: false } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Azure OpenAI store must be boolean. diff --git a/packages/ai/test/provider-options/checkpoint.types.ts b/packages/ai/test/provider-options/checkpoint.types.ts index 2f922eaf047a..e49626c63c05 100644 --- a/packages/ai/test/provider-options/checkpoint.types.ts +++ b/packages/ai/test/provider-options/checkpoint.types.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" import { + ai, CompactionCheckpointResponse, CompactionResponse, LanguageModel, - LLM, LLMClient, LLMRequest, } from "../../src/effect.js" @@ -16,7 +16,7 @@ import { OpenAICompatibleResponses, XAI, } from "../../src/providers.js" -import { ai } from "../../src/index.js" +import { AI } from "../../src/index.js" import type { WebSocketChannelExecutor } from "../../src/route.js" import type { RoutePatch } from "../../src/route/client.js" import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js" @@ -24,9 +24,9 @@ import type { Prepared } from "../../src/protocols/open-responses-channel.js" declare const webSocket: WebSocketChannelExecutor const model = OpenAI.configure().responses("fixture") -const request = LLM.request({ model, prompt: "hello" }) -LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse)) -LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe( +const request = ai.request({ model, prompt: "hello" }) +ai.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse)) +ai.compact(request, { mechanism: "trigger", webSocket }).pipe( Effect.map((result) => { result satisfies CompactionCheckpointResponse result.checkpoint.encrypted satisfies string @@ -36,11 +36,11 @@ LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe( }), ) // @ts-expect-error Endpoint compaction does not accept a WebSocket executor. -LLMClient.compact(request, { mechanism: "endpoint", webSocket }) +ai.compact(request, { mechanism: "endpoint", webSocket }) // @ts-expect-error Omitting mechanism selects the HTTP endpoint. -LLMClient.compact(request, { webSocket }) +ai.compact(request, { webSocket }) // @ts-expect-error Unknown mechanisms do not have a permissive fallback overload. -LLMClient.compact(request, { mechanism: "other" }) +ai.compact(request, { mechanism: "other" }) for (const selected of [ model, @@ -49,55 +49,57 @@ for (const selected of [ LanguageModel.make(LanguageModel.input(model)), LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }), ]) { - LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" }) + ai.compact(ai.request({ model: selected }), { mechanism: "trigger" }) } -LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" }) -LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" }) +ai.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" }) +ai.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" }) const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture") const xai = XAI.configure().responses("fixture") // @ts-expect-error Azure must not inherit OpenAI's trigger operation. -LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" }) -LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), { +ai.compact(ai.request({ model: azure }), { mechanism: "trigger" }) +ai.compact(ai.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), { // @ts-expect-error Azure's package entrypoint must preserve its narrower capability. mechanism: "trigger", }) // @ts-expect-error xAI endpoint support does not imply trigger support. -LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" }) +ai.compact(ai.request({ model: xai }), { mechanism: "trigger" }) // @ts-expect-error xAI's package entrypoint must preserve its narrower capability. -LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" }) +ai.compact(ai.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" }) const unsupported = { - bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }), - mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }), - anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }), - openai: LLM.request({ model: OpenAI.configure().chat("fixture") }), - azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }), - xai: LLM.request({ model: XAI.configure().chat("fixture") }), - compatible: LLM.request({ + bedrock: ai.request({ model: AmazonBedrock.configure().model("fixture") }), + mantle: ai.request({ model: AmazonBedrockMantle.configure().responses("fixture") }), + anthropic: ai.request({ model: Anthropic.configure().model("fixture") }), + openai: ai.request({ model: OpenAI.configure().chat("fixture") }), + azure: ai.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }), + xai: ai.request({ model: XAI.configure().chat("fixture") }), + compatible: ai.request({ model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"), }), } // @ts-expect-error Bedrock does not expose trigger compaction. -LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" }) +ai.compact(unsupported.bedrock, { mechanism: "trigger" }) // @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol. -LLMClient.compact(unsupported.mantle, { mechanism: "trigger" }) +ai.compact(unsupported.mantle, { mechanism: "trigger" }) // @ts-expect-error Anthropic does not expose trigger compaction. -LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" }) +ai.compact(unsupported.anthropic, { mechanism: "trigger" }) // @ts-expect-error OpenAI Chat does not expose trigger compaction. -LLMClient.compact(unsupported.openai, { mechanism: "trigger" }) +ai.compact(unsupported.openai, { mechanism: "trigger" }) // @ts-expect-error Azure Chat does not expose trigger compaction. -LLMClient.compact(unsupported.azure, { mechanism: "trigger" }) +ai.compact(unsupported.azure, { mechanism: "trigger" }) // @ts-expect-error xAI Chat does not expose trigger compaction. -LLMClient.compact(unsupported.xai, { mechanism: "trigger" }) +ai.compact(unsupported.xai, { mechanism: "trigger" }) // @ts-expect-error Generic protocol compatibility does not grant trigger support. -LLMClient.compact(unsupported.compatible, { mechanism: "trigger" }) +ai.compact(unsupported.compatible, { mechanism: "trigger" }) // @ts-expect-error Changing the model replaces its capability. -LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" }) -// @ts-expect-error Changing the route replaces its capability. -LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" }) -LLMClient.compact( - LLM.request({ +ai.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" }) +ai.compact(ai.request({ model: LanguageModel.update(model, { route: azure.route }) }), { + // @ts-expect-error Changing the route replaces its capability. + mechanism: "trigger", +}) +ai.compact( + ai.request({ model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }), }), // @ts-expect-error Replacing route operations does not retain the old trigger capability. @@ -108,39 +110,39 @@ declare const dynamic: LLMRequest declare const patch: Partial declare const routePatch: RoutePatch // @ts-expect-error A dynamic operation override cannot preserve trigger support. -LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), { +ai.compact(ai.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), { mechanism: "trigger", }) // @ts-expect-error Explicitly removing operations removes trigger support. -LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), { +ai.compact(ai.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), { mechanism: "trigger", }) // @ts-expect-error Dynamic models must be narrowed. -LLMClient.compact(dynamic, { mechanism: "trigger" }) -if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) { - LLMClient.compact(dynamic, { mechanism: "trigger" }) +ai.compact(dynamic, { mechanism: "trigger" }) +if (ai.canCompact(dynamic, { mechanism: "trigger" })) { + ai.compact(dynamic, { mechanism: "trigger" }) LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" })) } -if (LLMClient.canCompact(dynamic)) { - LLMClient.compact(dynamic) +if (ai.canCompact(dynamic)) { + ai.compact(dynamic) // @ts-expect-error Endpoint narrowing does not grant trigger support. - LLMClient.compact(dynamic, { mechanism: "trigger" }) + ai.compact(dynamic, { mechanism: "trigger" }) } // @ts-expect-error A dynamic model override cannot preserve trigger support. -LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" }) +ai.compact(LLMRequest.update(request, patch), { mechanism: "trigger" }) -void ai.llm.compact(request).then((result) => result satisfies CompactionResponse) -void ai.llm.compact(request, { mechanism: "trigger", webSocket, signal: AbortSignal.timeout(1) }).then((result) => { +void AI.ai.compact(request).then((result) => result satisfies CompactionResponse) +void AI.ai.compact(request, { mechanism: "trigger", webSocket, signal: AbortSignal.timeout(1) }).then((result) => { result satisfies CompactionCheckpointResponse // @ts-expect-error A trigger does not return replacement history. result.replacement }) // @ts-expect-error Endpoint compaction does not accept a WebSocket executor. -void ai.llm.compact(request, { webSocket }) +void AI.ai.compact(request, { webSocket }) // @ts-expect-error Azure must not inherit OpenAI's trigger operation. -void ai.llm.compact(LLM.request({ model: azure }), { mechanism: "trigger" }) +void AI.ai.compact(ai.request({ model: azure }), { mechanism: "trigger" }) // @ts-expect-error OpenAI Chat does not expose explicit compaction. -void ai.llm.compact(unsupported.openai) +void AI.ai.compact(unsupported.openai) // @ts-expect-error Dynamic models must be narrowed. -void ai.llm.compact(dynamic) -if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) void ai.llm.compact(dynamic, { mechanism: "trigger" }) +void AI.ai.compact(dynamic) +if (ai.canCompact(dynamic, { mechanism: "trigger" })) void AI.ai.compact(dynamic, { mechanism: "trigger" }) diff --git a/packages/ai/test/provider-options/cloudflare.types.ts b/packages/ai/test/provider-options/cloudflare.types.ts index f4b59ee28f8b..b75bcd7c81e8 100644 --- a/packages/ai/test/provider-options/cloudflare.types.ts +++ b/packages/ai/test/provider-options/cloudflare.types.ts @@ -1,11 +1,11 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { CloudflareWorkersAI } from "../../src/providers.js" const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model") -LLM.request({ model, prompt: "Hello", promptCacheKey: "cache" }) +ai.request({ model, prompt: "Hello", promptCacheKey: "cache" }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Prompt cache keys must be strings. diff --git a/packages/ai/test/provider-options/compaction.types.ts b/packages/ai/test/provider-options/compaction.types.ts index 9a72abfdfde0..2782cfa20e36 100644 --- a/packages/ai/test/provider-options/compaction.types.ts +++ b/packages/ai/test/provider-options/compaction.types.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" import { + ai, CompactionPart, LanguageModel, - LLM, LLMClient, LLMEvent, LLMRequest, @@ -15,7 +15,7 @@ const openai = OpenAI.configure({ apiKey: "test", providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] }, }).responses("gpt-5.3-codex") -LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })) +ai.compact(ai.request({ model: openai, prompt: "hello" })) for (const model of [ OpenAI.configure().responses("fixture"), @@ -28,53 +28,53 @@ for (const model of [ LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }), LanguageModel.make(LanguageModel.input(openai)), ]) { - LLMClient.compact(LLM.request({ model, prompt: "hello" })) + ai.compact(ai.request({ model, prompt: "hello" })) } const unsupported = { - anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }), - openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }), - azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }), - xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }), - compatible: LLM.request({ + anthropic: ai.request({ model: Anthropic.configure().model("fixture") }), + openaiChat: ai.request({ model: OpenAI.configure().chat("fixture") }), + azureChat: ai.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }), + xaiChat: ai.request({ model: XAI.configure().chat("fixture") }), + compatible: ai.request({ model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"), }), } // @ts-expect-error Anthropic has no standalone compact endpoint. -LLMClient.compact(unsupported.anthropic) +ai.compact(unsupported.anthropic) // @ts-expect-error Chat does not expose Responses compaction. -LLMClient.compact(unsupported.openaiChat) +ai.compact(unsupported.openaiChat) // @ts-expect-error Azure Chat does not expose Responses compaction. -LLMClient.compact(unsupported.azureChat) +ai.compact(unsupported.azureChat) // @ts-expect-error xAI Chat does not expose Responses compaction. -LLMClient.compact(unsupported.xaiChat) +ai.compact(unsupported.xaiChat) // @ts-expect-error Protocol compatibility does not guarantee endpoint support. -LLMClient.compact(unsupported.compatible) +ai.compact(unsupported.compatible) LLMClient.Service.use((client) => { // @ts-expect-error The service enforces the same capability as the convenience function. return client.compact(unsupported.anthropic) }) -const request = LLM.request({ model: openai, prompt: "hello" }) -LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] })) -LLMClient.compact(new LLMRequest(LLMRequest.input(request))) +const request = ai.request({ model: openai, prompt: "hello" }) +ai.compact(LLMRequest.update(request, { messages: [Message.user("continue")] })) +ai.compact(new LLMRequest(LLMRequest.input(request))) const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") }) // @ts-expect-error Switching models replaces, rather than inherits, the capability. -LLMClient.compact(switched) -LLMClient.compact(LLMRequest.update(switched, { model: openai })) -LLMClient.compact( +ai.compact(switched) +ai.compact(LLMRequest.update(switched, { model: openai })) +ai.compact( // @ts-expect-error Replacing the route also replaces compaction capability. - LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }), + ai.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }), ) declare const dynamicModel: LanguageModel declare const dynamicPatch: Partial -const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" }) +const dynamicRequest = ai.request({ model: dynamicModel, prompt: "hello" }) // @ts-expect-error A dynamically selected model must be narrowed first. -LLMClient.compact(dynamicRequest) -if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest) +ai.compact(dynamicRequest) +if (ai.canCompact(dynamicRequest)) ai.compact(dynamicRequest) // @ts-expect-error An optional model override cannot retain the old capability statically. -LLMClient.compact(LLMRequest.update(request, dynamicPatch)) +ai.compact(LLMRequest.update(request, dynamicPatch)) const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" }) const provider = ProviderID.make("anthropic") @@ -99,7 +99,7 @@ if (checkpoint.text !== undefined) { checkpoint.encrypted // @ts-expect-error Compaction parts do not contain a generic provider payload. checkpoint.value -LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe( +ai.compact(ai.request({ model: openai, prompt: "hello" })).pipe( Effect.map((result) => { result.replacement satisfies ReadonlyArray // @ts-expect-error The replacement window is named explicitly; the old field is not an alias. @@ -108,7 +108,7 @@ LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe( result.message }), ) -LLM.request({ +ai.request({ model: openai, providerOptions: { // @ts-expect-error A token threshold is numeric. @@ -116,7 +116,7 @@ LLM.request({ }, }) const anthropic = Anthropic.configure().model("claude-opus-4-6") -LLM.request({ +ai.request({ model: anthropic, providerOptions: { contextManagement: { @@ -124,7 +124,7 @@ LLM.request({ }, }, }) -LLM.request({ +ai.request({ model: anthropic, providerOptions: { // @ts-expect-error A pause setting is boolean. diff --git a/packages/ai/test/provider-options/google-vertex-chat.types.ts b/packages/ai/test/provider-options/google-vertex-chat.types.ts index 76635ecb9f56..a54234c8b20f 100644 --- a/packages/ai/test/provider-options/google-vertex-chat.types.ts +++ b/packages/ai/test/provider-options/google-vertex-chat.types.ts @@ -1,7 +1,7 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { GoogleVertexChat } from "../../src/providers.js" const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini") -LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } }) +ai.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } }) +ai.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } }) diff --git a/packages/ai/test/provider-options/google-vertex-interactions.types.ts b/packages/ai/test/provider-options/google-vertex-interactions.types.ts index 84a0562663ac..29a9fbf42747 100644 --- a/packages/ai/test/provider-options/google-vertex-interactions.types.ts +++ b/packages/ai/test/provider-options/google-vertex-interactions.types.ts @@ -1,17 +1,17 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { GoogleVertexInteractions } from "../../src/providers.js" import { Auth } from "../../src/route/auth.js" import { model } from "../../src/providers/google-vertex/interactions.js" const selected = GoogleVertexInteractions.configure({ project: "project" }).model("gemini-3.8-flash") -LLM.request({ +ai.request({ model: selected, prompt: "Hello", providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto", serviceTier: "flex", store: false }, }) -LLM.request({ +ai.request({ model: selected, prompt: "Hello", // @ts-expect-error Interactions store must be boolean. diff --git a/packages/ai/test/provider-options/google-vertex-messages.types.ts b/packages/ai/test/provider-options/google-vertex-messages.types.ts index a3e420bfc812..0feab7190466 100644 --- a/packages/ai/test/provider-options/google-vertex-messages.types.ts +++ b/packages/ai/test/provider-options/google-vertex-messages.types.ts @@ -1,11 +1,11 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { GoogleVertexMessages } from "../../src/providers.js" const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude") -LLM.request({ model, prompt: "Hello", providerOptions: { effort: "medium" } }) +ai.request({ model, prompt: "Hello", providerOptions: { effort: "medium" } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Vertex Anthropic effort must be a string. diff --git a/packages/ai/test/provider-options/google-vertex-responses.types.ts b/packages/ai/test/provider-options/google-vertex-responses.types.ts index 049575e590f9..2fb73659d3b6 100644 --- a/packages/ai/test/provider-options/google-vertex-responses.types.ts +++ b/packages/ai/test/provider-options/google-vertex-responses.types.ts @@ -1,12 +1,12 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { GoogleVertexResponses } from "../../src/providers.js" const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini") -LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "high" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) +ai.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "high" } }) +ai.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Vertex Responses verbosity must be a string. diff --git a/packages/ai/test/provider-options/google-vertex.types.ts b/packages/ai/test/provider-options/google-vertex.types.ts index 83bda8deb1d5..d9c6a0483757 100644 --- a/packages/ai/test/provider-options/google-vertex.types.ts +++ b/packages/ai/test/provider-options/google-vertex.types.ts @@ -1,15 +1,15 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { GoogleVertex } from "../../src/providers.js" const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-2.5-pro") -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { thinkingConfig: { includeThoughts: true } }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Vertex Gemini includeThoughts must be boolean. diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts index f36767baac59..3ffcf068d8de 100644 --- a/packages/ai/test/provider-options/google.types.ts +++ b/packages/ai/test/provider-options/google.types.ts @@ -1,15 +1,15 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Google } from "../../src/providers.js" const model = Google.provider.model("gemini-2.5-pro") -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { thinkingConfig: { thinkingBudget: 1024 } }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { @@ -18,7 +18,7 @@ LLM.request({ }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { @@ -29,14 +29,14 @@ LLM.request({ }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Gemini thinking budgets must be numeric. providerOptions: { thinkingConfig: { thinkingBudget: "large" } }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { thinkingConfig: { thinkingLevel: "maximum" } }, diff --git a/packages/ai/test/provider-options/minimax.types.ts b/packages/ai/test/provider-options/minimax.types.ts index 278f621bb524..e4e9effd6147 100644 --- a/packages/ai/test/provider-options/minimax.types.ts +++ b/packages/ai/test/provider-options/minimax.types.ts @@ -1,25 +1,25 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { MiniMax } from "../../src/providers.js" const minimax = MiniMax.configure() -LLM.request({ model: minimax.model("MiniMax-M3"), providerOptions: { thinking: { type: "adaptive" } } }) -LLM.request({ model: minimax.chat("MiniMax-M3"), providerOptions: { thinking: { type: "disabled" } } }) -LLM.request({ model: minimax.chat("MiniMax-M3"), providerOptions: { reasoningSplit: false } }) -LLM.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "minimal" } }) -LLM.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "future-effort" } }) +ai.request({ model: minimax.model("MiniMax-M3"), providerOptions: { thinking: { type: "adaptive" } } }) +ai.request({ model: minimax.chat("MiniMax-M3"), providerOptions: { thinking: { type: "disabled" } } }) +ai.request({ model: minimax.chat("MiniMax-M3"), providerOptions: { reasoningSplit: false } }) +ai.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "minimal" } }) +ai.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "future-effort" } }) -LLM.request({ +ai.request({ model: minimax.model("MiniMax-M3"), // @ts-expect-error MiniMax Messages has no documented effort setting. providerOptions: { effort: "high" }, }) -LLM.request({ +ai.request({ model: minimax.chat("MiniMax-M3"), // @ts-expect-error Chat reasoning_split is a boolean. providerOptions: { reasoningSplit: "true" }, }) -LLM.request({ +ai.request({ model: minimax.responses("MiniMax-M3"), // @ts-expect-error MiniMax Responses uses reasoning effort rather than Messages thinking. providerOptions: { thinking: { type: "adaptive" } }, diff --git a/packages/ai/test/provider-options/mistral.types.ts b/packages/ai/test/provider-options/mistral.types.ts index f7bdced56ced..17a3f52e24d8 100644 --- a/packages/ai/test/provider-options/mistral.types.ts +++ b/packages/ai/test/provider-options/mistral.types.ts @@ -1,22 +1,22 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Mistral } from "../../src/providers.js" const selected = Mistral.provider.model("mistral-small-latest") -LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } }) -LLM.request({ +ai.request({ model: selected, prompt: "Hello", // @ts-expect-error Mistral reasoning effort must be a string. providerOptions: { reasoningEffort: 1 }, }) -LLM.request({ +ai.request({ model: selected, prompt: "Hello", // @ts-expect-error Mistral prompt mode only supports reasoning. diff --git a/packages/ai/test/provider-options/moonshot.types.ts b/packages/ai/test/provider-options/moonshot.types.ts index 07ae1ad42a68..f87ce39fa743 100644 --- a/packages/ai/test/provider-options/moonshot.types.ts +++ b/packages/ai/test/provider-options/moonshot.types.ts @@ -1,28 +1,28 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Moonshot } from "../../src/providers.js" const moonshot = Moonshot.configure() -LLM.request({ model: moonshot.model("kimi-k2.6"), providerOptions: { thinking: { type: "enabled", keep: "all" } } }) -LLM.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking: { type: "disabled", keep: null } } }) -LLM.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: "high" } }) -LLM.request({ +ai.request({ model: moonshot.model("kimi-k2.6"), providerOptions: { thinking: { type: "enabled", keep: "all" } } }) +ai.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking: { type: "disabled", keep: null } } }) +ai.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: "high" } }) +ai.request({ model: moonshot.messages("kimi-k3"), providerOptions: { effort: "low", metadata: { user_id: "fixture" } }, }) -LLM.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: "future-effort" } }) +ai.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: "future-effort" } }) -LLM.request({ +ai.request({ model: moonshot.messages("kimi-k3"), // @ts-expect-error Messages exposes effort rather than the K2 Chat thinking parameter. providerOptions: { thinking: { type: "enabled" } }, }) -LLM.request({ +ai.request({ model: moonshot.chat("kimi-k2.6"), // @ts-expect-error Preserved thinking takes a string or null. providerOptions: { thinking: { type: "enabled", keep: true } }, }) -LLM.request({ +ai.request({ model: moonshot.responses("kimi-k3"), // @ts-expect-error Responses uses reasoningEffort rather than the Messages effort option. providerOptions: { effort: "high" }, diff --git a/packages/ai/test/provider-options/openai-compatible-responses.types.ts b/packages/ai/test/provider-options/openai-compatible-responses.types.ts index dca0f885323f..17f780af17ea 100644 --- a/packages/ai/test/provider-options/openai-compatible-responses.types.ts +++ b/packages/ai/test/provider-options/openai-compatible-responses.types.ts @@ -1,15 +1,15 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { OpenAICompatibleResponses } from "../../src/providers.js" const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model") -LLM.request({ model, prompt: "Hello", providerOptions: { reasoningSummary: "detailed" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "low" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) +ai.request({ model, prompt: "Hello", providerOptions: { reasoningSummary: "detailed" } }) +ai.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) +ai.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) +ai.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "low" } }) +ai.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error Open Responses reasoning summaries use a fixed union. diff --git a/packages/ai/test/provider-options/openai-compatible.types.ts b/packages/ai/test/provider-options/openai-compatible.types.ts index 39f62753f27f..cc6ac0295f9e 100644 --- a/packages/ai/test/provider-options/openai-compatible.types.ts +++ b/packages/ai/test/provider-options/openai-compatible.types.ts @@ -1,11 +1,11 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { OpenAICompatible } from "../../src/providers.js" const model = OpenAICompatible.configure({ baseURL: "https://compatible.example/v1" }).model("test-model") -LLM.request({ model, prompt: "Hello", providerOptions: { store: false } }) +ai.request({ model, prompt: "Hello", providerOptions: { store: false } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error OpenAI-compatible store must be boolean. diff --git a/packages/ai/test/provider-options/openai.types.ts b/packages/ai/test/provider-options/openai.types.ts index cbd86b636ebe..3d2d73587a7d 100644 --- a/packages/ai/test/provider-options/openai.types.ts +++ b/packages/ai/test/provider-options/openai.types.ts @@ -1,20 +1,20 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { OpenAI } from "../../src/providers.js" const selected = OpenAI.responses("gpt-5") const chat = OpenAI.chat("gpt-4o-mini") -LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "ultrafast" } }) -LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } }) -LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } }) -LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "ultrafast" } }) +ai.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } }) +ai.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } }) +ai.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) -LLM.request({ +ai.request({ model: selected, prompt: "Hello", // @ts-expect-error OpenAI reasoning effort must be a string. diff --git a/packages/ai/test/provider-options/openrouter.types.ts b/packages/ai/test/provider-options/openrouter.types.ts index ce1741711dda..bae19453bce2 100644 --- a/packages/ai/test/provider-options/openrouter.types.ts +++ b/packages/ai/test/provider-options/openrouter.types.ts @@ -1,11 +1,11 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { OpenRouter } from "../../src/providers.js" const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5") -LLM.request({ model, prompt: "Hello", providerOptions: { usage: true } }) +ai.request({ model, prompt: "Hello", providerOptions: { usage: true } }) -LLM.request({ +ai.request({ model, prompt: "Hello", providerOptions: { @@ -25,9 +25,31 @@ LLM.request({ }, }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error OpenRouter usage must be boolean or an option record. providerOptions: { usage: "yes" }, }) + +const responses = OpenRouter.provider.responses("openai/gpt-5.4") + +ai.request({ + model: responses, + prompt: "Hello", + providerOptions: { + reasoningEffort: "low", + models: ["openai/gpt-5.4-mini"], + provider: { only: ["openai"], sort: "future-sort", data_collection: "future-policy" }, + plugins: [{ id: "future-plugin", enabled: true }], + debug: { echo_upstream_body: true }, + user: "user_123", + }, +}) + +ai.request({ + model: responses, + prompt: "Hello", + // @ts-expect-error OpenRouter fallback models must be a list of model IDs. + providerOptions: { models: "openai/gpt-5.4-mini" }, +}) diff --git a/packages/ai/test/provider-options/venice.types.ts b/packages/ai/test/provider-options/venice.types.ts index 986cf0dd3b98..28ff86a6137d 100644 --- a/packages/ai/test/provider-options/venice.types.ts +++ b/packages/ai/test/provider-options/venice.types.ts @@ -1,25 +1,25 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Venice } from "../../src/providers.js" -LLM.request({ +ai.request({ model: Venice.chat("qwen3-6-27b"), providerOptions: { reasoningEffort: "high", reasoning: { summary: "concise" }, veniceParameters: { includeVeniceSystemPrompt: false } }, }) -LLM.request({ +ai.request({ model: Venice.configure({ providerOptions: { reasoningEffort: "future-effort" } }).model("future-model"), providerOptions: { reasoning: { enabled: false }, promptCacheRetention: "future-retention", parallelToolCalls: false }, }) -LLM.request({ +ai.request({ model: Venice.chat("qwen3-6-27b"), // @ts-expect-error Thinking toggles are boolean. providerOptions: { reasoning: { enabled: "false" } }, }) -LLM.request({ +ai.request({ model: Venice.chat("qwen3-6-27b"), // @ts-expect-error Venice uses nested reasoning, not Anthropic thinking controls. providerOptions: { thinking: { type: "disabled" } }, }) -LLM.request({ +ai.request({ model: Venice.chat("qwen3-6-27b"), // @ts-expect-error Venice's system-prompt toggle is boolean. providerOptions: { veniceParameters: { includeVeniceSystemPrompt: "false" } }, diff --git a/packages/ai/test/provider-options/vercel-ai-gateway.types.ts b/packages/ai/test/provider-options/vercel-ai-gateway.types.ts index da7b791ab02f..9bb7b8d16051 100644 --- a/packages/ai/test/provider-options/vercel-ai-gateway.types.ts +++ b/packages/ai/test/provider-options/vercel-ai-gateway.types.ts @@ -1,21 +1,21 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { VercelAIGateway } from "../../src/providers/vercel-ai-gateway.js" const gateway = VercelAIGateway.configure({ apiKey: "fixture", providerOptions: { gateway: { only: ["anthropic", "bedrock"], caching: "auto" }, reasoningEffort: "high" }, }) -LLM.request({ +ai.request({ model: gateway.model("meta/muse-spark-1.3"), prompt: "Hello", providerOptions: { reasoningEffort: "max" }, }) -LLM.request({ +ai.request({ model: gateway.messages("anthropic/claude-sonnet-5.5"), prompt: "Hello", providerOptions: { thinking: { type: "enabled", budgetTokens: 2048 } }, }) -LLM.request({ +ai.request({ model: gateway.responses("openai/gpt-6-luna"), prompt: "Hello", providerOptions: { reasoningSummary: "detailed", cacheTTL: "1h" }, diff --git a/packages/ai/test/provider-options/xai.types.ts b/packages/ai/test/provider-options/xai.types.ts index 061dd9f8a4d2..1fae6950b638 100644 --- a/packages/ai/test/provider-options/xai.types.ts +++ b/packages/ai/test/provider-options/xai.types.ts @@ -1,12 +1,12 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { XAI } from "../../src/providers.js" const model = XAI.provider.model("grok-4") -LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) -LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) +ai.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) +ai.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) -LLM.request({ +ai.request({ model, prompt: "Hello", // @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string. diff --git a/packages/ai/test/provider-options/zai.types.ts b/packages/ai/test/provider-options/zai.types.ts index 28dc1e3fc650..6db506e29c98 100644 --- a/packages/ai/test/provider-options/zai.types.ts +++ b/packages/ai/test/provider-options/zai.types.ts @@ -1,31 +1,31 @@ -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { ZAI, ZAICodingPlan } from "../../src/providers.js" -LLM.request({ +ai.request({ model: ZAI.chat("glm-5.3"), providerOptions: { thinking: { clear_thinking: false }, reasoningEffort: "max" }, }) -LLM.request({ +ai.request({ model: ZAI.chat("glm-5.2"), providerOptions: { thinking: { type: "disabled" }, toolStream: false, responseFormat: { type: "json_object" } }, }) -LLM.request({ +ai.request({ model: ZAICodingPlan.messages("glm-5.3"), providerOptions: { thinking: { type: "enabled" }, effort: "high" }, }) -LLM.request({ model: ZAICodingPlan.responses("glm-5.3"), providerOptions: { reasoningEffort: "future-effort" } }) +ai.request({ model: ZAICodingPlan.responses("glm-5.3"), providerOptions: { reasoningEffort: "future-effort" } }) -LLM.request({ +ai.request({ model: ZAI.chat("glm-5.3"), // @ts-expect-error clear_thinking is a boolean. providerOptions: { thinking: { clear_thinking: "false" } }, }) -LLM.request({ +ai.request({ model: ZAICodingPlan.messages("glm-5.3"), // @ts-expect-error Messages uses effort. providerOptions: { reasoningEffort: "high" }, }) -LLM.request({ +ai.request({ model: ZAICodingPlan.responses("glm-5.3"), // @ts-expect-error Responses uses reasoning effort, not Chat thinking controls. providerOptions: { thinking: { type: "enabled" } }, diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index e2b02ddcbcca..9d0aff227fcd 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { model } from "@opencode/ai/providers/openai" -import { LLM } from "../src/effect.js" +import { ai } from "../src/effect.js" import { Endpoint } from "../src/route/endpoint.js" const configuration = (provider: string, message: string) => @@ -60,9 +60,24 @@ describe("provider package entrypoints", () => { test("maps OpenRouter, DigitalOcean and Venice Responses entrypoints onto provider-owned routes", async () => { const providers = [ - ["openrouter", "https://openrouter.ai/api/v1", await import("@opencode/ai/providers/openrouter/responses")], - ["digitalocean", "https://inference.do-ai.run/v1", await import("@opencode/ai/providers/digitalocean/responses")], - ["venice", "https://api.venice.ai/api/v1", await import("@opencode/ai/providers/venice/responses")], + [ + "openrouter", + "https://openrouter.ai/api/v1", + "openrouter-responses", + await import("@opencode/ai/providers/openrouter/responses"), + ], + [ + "digitalocean", + "https://inference.do-ai.run/v1", + "open-responses", + await import("@opencode/ai/providers/digitalocean/responses"), + ], + [ + "venice", + "https://api.venice.ai/api/v1", + "open-responses", + await import("@opencode/ai/providers/venice/responses"), + ], ] as const const settings = { apiKey: "fixture", @@ -70,11 +85,11 @@ describe("provider package entrypoints", () => { body: { future_option: true }, reasoningEffort: "low", } - for (const [provider, baseURL, module] of providers) { + for (const [provider, baseURL, protocol, module] of providers) { const selected = module.model("gpt-5.4", settings) expect(selected.provider).toBe(provider) expect(selected.route.id).toBe(`${provider}-responses`) - expect(selected.route.protocol).toBe("open-responses") + expect(selected.route.protocol).toBe(protocol) expect(selected.route.endpoint).toMatchObject({ baseURL, path: "/responses" }) expect(selected.route.defaults.headers).toEqual(settings.headers) expect(selected.route.defaults.http?.body).toEqual(settings.body) @@ -291,7 +306,7 @@ describe("provider package entrypoints", () => { baseURL: "https://messages.example.test/v1", }) expect( - Endpoint.render(selected.route.endpoint, { request: LLM.request({ model: selected }), body: {} }).toString(), + Endpoint.render(selected.route.endpoint, { request: ai.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" } }) diff --git a/packages/ai/test/provider/alibaba-capabilities.recorded.test.ts b/packages/ai/test/provider/alibaba-capabilities.recorded.test.ts index 1625a55ea34a..879bd67bc271 100644 --- a/packages/ai/test/provider/alibaba-capabilities.recorded.test.ts +++ b/packages/ai/test/provider/alibaba-capabilities.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, Media } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Media, Message, ToolDefinition } from "../../src/effect.js" import { Alibaba } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -25,7 +25,7 @@ for (const api of ["chat", "messages", "responses"] as const) { { tags: ["thinking", "usage"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: alibaba[api]("qwen3.7-plus"), providerOptions: api === "messages" @@ -59,7 +59,7 @@ for (const api of ["chat", "messages", "responses"] as const) { Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(), ) const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba[api]("qwen3.8-flash"), providerOptions: api === "messages" ? { thinking: { type: "disabled" } } : { enableThinking: false }, messages: [ @@ -82,7 +82,7 @@ for (const api of ["chat", "messages", "responses"] as const) { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba[api]("qwen3.8-max"), prompt: "Find the current weather in Paris.", providerOptions: api === "messages" ? { thinking: { type: "disabled" } } : { reasoningEffort: "none" }, @@ -116,7 +116,7 @@ record("chat").effect.with( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba.chat("qwen3.8-max"), prompt: 'Return a JSON object with one key "city" set to the capital city of France.', providerOptions: { reasoningEffort: "none", responseFormat: { type: "json_object" } }, @@ -135,7 +135,7 @@ record("messages").effect.with( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba.messages("qwen3.8-max"), prompt: 'Return a JSON object with one key "city" set to the capital city of France.', providerOptions: { @@ -168,7 +168,7 @@ responses.effect.with( () => Effect.gen(function* () { const first = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba.responses("qwen3.8-max"), prompt: "Remember the password word apricot. Reply OK.", providerOptions: { store: true, reasoningEffort: "none" }, @@ -179,7 +179,7 @@ responses.effect.with( expect(id).toBeString() if (typeof id !== "string") throw new Error("Missing Alibaba response ID") const second = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba.responses("qwen3.8-max"), prompt: "What word did I ask you to remember? Reply with only the word.", providerOptions: { previousResponseId: id, store: true, reasoningEffort: "none" }, @@ -197,7 +197,7 @@ responses.effect.with( { tags: ["hosted-tool", "web-search", "web-extractor"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: alibaba.responses("qwen3.8-max"), prompt: "Use web search to find Alibaba Cloud Model Studio's official documentation, then use web_extractor to read the page. Give a brief summary with the source URL.", @@ -235,7 +235,7 @@ responses.effect.with( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: alibaba.responses("qwen3.8-max"), prompt: "Use the code interpreter to compute the SHA-256 hash of the UTF-8 string hello (no newline). Reply with only the hash.", diff --git a/packages/ai/test/provider/alibaba-images.recorded.test.ts b/packages/ai/test/provider/alibaba-images.recorded.test.ts index 9b8e5584e4ba..26f929d8bfc0 100644 --- a/packages/ai/test/provider/alibaba-images.recorded.test.ts +++ b/packages/ai/test/provider/alibaba-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image, Media } from "../../src/effect.js" +import { ai, Media } from "../../src/effect.js" import { Alibaba } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" @@ -17,7 +17,7 @@ const recorded = recordedTests({ describe("Alibaba Images recorded", () => { recorded.effect("generates Qwen-Image images", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: alibaba.image("qwen-image-2.0"), prompt: "A simple flat red circle centered on a plain white background.", n: 2, @@ -34,7 +34,7 @@ describe("Alibaba Images recorded", () => { recorded.effect("generates Wan images", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: alibaba.image("wan2.7-image"), prompt: "A simple flat blue square centered on a plain white background.", n: 2, @@ -51,7 +51,7 @@ describe("Alibaba Images recorded", () => { "edits an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: alibaba.image("qwen-image-2.0"), prompt: "Keep the simple shape and change it from black to bright purple.", images: [ diff --git a/packages/ai/test/provider/alibaba-images.test.ts b/packages/ai/test/provider/alibaba-images.test.ts index ae0f56378b27..24da44452499 100644 --- a/packages/ai/test/provider/alibaba-images.test.ts +++ b/packages/ai/test/provider/alibaba-images.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { HttpClientRequest } from "effect/http" -import { Image, ImageClient, Media } from "../../src/effect.js" +import { ai, ImageClient, Media } from "../../src/effect.js" import { Alibaba } from "../../src/providers.js" import { it } from "../lib/effect.js" import { dynamicResponse, fixedResponse } from "../lib/http.js" @@ -14,7 +14,7 @@ const result = JSON.stringify({ describe("Alibaba Images", () => { it.effect("generates through the Model Studio multimodal generation API", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: Alibaba.configure({ region: "cn-beijing", workspaceID: "llm-fixture", @@ -118,7 +118,7 @@ describe("Alibaba Images", () => { { region: "us-east-1" }, { baseURL: "https://token-plan.example/api/v1" }, ]) - yield* Image.generate({ + yield* ai.image.generate({ model: Alibaba.configure({ ...config, apiKey: "test" }).image("qwen-image-2.0"), prompt: "test", }).pipe(Effect.provide(layer)) @@ -135,11 +135,11 @@ describe("Alibaba Images", () => { Effect.gen(function* () { const model = Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).image("qwen-image-2.0") const layer = ImageClient.layer.pipe(Layer.provide(fixedResponse(result, json))) - const ref = yield* Image.generate({ model, prompt: "test", images: [Media.ref("alibaba", "file-1")] }).pipe( + const ref = yield* ai.image.generate({ model, prompt: "test", images: [Media.ref("alibaba", "file-1")] }).pipe( Effect.provide(layer), Effect.flip, ) - const tooLarge = yield* Image.generate({ model, prompt: "test", aspect: "1:1", resolution: "4K" }).pipe( + const tooLarge = yield* ai.image.generate({ model, prompt: "test", aspect: "1:1", resolution: "4K" }).pipe( Effect.provide(layer), Effect.flip, ) @@ -165,7 +165,7 @@ describe("Alibaba Images", () => { ] yield* Effect.forEach(payloads, (payload) => - Image.generate({ model, prompt: "test" }).pipe( + ai.image.generate({ model, prompt: "test" }).pipe( Effect.provide(ImageClient.layer.pipe(Layer.provide(fixedResponse(JSON.stringify(payload), json)))), Effect.flip, Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))), @@ -176,7 +176,7 @@ describe("Alibaba Images", () => { it.effect("surfaces Model Studio error messages", () => Effect.gen(function* () { - const error = yield* Image.generate({ + const error = yield* ai.image.generate({ model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).image("qwen-image-2.0"), prompt: "test", }).pipe( diff --git a/packages/ai/test/provider/alibaba.recorded.test.ts b/packages/ai/test/provider/alibaba.recorded.test.ts index f1732b0e51d0..253a11408772 100644 --- a/packages/ai/test/provider/alibaba.recorded.test.ts +++ b/packages/ai/test/provider/alibaba.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, type LLMResponse, Message, ToolDefinition } from "../../src/effect.js" import { Alibaba } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -35,7 +35,7 @@ for (const api of ["chat", "messages", "responses"] as const) { { tags: ["text", "reasoning", "usage"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: alibaba[api]("qwen3.8-max"), prompt: "What is 173 multiplied by 219? Reply with only the final integer.", providerOptions: api === "messages" ? { effort } : { reasoningEffort: effort }, @@ -66,7 +66,7 @@ for (const api of ["chat", "messages", "responses"] as const) { { tags: ["tool", "tool-loop", "reasoning", "continuation"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: alibaba[api]("qwen3.8-max"), providerOptions: api === "messages" diff --git a/packages/ai/test/provider/alibaba.test.ts b/packages/ai/test/provider/alibaba.test.ts index de9acbf21326..e20b505a359b 100644 --- a/packages/ai/test/provider/alibaba.test.ts +++ b/packages/ai/test/provider/alibaba.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { Auth, LLM, LLMClient, LLMRequest, Message, ReasoningPart, ToolDefinition } from "../../src/effect.js" +import { ai, Auth, LLMClient, LLMRequest, Message, ReasoningPart, ToolDefinition } from "../../src/effect.js" import { Alibaba } from "../../src/providers.js" import { compileRequest } from "../../src/route/client.js" import { Endpoint } from "../../src/route/endpoint.js" @@ -34,7 +34,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () => expect(provider.model).toBe(provider.chat) for (const api of ["chat", "messages", "responses"] as const) { const model = provider[api]("qwen-plus-us") - const request = LLM.request({ model, prompt: "Hello" }) + const request = ai.request({ model, prompt: "Hello" }) const compiled = yield* compileRequest(request) expect(Endpoint.render(model.route.endpoint, { request, body: compiled.body }).toString()).toBe( `https://llm-fixture.${region}.maas.aliyuncs.com${paths[api]}`, @@ -64,7 +64,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () => ]) { const provider = Alibaba.configure({ region, apiKey: "fixture" }) for (const api of ["chat", "messages", "responses"] as const) { - const request = LLM.request({ model: provider[api]("qwen3.8-max") }) + const request = ai.request({ model: provider[api]("qwen3.8-max") }) const compiled = yield* compileRequest(request) expect(Endpoint.render(request.model.route.endpoint, { request, body: compiled.body }).toString()).toBe( `https://${host}${paths[api]}`, @@ -118,7 +118,7 @@ it.effect("Alibaba resolves explicit auth, API keys, and regional environment cr ]) { const provider = Alibaba.configure({ region: "ap-southeast-1", ...item.config }) for (const api of ["chat", "messages", "responses"] as const) { - const request = LLM.request({ model: provider[api]("qwen3.8-max") }) + const request = ai.request({ model: provider[api]("qwen3.8-max") }) const headers = yield* request.model.route.auth .apply({ request, method: "POST", url: "https://fixture", body: "{}", headers: Headers.empty }) .pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: item.env })))) @@ -133,13 +133,13 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" }) for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "future-effort"]) { const chat = yield* compileRequest( - LLM.request({ model: provider.chat("qwen3.8-max"), providerOptions: { reasoningEffort: effort } }), + ai.request({ model: provider.chat("qwen3.8-max"), providerOptions: { reasoningEffort: effort } }), ) const messages = yield* compileRequest( - LLM.request({ model: provider.messages("qwen3.8-max"), providerOptions: { effort } }), + ai.request({ model: provider.messages("qwen3.8-max"), providerOptions: { effort } }), ) const responses = yield* compileRequest( - LLM.request({ model: provider.responses("qwen3.8-max"), providerOptions: { reasoningEffort: effort } }), + ai.request({ model: provider.responses("qwen3.8-max"), providerOptions: { reasoningEffort: effort } }), ) expect(chat.body.reasoning_effort).toBe(effort) expect(messages.body.output_config).toEqual({ effort }) @@ -151,7 +151,7 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s } for (const enableThinking of [true, false]) { const chat = yield* compileRequest( - LLM.request({ + ai.request({ model: provider.chat("qwen3.7-plus"), tools: [tool], generation: { maxTokens: 1234, topK: 20 }, @@ -188,7 +188,7 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s expect.objectContaining({ function: expect.not.objectContaining({ strict: expect.anything() }) }), ]) const responses = yield* compileRequest( - LLM.request({ + ai.request({ model: provider.responses("qwen3.7-plus"), providerOptions: { enableThinking, store: false, previousResponseId: "resp_previous" }, }), @@ -206,7 +206,7 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s { type: "future", budget_tokens: 4096 }, ]) { const messages = yield* compileRequest( - LLM.request({ model: provider.messages("qwen3.7-plus"), providerOptions: { thinking } }), + ai.request({ model: provider.messages("qwen3.7-plus"), providerOptions: { thinking } }), ) expect(messages.body.thinking).toEqual({ type: thinking.type, @@ -221,7 +221,7 @@ it.effect("Alibaba fits explicit thinking budgets to half the output limit", () const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" }) const chat = (maxTokens?: number) => compileRequest( - LLM.request({ + ai.request({ model: provider.chat("qwen3.7-plus"), prompt: "hi", ...(maxTokens === undefined ? {} : { generation: { maxTokens } }), @@ -229,7 +229,7 @@ it.effect("Alibaba fits explicit thinking budgets to half the output limit", () }), ).pipe(Effect.map((prepared) => prepared.body.thinking_budget)) const messages = yield* compileRequest( - LLM.request({ + ai.request({ model: provider.messages("qwen3.7-plus"), prompt: "hi", generation: { maxTokens: 32_000 }, @@ -252,7 +252,7 @@ it.effect("Alibaba validates malformed options before execution", () => ["responses", { enableThinking: "false" }], ] as const) { const model = provider[api]("qwen3.8-max").route.with({ providerOptions }).model({ id: "qwen3.8-max" }) - const error = yield* compileRequest(LLM.request({ model })).pipe(Effect.flip) + const error = yield* compileRequest(ai.request({ model })).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") } }), @@ -261,7 +261,7 @@ it.effect("Alibaba validates malformed options before execution", () => it.effect("Alibaba preserves unsigned and signed Messages thinking without a budget requirement", () => Effect.gen(function* () { const result = yield* compileRequest( - LLM.request({ + ai.request({ model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" }).messages("qwen3.8-max"), providerOptions: { thinking: { type: "enabled" }, effort: "low" }, messages: [ @@ -294,7 +294,7 @@ it.effect("Alibaba preserves unsigned and signed Messages thinking without a bud it.effect("Alibaba serializes Messages configuration, per-request controls, and final HTTP overlays", () => LLMClient.generate( - LLM.request({ + ai.request({ model: Alibaba.configure({ baseURL: "https://gateway.example/v1", apiKey: "fixture", @@ -335,7 +335,7 @@ it.effect("Alibaba serializes Messages configuration, per-request controls, and it.effect("Alibaba Responses lowers hosted tools and named selection using HTTP even with a WebSocket executor", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" }).responses("qwen3.8-max"), tools: [Alibaba.webSearch(), Alibaba.webExtractor(), Alibaba.codeInterpreter(), tool], prompt: "Hello", diff --git a/packages/ai/test/provider/anthropic-compaction.test.ts b/packages/ai/test/provider/anthropic-compaction.test.ts index 68e89af48a19..8539e8d7d214 100644 --- a/packages/ai/test/provider/anthropic-compaction.test.ts +++ b/packages/ai/test/provider/anthropic-compaction.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, LLMRequest, Message } from "../../src/effect.js" +import { ai, LLMRequest, Message } from "../../src/effect.js" import { LLMClient } from "../../src/route/client.js" import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js" import { testEffect } from "../lib/effect.js" @@ -49,7 +49,7 @@ for (const fixture of [ ).effect(fixture.name, () => Effect.gen(function* () { const result = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello", }), @@ -113,7 +113,7 @@ for (const model of [ `${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, prompt: "hello", http: { headers: { "anthropic-beta": "existing-beta" } }, @@ -163,7 +163,7 @@ for (const events of [ () => Effect.gen(function* () { const error = yield* LLMClient.generate( - LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }), + ai.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }), ).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.reason.http?.status).toBe(200) diff --git a/packages/ai/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/ai/test/provider/anthropic-messages-cache.recorded.test.ts index c7b3371160c3..20e9a5efce2b 100644 --- a/packages/ai/test/provider/anthropic-messages-cache.recorded.test.ts +++ b/packages/ai/test/provider/anthropic-messages-cache.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src/effect.js" +import { ai, CacheHint, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import * as Anthropic from "../../src/providers/anthropic.js" import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios.js" @@ -13,7 +13,7 @@ const model = Anthropic.configure({ // Two identical generations in a row. The first call writes the prefix into // Anthropic's cache; the second should report a cache read against the same // prefix. Cassette captures both interactions in order. -const cacheRequest = LLM.request({ +const cacheRequest = ai.request({ id: "recorded_anthropic_cache", model, system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], @@ -48,7 +48,7 @@ const longToolTurn = [ ] }).flat(), ] -const longToolTurnRequest = LLM.request({ +const longToolTurnRequest = ai.request({ id: "recorded_anthropic_cache_long_tool_turn", model, system: LARGE_CACHEABLE_SYSTEM, diff --git a/packages/ai/test/provider/anthropic-messages.recorded.test.ts b/packages/ai/test/provider/anthropic-messages.recorded.test.ts index 1c3bda38b742..78bfa97fa2db 100644 --- a/packages/ai/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/ai/test/provider/anthropic-messages.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, AIError, Message, ToolCallPart } from "../../src/effect.js" +import { ai, AIError, Message, ToolCallPart } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import * as Anthropic from "../../src/providers/anthropic.js" import { weatherToolName } from "../recorded-scenarios.js" @@ -10,7 +10,7 @@ const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", }).model("claude-haiku-4-5-20251001") -const malformedToolOrderRequest = LLM.request({ +const malformedToolOrderRequest = ai.request({ id: "recorded_anthropic_malformed_tool_order", model, messages: [ diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index fbc0e93759dc..3dd67aa7b841 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -2,15 +2,15 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/http" import { - CacheHint, - LLM, + ai, AIError, + CacheHint, LLMRequest, + Media, Message, ToolCallPart, ToolDefinition, Usage, - Media, } from "../../src/effect.js" import { Auth, Endpoint, LLMClient, Route } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -31,7 +31,7 @@ const opus48 = AnthropicMessages.route const compileUnsignedReasoning = (model: LLMRequest["model"]) => compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])], cache: "none", @@ -44,7 +44,7 @@ const vertexOpus48 = GoogleVertexMessages.configure({ project: "test", }).model("claude-opus-4-8") -const request = LLM.request({ +const request = ai.request({ id: "req_1", model, system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) }, @@ -97,7 +97,7 @@ describe("Anthropic Messages route", () => { it.effect("filters whitespace-only text and removes empty messages", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user(" \n\t"), @@ -294,7 +294,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus48, messages: [ Message.user("Before."), @@ -332,7 +332,7 @@ describe("Anthropic Messages route", () => { const prepared = yield* Effect.forEach(ids, (id) => compileRequest( - LLM.request({ + ai.request({ model: AnthropicMessages.route .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) .model({ id }), @@ -349,7 +349,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Before."), @@ -385,7 +385,7 @@ describe("Anthropic Messages route", () => { const prepared = yield* Effect.forEach(ids, (id) => compileRequest( - LLM.request({ + ai.request({ model: AnthropicMessages.route .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) .model({ id }), @@ -404,7 +404,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects non-text chronological system update content before send", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model: opus48, messages: [ Message.user("Before."), @@ -421,7 +421,7 @@ describe("Anthropic Messages route", () => { Effect.gen(function* () { expect( (yield* compileRequest( - LLM.request({ + ai.request({ model: opus48, messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], cache: "none", @@ -435,12 +435,12 @@ describe("Anthropic Messages route", () => { }, ]) expect( - (yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }))) - .body.messages, + (yield* compileRequest(ai.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }))).body + .messages, ).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }]) expect( (yield* compileRequest( - LLM.request({ + ai.request({ model: opus48, messages: [ Message.user("Start."), @@ -463,7 +463,7 @@ describe("Anthropic Messages route", () => { it.effect("moves system updates to the next assistant turn and sends consecutive updates together", () => Effect.gen(function* () { const lower = (messages: ReadonlyArray) => - compileRequest(LLM.request({ model: opus48, messages: [...messages], cache: "none" })).pipe( + compileRequest(ai.request({ model: opus48, messages: [...messages], cache: "none" })).pipe( Effect.map((prepared) => prepared.body.messages), ) const system = (text: string) => ({ role: "system", content: [{ type: "text", text, cache_control: undefined }] }) @@ -521,7 +521,7 @@ describe("Anthropic Messages route", () => { it.effect("keeps wrapped system updates in place for models without native system updates", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Fix it."), @@ -566,9 +566,9 @@ describe("Anthropic Messages route", () => { { role: "system", content: [{ type: "text", text: "Operator update.", cache_control: undefined }] }, ] - const terminal = yield* compileRequest(LLM.request({ model: vertexOpus48, messages: toolTurn, cache: "none" })) + const terminal = yield* compileRequest(ai.request({ model: vertexOpus48, messages: toolTurn, cache: "none" })) const history = yield* compileRequest( - LLM.request({ + ai.request({ model: vertexOpus48, messages: [...toolTurn, Message.assistant("Acknowledged."), Message.user("Next step.")], cache: "none", @@ -587,7 +587,7 @@ describe("Anthropic Messages route", () => { it.effect("keeps a terminal direct Anthropic system update native", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus48, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), @@ -626,7 +626,7 @@ describe("Anthropic Messages route", () => { it.effect("keeps an ordinary terminal Vertex system update native", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: vertexOpus48, messages: [Message.user("Before."), Message.system("Operator update.")], cache: "none", @@ -646,7 +646,7 @@ describe("Anthropic Messages route", () => { it.effect("moves a system update between a local tool call and its result after the result", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: opus48, messages: [ Message.user("Use the tool."), @@ -669,7 +669,7 @@ describe("Anthropic Messages route", () => { it.effect("prepares tool call and tool result messages", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result", model, messages: [ @@ -702,7 +702,7 @@ describe("Anthropic Messages route", () => { const id = `functions.lookup:1|${"x".repeat(64)}` const scrubbed = `functions_lookup_1_${"x".repeat(64)}` const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]), @@ -723,7 +723,7 @@ describe("Anthropic Messages route", () => { it.effect("batches parallel tool results into one Anthropic user message", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Check both cities."), @@ -764,7 +764,7 @@ describe("Anthropic Messages route", () => { it.effect("keeps tools and sends tool_choice none", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_choice_none", model, tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }], @@ -794,7 +794,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers media tool-result content as structured blocks", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_image", model, messages: [ @@ -827,7 +827,7 @@ describe("Anthropic Messages route", () => { Effect.gen(function* () { const spaced = " Keep this spacing. " const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -846,7 +846,7 @@ describe("Anthropic Messages route", () => { }), ) const blank = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -867,7 +867,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers single-image tool-result content as a structured image block", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_image_only", model, messages: [ @@ -892,7 +892,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects tool-result media that cannot be lowered", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_unsupported_media", model, messages: [ @@ -926,7 +926,7 @@ describe("Anthropic Messages route", () => { return Array.isArray(content) ? content.filter((item) => item.type === "image") : [] } const success = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -936,7 +936,7 @@ describe("Anthropic Messages route", () => { }), ) const failure = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -974,7 +974,7 @@ describe("Anthropic Messages route", () => { it.effect("sends only text blocks in an error tool_result", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -1023,7 +1023,7 @@ describe("Anthropic Messages route", () => { content?: ReadonlyArray<{ type: "text"; text: string } | { type: "file"; uri: string; mime: string }>, ) => compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -1088,7 +1088,7 @@ describe("Anthropic Messages route", () => { it.effect("serializes Messages video inputs", () => Effect.gen(function* () { - const user = LLM.request({ + const user = ai.request({ model: customModel, prompt: [ Message.media(Media.base64("AAECAw==", "video/mp4"), { cache: new CacheHint({ type: "ephemeral" }) }), @@ -1129,7 +1129,7 @@ describe("Anthropic Messages route", () => { expect(foreign.message).toContain("got custom:file_clip") const native = yield* compileRequest( - LLM.request({ + ai.request({ model: AnthropicMessages.route.model({ id: "video-model" }), prompt: Message.media(Media.base64("AAECAw==", "video/mp4")), cache: "none", @@ -1141,7 +1141,7 @@ describe("Anthropic Messages route", () => { it.effect("moves Meta tool result videos after consecutive results", () => Effect.gen(function* () { - const meta = LLM.request({ + const meta = ai.request({ model: Meta.configure({ apiKey: "test" }).messages("video-model"), messages: [ Message.assistant([ @@ -1230,7 +1230,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers preserved Anthropic reasoning signature metadata", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -1308,7 +1308,7 @@ describe("Anthropic Messages route", () => { it.effect("round-trips redacted thinking as redacted_thinking blocks", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -1350,7 +1350,7 @@ describe("Anthropic Messages route", () => { citations: [{ type: "web_search_result_location", cited_text: "Example" }], }, ] - const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Search." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: compatible, prompt: "Search." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -1410,7 +1410,7 @@ describe("Anthropic Messages route", () => { }) const prepared = yield* compileRequest( - LLM.request({ model: compatible, messages: [response.message], cache: "none" }), + ai.request({ model: compatible, messages: [response.message], cache: "none" }), ) expect(prepared.body.messages).toEqual([ { @@ -1448,7 +1448,7 @@ describe("Anthropic Messages route", () => { { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } }, { type: "message_stop" }, ) - const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Think." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: compatible, prompt: "Think." })).pipe( Effect.provide(fixedResponse(body)), ) @@ -1466,7 +1466,7 @@ describe("Anthropic Messages route", () => { ]) const prepared = yield* compileRequest( - LLM.request({ model: compatible, messages: [response.message], cache: "none" }), + ai.request({ model: compatible, messages: [response.message], cache: "none" }), ) expect(prepared.body.messages).toEqual([ { role: "assistant", content: [{ type: "thinking", thinking: "Reasoning.", signature: "sig_1" }] }, @@ -1843,7 +1843,7 @@ describe("Anthropic Messages route", () => { { type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } }, ]) - const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) + const prepared = yield* compileRequest(ai.request({ model, messages: [response.message], cache: "none" })) expect(prepared.body.messages).toEqual([ { role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] }, ]) @@ -1985,7 +1985,7 @@ describe("Anthropic Messages route", () => { ), ) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Say hello."), @@ -2516,7 +2516,7 @@ describe("Anthropic Messages route", () => { it.effect("round-trips provider-executed assistant content into server tool blocks", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_round_trip", model, messages: [ @@ -2567,7 +2567,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects round-trip for unknown server tool names", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_unknown_server_tool", model, messages: [ @@ -2591,7 +2591,7 @@ describe("Anthropic Messages route", () => { it.effect("continues a conversation with user media content", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "req_media", model, messages: [ @@ -2644,7 +2644,7 @@ describe("Anthropic Messages route", () => { it.effect("sends the hint ttl on cache_control", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttl: "1h" }) }, prompt: "hi", @@ -2660,7 +2660,7 @@ describe("Anthropic Messages route", () => { it.effect("emits cache_control on tool definitions and tool-result blocks", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, tools: [ { @@ -2701,7 +2701,7 @@ describe("Anthropic Messages route", () => { Effect.gen(function* () { const hint = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: [ { type: "text", text: "a", cache: hint }, @@ -2727,7 +2727,7 @@ describe("Anthropic Messages route", () => { Effect.gen(function* () { const hint = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, tools: [ { diff --git a/packages/ai/test/provider/anthropic-thinking-binding.test.ts b/packages/ai/test/provider/anthropic-thinking-binding.test.ts index 76597b0be7dd..2a16f05f2e4a 100644 --- a/packages/ai/test/provider/anthropic-thinking-binding.test.ts +++ b/packages/ai/test/provider/anthropic-thinking-binding.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js" import { compileRequest } from "../../src/route/client.js" import { it } from "../lib/effect.js" @@ -21,7 +21,7 @@ for (const [id, enabled] of [ ] as const) { it.effect(`thinking-binding defaults for ${id}`, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: AnthropicMessages.route.model({ id }), prompt: "Hello", http: { headers: { "anthropic-beta": "existing-beta" } }, @@ -47,7 +47,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade { type: "between_tools" }, { type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } }, ] as const) { - const request = LLM.request({ + const request = ai.request({ model: AnthropicMessages.route.model({ id: "claude-fable-5-1" }), prompt: "Hello", providerOptions: { thinking, contextManagement: { edits: [{ type: "compact_20260112" }] } }, diff --git a/packages/ai/test/provider/assemblyai-transcription.recorded.test.ts b/packages/ai/test/provider/assemblyai-transcription.recorded.test.ts index 65176f7cd9b2..42bcb1c619ad 100644 --- a/packages/ai/test/provider/assemblyai-transcription.recorded.test.ts +++ b/packages/ai/test/provider/assemblyai-transcription.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Transcription } from "../../src/effect.js" +import { Transcription } from "../../src/experimental.js" import { AssemblyAI } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TRANSCRIPT, audio, audioRecording } from "./transcription-recording.js" diff --git a/packages/ai/test/provider/azure.recorded.test.ts b/packages/ai/test/provider/azure.recorded.test.ts index 75ddc2bc4abf..1bce8117f546 100644 --- a/packages/ai/test/provider/azure.recorded.test.ts +++ b/packages/ai/test/provider/azure.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/effect.js" +import { ai, Message, ToolCallPart, ToolDefinition } from "../../src/effect.js" import { Azure } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { recordedTests } from "../recorded-test.js" @@ -33,7 +33,7 @@ describe("Azure OpenAI recorded", () => { recorded.effect("chat streams text", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }), + ai.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }), ) expect(response.text.toLowerCase()).toContain("hello") @@ -43,7 +43,7 @@ describe("Azure OpenAI recorded", () => { recorded.effect("responses streams text", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }), + ai.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }), ) expect(response.text.toLowerCase()).toContain("bonjour") @@ -53,7 +53,7 @@ describe("Azure OpenAI recorded", () => { recorded.effect("responses calls a tool", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: responsesModel, prompt: "What is the weather in Paris? Use the lookup_weather tool.", tools: [lookupWeather], @@ -69,7 +69,7 @@ describe("Azure OpenAI recorded", () => { recorded.effect("responses continues after a tool result", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: responsesModel, messages: [ Message.user("What is the weather in Paris?"), diff --git a/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts index 91e3910b3d4f..bbccc246764f 100644 --- a/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM } from "../../src/effect.js" +import { ai, CacheHint } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { AmazonBedrock } from "../../src/providers.js" import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios.js" @@ -17,7 +17,7 @@ const model = AmazonBedrock.configure({ region: RECORDING_REGION, }).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0") -const cacheRequest = LLM.request({ +const cacheRequest = ai.request({ id: "recorded_bedrock_cache", model, system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], diff --git a/packages/ai/test/provider/bedrock-converse-cache.test.ts b/packages/ai/test/provider/bedrock-converse-cache.test.ts index 89b8d336ee87..3ec178fdcec5 100644 --- a/packages/ai/test/provider/bedrock-converse-cache.test.ts +++ b/packages/ai/test/provider/bedrock-converse-cache.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LanguageModel, LLM, Message, ToolCallPart } from "../../src/effect.js" +import { ai, CacheHint, LanguageModel, Message, ToolCallPart } from "../../src/effect.js" import { AmazonBedrock } from "../../src/providers.js" import { compileRequest } from "../../src/route/client.js" import { it } from "../lib/effect.js" @@ -34,7 +34,7 @@ describe("Bedrock Converse cache policy", () => { // Exercise both automatic placement and manual hints at every lowering site. const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttl: "1h" }) : undefined const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: bedrock.model(id), cache: policy, system: [{ type: "text", text: "System prefix", cache }], @@ -88,7 +88,7 @@ describe("Bedrock Converse cache policy", () => { it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlRequest ?? "default"})`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: bedrock.model(id), system: [ { type: "text", text: "Agent" }, @@ -125,7 +125,7 @@ describe("Bedrock Converse cache policy", () => { () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(bedrock.model(arn), { compatibility: { cacheFormat: "anthropic" } }), system: [ { type: "text", text: "Agent" }, @@ -154,7 +154,7 @@ describe("Bedrock Converse cache policy", () => { it.effect(`ignores the Alibaba cache format for ${id}`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(bedrock.model(id), { compatibility: { cacheFormat: "alibaba" } }), system: "System prefix", prompt: "Question", diff --git a/packages/ai/test/provider/bedrock-converse-options.recorded.test.ts b/packages/ai/test/provider/bedrock-converse-options.recorded.test.ts index 20ecd3ff606b..42795ca3bfe2 100644 --- a/packages/ai/test/provider/bedrock-converse-options.recorded.test.ts +++ b/packages/ai/test/provider/bedrock-converse-options.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { AmazonBedrock } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" @@ -29,7 +29,7 @@ describe("Bedrock Converse options recorded", () => { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_options_claude", model: bedrock("us.anthropic.claude-sonnet-4-6", { effort: "high", @@ -52,7 +52,7 @@ describe("Bedrock Converse options recorded", () => { recorded.effect.with("sends gpt-oss reasoning effort with the priority tier", { tags: ["reasoning"] }, () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_options_gpt_oss", model: bedrock("openai.gpt-oss-120b-1:0", { effort: "low", @@ -74,7 +74,7 @@ describe("Bedrock Converse options recorded", () => { recorded.effect.with("sends Nova 2 reasoning effort", { tags: ["reasoning"] }, () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_options_nova", model: bedrock("us.amazon.nova-2-lite-v1:0", { effort: "low" }), prompt: "What is 17*23? Reply with the number only.", @@ -92,7 +92,7 @@ describe("Bedrock Converse options recorded", () => { recorded.effect("constrains Claude output to a JSON schema", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_options_output_format", model: bedrock("us.anthropic.claude-sonnet-4-6", { outputFormat: { diff --git a/packages/ai/test/provider/bedrock-converse-options.test.ts b/packages/ai/test/provider/bedrock-converse-options.test.ts index dfde0b8f6fdd..d7ebf72c7755 100644 --- a/packages/ai/test/provider/bedrock-converse-options.test.ts +++ b/packages/ai/test/provider/bedrock-converse-options.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import { AmazonBedrock } from "../../src/providers.js" @@ -11,7 +11,7 @@ const bedrock = (id: string, settings: Omit) => - LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 64 } }) + ai.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 64 } }) const body = (model: ReturnType) => compileRequest(request(model)).pipe(Effect.map((prepared) => prepared.body)) diff --git a/packages/ai/test/provider/bedrock-converse-thinking-binding.recorded.test.ts b/packages/ai/test/provider/bedrock-converse-thinking-binding.recorded.test.ts index fe690b2ab77b..b941c1240f4a 100644 --- a/packages/ai/test/provider/bedrock-converse-thinking-binding.recorded.test.ts +++ b/packages/ai/test/provider/bedrock-converse-thinking-binding.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMRequest, Message } from "../../src/effect.js" +import { ai, LLMRequest, Message } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { AmazonBedrock } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" @@ -26,7 +26,7 @@ const model = (blockBinding?: { readonly prefix_mismatch_behavior: string }) => const question = "How many positive integers below 5000 have exactly 12 positive divisors? Reply with the number only." -const first = LLM.request({ +const first = ai.request({ id: "recorded_bedrock_thinking_binding_first", model: model(), system: "You are a concise assistant.", @@ -46,7 +46,7 @@ describe("Bedrock Converse thinking binding recorded", () => { recorded.effect.with("accepts the default binding with no thinking configured", { tags: ["reasoning"] }, () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_thinking_binding_default", model: AmazonBedrock.model(modelID, { apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture", diff --git a/packages/ai/test/provider/bedrock-converse-thinking-binding.test.ts b/packages/ai/test/provider/bedrock-converse-thinking-binding.test.ts index d0e39cc5303a..a037495037b8 100644 --- a/packages/ai/test/provider/bedrock-converse-thinking-binding.test.ts +++ b/packages/ai/test/provider/bedrock-converse-thinking-binding.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { GenerationOptions, LanguageModel, LLM } from "../../src/effect.js" +import { ai, GenerationOptions, LanguageModel } from "../../src/effect.js" import { compileRequest } from "../../src/route/client.js" import { AmazonBedrock } from "../../src/providers.js" import { it } from "../lib/effect.js" @@ -13,7 +13,14 @@ const bedrock = (id: string, settings: Parameters[1] const fields = (model: LanguageModel, generation?: GenerationOptions) => compileRequest( - LLM.request({ id: "req_1", model, system: "You are concise.", prompt: "Say hello.", cache: "none", generation }), + ai.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + cache: "none", + generation, + }), ).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields)) describe("Bedrock Converse thinking block binding", () => { diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index a09dd118e2f3..c895ba159bd0 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -5,15 +5,15 @@ import { Effect, Ref, Schema, Stream } from "effect" import { Base64 } from "effect/encoding" import { HttpClientRequest } from "effect/http" import { - Media, + ai, CacheHint, GenerationOptions, type LanguageModel, - LLM, LLMEvent, LLMRequest, + Media, Message, - Tool, + tool, ToolCallPart, ToolChoice, ToolDefinition, @@ -141,7 +141,7 @@ const model = AmazonBedrock.configure({ apiKey: "test-bearer", }).model("anthropic.claude-sonnet-4-5-20250929-v1:0") -const baseRequest = LLM.request({ +const baseRequest = ai.request({ id: "req_1", model, system: "You are concise.", @@ -168,9 +168,9 @@ describe("Bedrock Converse route", () => { it.effect("omits empty initial system blocks", () => Effect.gen(function* () { - const empty = yield* compileRequest(LLM.request({ model, system: "", prompt: "hello" })) + const empty = yield* compileRequest(ai.request({ model, system: "", prompt: "hello" })) const cachedEmpty = yield* compileRequest( - LLM.request({ + ai.request({ model, system: [{ type: "text", text: "", cache: new CacheHint({ type: "ephemeral" }) }], prompt: "hello", @@ -187,7 +187,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: [ { type: "text", text: "", cache }, @@ -277,7 +277,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], cache: "none", @@ -404,7 +404,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers assistant tool-call + tool-result message history", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_history", model, messages: [ @@ -452,7 +452,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const call = ToolCallPart.make({ id: "call_unknown", name: item.name, input: { query: "weather" } }) const error = `No tool named "${item.name}" is currently available. Please use a tool from the available tool list.` - const request = LLM.request({ + const request = ai.request({ model, cache: "none", tools: [ToolDefinition.make({ name: "execute", description: "Run code", inputSchema: { type: "object" } })], @@ -498,7 +498,7 @@ describe("Bedrock Converse route", () => { const original = structuredClone(input) const call = ToolCallPart.make({ id: "tool_1", name: "edit", input }) const prepared = yield* compileRequest( - LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }), + ai.request({ model, messages: [Message.assistant([call])], cache: "none" }), ) expect(prepared.body.messages[0]).toEqual({ @@ -526,7 +526,7 @@ describe("Bedrock Converse route", () => { it.effect("keeps empty tool inputs and empties inputs containing only empty keys", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -551,7 +551,7 @@ describe("Bedrock Converse route", () => { it.effect("merges parallel tool results into one user message", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_parallel_history", model, messages: [ @@ -602,7 +602,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers image content in tool-result messages", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_image", model, messages: [ @@ -651,7 +651,7 @@ describe("Bedrock Converse route", () => { it.effect("drops blank tool-result text and keeps the image", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_blank_text", model, messages: [ @@ -698,7 +698,7 @@ describe("Bedrock Converse route", () => { it.effect("hoists tool-result images beside the result for Bedrock GPT models", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model( "global.openai.gpt-6-sol", ), @@ -751,7 +751,7 @@ describe("Bedrock Converse route", () => { "us.meta.llama4-scout-17b-instruct-v1:0", ]) { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model( id, ), @@ -783,7 +783,7 @@ describe("Bedrock Converse route", () => { it.effect(`keeps ${id} tool images inside the result`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model( id, ), @@ -821,7 +821,7 @@ describe("Bedrock Converse route", () => { it.effect("keeps parallel tool results before hoisted images and gives image-only results text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model( "global.openai.gpt-6-sol", ), @@ -869,7 +869,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers rich Claude tool failures as text notes", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -916,7 +916,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { for (const id of [model.id, "us.amazon.nova-pro-v1:0"]) { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model( id, ), @@ -966,7 +966,7 @@ describe("Bedrock Converse route", () => { it.effect("keeps an error whose text is the empty-output placeholder when media is present", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -999,7 +999,7 @@ describe("Bedrock Converse route", () => { it.effect("hoists an image from a blank error for models that do not keep tool images inline", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model( "global.openai.gpt-6-sol", ), @@ -1311,7 +1311,7 @@ describe("Bedrock Converse route", () => { expect(call.name).toBe("browser.tabs.open") const dispatched = yield* ToolRuntime.dispatch( { - browser_tabs_open: Tool.make({ + browser_tabs_open: tool({ description: "Open a tab", parameters: Schema.Struct({}), success: Schema.String, @@ -1327,7 +1327,7 @@ describe("Bedrock Converse route", () => { }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [response.message, Message.tool({ id: call.id, name: call.name, result: dispatched.result })], @@ -1419,7 +1419,7 @@ describe("Bedrock Converse route", () => { it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Think"), @@ -1449,7 +1449,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Think"), @@ -1475,7 +1475,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -1537,7 +1537,7 @@ describe("Bedrock Converse route", () => { }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -1593,7 +1593,7 @@ describe("Bedrock Converse route", () => { }) const prepared = yield* compileRequest( - LLM.request({ model: compatible, messages: [response.message], cache: "none" }), + ai.request({ model: compatible, messages: [response.message], cache: "none" }), ) expect(prepared.body.messages).toEqual([ { @@ -1636,7 +1636,7 @@ describe("Bedrock Converse route", () => { }, ]) - const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) + const prepared = yield* compileRequest(ai.request({ model, messages: [response.message], cache: "none" })) expect(prepared.body.messages).toEqual([ { role: "assistant", @@ -1728,7 +1728,7 @@ describe("Bedrock Converse route", () => { providerMetadata: { bedrock: { redactedData } }, }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Say hello."), @@ -2107,7 +2107,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_cache", model, system: [{ type: "text", text: "System prefix.", cache }], @@ -2149,7 +2149,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers image media into Bedrock image blocks", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_image", model, messages: [ @@ -2186,7 +2186,7 @@ describe("Bedrock Converse route", () => { it.effect("base64-encodes Uint8Array image bytes", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_image_bytes", model, messages: [ @@ -2210,7 +2210,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects image media that is not valid base64", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.user({ type: "media", media: Media.base64("not base64!", "image/png") })], }), @@ -2224,7 +2224,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects remote image URLs that were not materialized", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user({ @@ -2243,7 +2243,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers document media into Bedrock document blocks with format and name", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_doc", model, cache: "none", @@ -2320,7 +2320,7 @@ describe("Bedrock Converse route", () => { ].forEach((item) => { it.effect(`normalizes ${item.label} in user and tool-result documents`, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, cache: "none", messages: [ @@ -2368,7 +2368,7 @@ describe("Bedrock Converse route", () => { it.effect("keeps colliding document labels distinct within a request", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -2399,7 +2399,7 @@ describe("Bedrock Converse route", () => { it.effect("annotates renamed document-only messages with their original filename", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -2427,7 +2427,7 @@ describe("Bedrock Converse route", () => { it.effect("quotes original filenames in annotations and omits redundant mappings", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -2455,7 +2455,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers document media in tool results", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, cache: "none", messages: [ @@ -2508,7 +2508,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects remote media URLs in tool results", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]), @@ -2539,7 +2539,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects unsupported image media types", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_bad_image", model, messages: [Message.user([{ type: "media", media: Media.base64("x", "image/svg+xml") }])], @@ -2553,7 +2553,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects unsupported document media types", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_bad_doc", model, messages: [ @@ -2570,7 +2570,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral", ttl: "1h" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: [{ type: "text", text: "system", cache }], prompt: "hi", @@ -2587,7 +2587,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], messages: [ @@ -2619,7 +2619,7 @@ describe("Bedrock Converse route", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: [ { type: "text", text: "a", cache }, @@ -2675,7 +2675,7 @@ describe("Bedrock Converse recorded", () => { Effect.gen(function* () { const llm = yield* LLMClient.Service const response = yield* llm.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_text", model: recordedModel(), system: "Reply with the single word 'Hello'.", @@ -2696,7 +2696,7 @@ describe("Bedrock Converse recorded", () => { Effect.gen(function* () { const llm = yield* LLMClient.Service const response = yield* llm.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_tool_call", model: recordedModel(), system: "Call tools exactly as requested.", @@ -2731,7 +2731,7 @@ describe("Bedrock Converse recorded", () => { recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "recorded_bedrock_parallel_tool_results", model: recordedModel(), system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.", diff --git a/packages/ai/test/provider/bedrock-mantle.test.ts b/packages/ai/test/provider/bedrock-mantle.test.ts index 578b0e69d59c..5329aa5fafaf 100644 --- a/packages/ai/test/provider/bedrock-mantle.test.ts +++ b/packages/ai/test/provider/bedrock-mantle.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Message, ToolDefinition } from "../../src/effect.js" import { AmazonBedrockMantle } from "../../src/providers.js" import { OpenResponses } from "../../src/protocols/open-responses.js" import { compileRequest, LLMClient } from "../../src/route/client.js" @@ -22,12 +22,12 @@ describe("Amazon Bedrock Mantle provider", () => { Effect.gen(function* () { const provider = AmazonBedrockMantle.configure({ credentials }) expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport) - const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" })) + const chat = yield* compileRequest(ai.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" })) const messages = yield* compileRequest( - LLM.request({ model: provider.messages("anthropic.claude-opus-4-8"), prompt: "Hi", cache: "none" }), + ai.request({ model: provider.messages("anthropic.claude-opus-4-8"), prompt: "Hi", cache: "none" }), ) const responses = yield* compileRequest( - LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }), + ai.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }), ) expect(chat).toMatchObject({ @@ -55,13 +55,19 @@ describe("Amazon Bedrock Mantle provider", () => { Effect.gen(function* () { const settings = { apiKey: "test-key", topP: 0.8 } const chat = yield* compileRequest( - LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }), + ai.request({ + model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), + prompt: "Hi", + }), ) const messages = yield* compileRequest( - LLM.request({ model: AmazonBedrockMantle.messagesModel("anthropic.claude-opus-4-8", settings), prompt: "Hi" }), + ai.request({ + model: AmazonBedrockMantle.messagesModel("anthropic.claude-opus-4-8", settings), + prompt: "Hi", + }), ) const responses = yield* compileRequest( - LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }), + ai.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }), ) expect(chat.body.top_p).toBe(0.8) @@ -78,7 +84,7 @@ describe("Amazon Bedrock Mantle provider", () => { configured.responses("openai.gpt-oss-120b"), configured.messages("anthropic.claude-opus-4-8"), ]) { - yield* LLMClient.generate(LLM.request({ model: selected, prompt: "Hi" })).pipe( + yield* LLMClient.generate(ai.request({ model: selected, prompt: "Hi" })).pipe( Effect.provide( dynamicResponse((input) => Effect.gen(function* () { @@ -104,7 +110,7 @@ describe("Amazon Bedrock Mantle provider", () => { Effect.gen(function* () { const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).messages("anthropic.claude-opus-4-8") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: "You are concise.", messages: [Message.user("Hello")], @@ -125,7 +131,7 @@ describe("Amazon Bedrock Mantle provider", () => { Effect.gen(function* () { const seen: Array = [] const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b") - yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + yield* LLMClient.generate(ai.request({ model, prompt: "Hi" })).pipe( Effect.provide( dynamicResponse((input) => Effect.gen(function* () { @@ -158,7 +164,7 @@ describe("Amazon Bedrock Mantle provider", () => { apiKey: "test-key", baseURL: "https://mantle.test/v1", }).chat("openai.gpt-oss-safeguard-20b") - yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + yield* LLMClient.generate(ai.request({ model, prompt: "Hi" })).pipe( Effect.provide( dynamicResponse((input) => Effect.gen(function* () { @@ -180,7 +186,7 @@ describe("Amazon Bedrock Mantle provider", () => { Effect.gen(function* () { const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b") const item = { type: "reasoning", id: "msg_95d4d0af4350432a", encrypted_content: "mantle-state" } - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Think." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -194,7 +200,7 @@ describe("Amazon Bedrock Mantle provider", () => { ) const prepared = yield* compileRequest( - LLM.request({ model, messages: [response.message, Message.user("Continue.")] }), + ai.request({ model, messages: [response.message, Message.user("Continue.")] }), ) expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ @@ -225,7 +231,7 @@ describe("Amazon Bedrock Mantle recorded", () => { recorded.effect("streams text", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: AmazonBedrockMantle.configure({ apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture", region: "us-east-1", @@ -272,7 +278,7 @@ describe("Amazon Bedrock Mantle Messages recorded", () => { () => Effect.gen(function* () { const model = mantleMessages("anthropic.claude-opus-4-8") - const initial = LLM.request({ + const initial = ai.request({ model, system: "You are a concise assistant.", prompt: @@ -330,7 +336,7 @@ describe("Amazon Bedrock Mantle Messages recorded", () => { { tags: ["reasoning", "system-update"], metadata: { model: "anthropic.claude-haiku-4-5" } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: mantleMessages("anthropic.claude-haiku-4-5"), messages: [Message.user("What is 19 multiplied by 23?"), Message.system("Reply with only the integer.")], providerOptions: { @@ -356,7 +362,7 @@ describe("Amazon Bedrock Mantle Messages recorded", () => { () => Effect.gen(function* () { const model = mantleMessages("anthropic.claude-opus-5-5") - const firstRequest = LLM.request({ + const firstRequest = ai.request({ model, prompt: "Compute 37 * 43 step by step, then reply with only the integer.", providerOptions: { @@ -377,7 +383,7 @@ describe("Amazon Bedrock Mantle Messages recorded", () => { expect(first.reasoning.length).toBeGreaterThan(0) expect(first.text.replaceAll(",", "")).toContain("1591") - const secondRequest = LLM.request({ + const secondRequest = ai.request({ model, messages: [ ...firstRequest.messages, @@ -409,7 +415,7 @@ describe("Amazon Bedrock Mantle Messages recorded", () => { { tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5" } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: mantleMessages("anthropic.claude-opus-5"), messages: [ Message.user("What is 12 + 30?"), diff --git a/packages/ai/test/provider/bedrock-mistral-ids.test.ts b/packages/ai/test/provider/bedrock-mistral-ids.test.ts index 0589c92cd316..6e3c2ae43a24 100644 --- a/packages/ai/test/provider/bedrock-mistral-ids.test.ts +++ b/packages/ai/test/provider/bedrock-mistral-ids.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, Message, ToolCallPart } from "../../src/effect.js" +import { ai, Message, ToolCallPart } from "../../src/effect.js" import { AmazonBedrock } from "../../src/providers.js" import { BedrockConverse } from "../../src/protocols/bedrock-converse.js" import { compileRequest } from "../../src/route/client.js" @@ -20,7 +20,7 @@ for (const model of [ ]) { it.effect(`preserves distinct call/result pairs and history for ${model}`, () => Effect.gen(function* () { - const request = LLM.request({ model: bedrock.model(model), messages: history(["a"]), cache: "none" }) + const request = ai.request({ model: bedrock.model(model), messages: history(["a"]), cache: "none" }) const first = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)( (yield* compileRequest(request)).body, ) @@ -40,7 +40,7 @@ for (const model of [ ] const messages = history(ids) const before = structuredClone(messages) - const input = LLM.request({ model: bedrock.model(model), messages, cache: "none" }) + const input = ai.request({ model: bedrock.model(model), messages, cache: "none" }) const prepared = yield* compileRequest(input) const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body) const calls = body.messages.flatMap((message) => @@ -65,7 +65,7 @@ it.effect("leaves non-Mistral Bedrock IDs unchanged", () => Effect.gen(function* () { const ids = ["a", "tooluse_abcdefghi111111111", "tooluse_abcdefghi222222222", "Ab12Cd34E"] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: bedrock.model("global.anthropic.claude-haiku-4-5-20251001-v1:0"), messages: history(ids), cache: "none", diff --git a/packages/ai/test/provider/cartesia-speech.recorded.test.ts b/packages/ai/test/provider/cartesia-speech.recorded.test.ts index bf112d0f35c2..faa0746be72f 100644 --- a/packages/ai/test/provider/cartesia-speech.recorded.test.ts +++ b/packages/ai/test/provider/cartesia-speech.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Speech, SpeechEvent } from "../../src/effect.js" +import { Speech, SpeechEvent } from "../../src/experimental.js" import { Cartesia } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TEXT, collectSpeech } from "./speech-recording.js" diff --git a/packages/ai/test/provider/checkpoint.test.ts b/packages/ai/test/provider/checkpoint.test.ts index 7cf8a2fd62da..bb9f124a966a 100644 --- a/packages/ai/test/provider/checkpoint.test.ts +++ b/packages/ai/test/provider/checkpoint.test.ts @@ -1,13 +1,16 @@ import { expect } from "bun:test" import { Effect, Stream } from "effect" -import { LLM, LLMClient, LLMRequest } from "../../src/effect.js" +import { ai, LLMClient, LLMRequest } from "../../src/effect.js" import { Azure, OpenAI } from "../../src/providers.js" import { testEffect } from "../lib/effect.js" import { dynamicResponse, fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" } -const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" }) +const request = ai.request({ + model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), + prompt: "hello", +}) const trigger = { mechanism: "trigger" } as const testEffect( @@ -61,7 +64,7 @@ testEffect( ).effect("trigger keeps request controls, configured deployment, and supplied subscription headers", () => Effect.gen(function* () { const calls: string[] = [] - const input = LLM.request({ + const input = ai.request({ model: request.model, system: "Keep the context", prompt: "hello", @@ -91,7 +94,7 @@ testEffect( }, }) const original = LLMRequest.input(input) - const result = yield* LLMClient.compact(input, { + const result = yield* ai.compact(input, { ...trigger, http: (request, next) => { calls.push("http") @@ -126,8 +129,8 @@ testEffect( }), ), ).effect("keeps explicit verbosity on a trigger checkpoint for prompt cache reuse", () => - LLMClient.compact( - LLM.request({ + ai.compact( + ai.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"), prompt: "Hello.", providerOptions: { textVerbosity: "low" }, @@ -149,8 +152,8 @@ testEffect( }), ), ).effect("keeps the effective body-overlay verbosity and text formatting", () => - LLMClient.compact( - LLM.request({ + ai.compact( + ai.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"), prompt: "Hello.", generation: { maxTokens: 20_000 }, @@ -173,14 +176,16 @@ testEffect( ), ).effect("passes configured output limits through and leaves rejection to the provider", () => Effect.gen(function* () { - const error = yield* LLMClient.compact( - LLM.request({ - model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"), - prompt: "Hello.", - generation: { maxTokens: 128 }, - }), - trigger, - ).pipe(Effect.flip) + const error = yield* ai + .compact( + ai.request({ + model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"), + prompt: "Hello.", + generation: { maxTokens: 128 }, + }), + trigger, + ) + .pipe(Effect.flip) expect(error.message).toContain("at least 20000") }), ) @@ -209,7 +214,7 @@ testEffect( ), ).effect("correlates an ID-less checkpoint across repeated events and ignores other output", () => Effect.gen(function* () { - const result = yield* LLMClient.compact(request, trigger) + const result = yield* ai.compact(request, trigger) expect(result.checkpoint.encrypted).toBe("opaque") expect(result.checkpoint.id).toBeString() expect("message" in result).toBe(false) @@ -240,7 +245,7 @@ for (const [name, events] of Object.entries({ fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }), ).effect(`rejects ${name} checkpoint response and preserves original error context`, () => Effect.gen(function* () { - const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip) + const error = yield* ai.compact(request, trigger).pipe(Effect.flip) expect(error.reason.body).toBe(JSON.stringify(wire.at(-1))) expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } }) }), @@ -251,7 +256,7 @@ testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: ch "rejects clean EOF without response.completed", () => Effect.gen(function* () { - const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip) + const error = yield* ai.compact(request, trigger).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") }), ) @@ -260,9 +265,7 @@ for (const body of [{ input: [] }, { previous_response_id: "stale" }, { stream: `rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`, () => Effect.gen(function* () { - const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe( - Effect.flip, - ) + const error = yield* ai.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") }), ) @@ -273,14 +276,14 @@ testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effe () => Effect.gen(function* () { const client = yield* LLMClient.Service - const unsupported = LLM.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") }) - expect(LLMClient.canCompact(unsupported)).toBe(true) - expect(LLMClient.canCompact(unsupported, trigger)).toBe(false) + const unsupported = ai.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") }) + expect(ai.canCompact(unsupported)).toBe(true) + expect(ai.canCompact(unsupported, trigger)).toBe(false) // @ts-expect-error Exercise untyped consumers; the service must reject before sending. const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip) expect(serviceError.reason._tag).toBe("UnsupportedOperation") // @ts-expect-error Exercise an unknown mechanism supplied by JavaScript. - const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip) + const error = yield* ai.compact(request, { mechanism: "other" }).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") }), ) @@ -290,7 +293,7 @@ testEffect(fixedResponse("must not use HTTP")).effect( () => Effect.gen(function* () { let completed = 0 - const operation = LLMClient.compact(request, { + const operation = ai.compact(request, { mechanism: "trigger", webSocket: { execute: () => diff --git a/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts b/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts index 85e940b4cfcd..cce34bd52058 100644 --- a/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts +++ b/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { CloudflareWorkersAI } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -25,7 +25,7 @@ const recorded = recordedTests({ describe("Cloudflare Workers AI Images recorded", () => { recorded.effect("generates a raw binary image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: workers.image("@cf/bytedance/stable-diffusion-xl-lightning"), prompt: "A red apple on a wooden table", aspect: "1:1", @@ -43,7 +43,7 @@ describe("Cloudflare Workers AI Images recorded", () => { "generates a FLUX.2 image from multipart fields", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: workers.image("@cf/black-forest-labs/flux-2-klein-4b"), prompt: "A red apple on a wooden table", providerOptions: { width: 64, height: 64, seed: 3, steps: 1 }, diff --git a/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts b/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts index a7289a29aed8..b072c2f0e11c 100644 --- a/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts +++ b/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { HttpClientRequest } from "effect/http" -import { Image, ImageClient, Media } from "../../src/effect.js" +import { ai, ImageClient, Media } from "../../src/effect.js" import { CloudflareWorkersAI } from "../../src/providers.js" import { ProviderConfigurationError } from "../../src/schema/index.js" import { it } from "../lib/effect.js" @@ -23,7 +23,7 @@ describe("Cloudflare Workers AI Images", () => { it.effect("posts JSON inputs to the Run API and decodes the base64 envelope", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: workers.image("@cf/leonardo/lucid-origin"), prompt: "A red apple", providerOptions: { width: 512, height: 256, seed: 7, steps: 4, guidance: 3 }, @@ -69,7 +69,7 @@ describe("Cloudflare Workers AI Images", () => { ) it.effect("rejects an envelope without an image", () => - Image.generate({ model: workers.image("@cf/black-forest-labs/flux-1-schnell"), prompt: "test" }).pipe( + ai.image.generate({ model: workers.image("@cf/black-forest-labs/flux-1-schnell"), prompt: "test" }).pipe( Effect.provide( ImageClient.layer.pipe( Layer.provide( @@ -85,54 +85,58 @@ describe("Cloudflare Workers AI Images", () => { ) it.effect("sends FLUX.2 input images as numbered multipart files", () => - Image.generate({ - model: workers.image("@cf/black-forest-labs/flux-2-klein-4b"), - prompt: "Put the cat from image 1 in image 0", - images: [Media.bytes(new Uint8Array([1, 2]), "image/png"), Media.base64("AwQ=", "image/jpeg")], - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - const form = yield* Effect.promise(() => request.formData()) - const bytes = yield* Effect.forEach(["input_image_0", "input_image_1"], (name) => - Effect.promise(async () => [...new Uint8Array(await (form.get(name) as Blob).arrayBuffer())]), - ) - expect(form.get("prompt")).toBe("Put the cat from image 1 in image 0") - expect(bytes).toEqual([ - [1, 2], - [3, 4], - ]) - return input.respond(JSON.stringify({ result: { image: "iVBORw0KGgo=" } }), { - headers: { "content-type": "application/json" }, - }) - }), + ai.image + .generate({ + model: workers.image("@cf/black-forest-labs/flux-2-klein-4b"), + prompt: "Put the cat from image 1 in image 0", + images: [Media.bytes(new Uint8Array([1, 2]), "image/png"), Media.base64("AwQ=", "image/jpeg")], + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const form = yield* Effect.promise(() => request.formData()) + const bytes = yield* Effect.forEach(["input_image_0", "input_image_1"], (name) => + Effect.promise(async () => [...new Uint8Array(await (form.get(name) as Blob).arrayBuffer())]), + ) + expect(form.get("prompt")).toBe("Put the cat from image 1 in image 0") + expect(bytes).toEqual([ + [1, 2], + [3, 4], + ]) + return input.respond(JSON.stringify({ result: { image: "iVBORw0KGgo=" } }), { + headers: { "content-type": "application/json" }, + }) + }), + ), ), ), ), ), - ), ) it.effect("sends the first input image of a JSON model as image_b64", () => - Image.generate({ - model: workers.image("@cf/lykon/dreamshaper-8-lcm"), - prompt: "Make it green", - images: [Media.bytes(new Uint8Array([1, 2, 3]), "image/png")], - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toEqual({ prompt: "Make it green", image_b64: "AQID" }) - return Effect.succeed(input.respond(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))) - }), + ai.image + .generate({ + model: workers.image("@cf/lykon/dreamshaper-8-lcm"), + prompt: "Make it green", + images: [Media.bytes(new Uint8Array([1, 2, 3]), "image/png")], + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toEqual({ prompt: "Make it green", image_b64: "AQID" }) + return Effect.succeed(input.respond(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))) + }), + ), ), ), ), - ), ) it.effect("fails typed for fields the Run API image models cannot take", () => @@ -143,7 +147,7 @@ describe("Cloudflare Workers AI Images", () => { { model: workers.image("@cf/black-forest-labs/flux-2-dev"), prompt: "test", n: 2 }, { model: workers.image("@cf/lykon/dreamshaper-8-lcm"), prompt: "test", images: [image, image] }, ], - (request) => Image.generate(request).pipe(Effect.flip), + (request) => ai.image.generate(request).pipe(Effect.flip), ) expect(errors.map((error) => error.reason._tag)).toEqual(["UnsupportedOperation", "UnsupportedOperation"]) }).pipe(Effect.provide(ImageClient.layer.pipe(Layer.provide(fixedResponse("unused"))))), diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 6a1e5066812f..cf58e06ca8a3 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Schema } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, LLMEvent } from "../../src/effect.js" +import { ai, LLMEvent } from "../../src/effect.js" import { CloudflareAIGateway } from "../../src/providers/cloudflare-ai-gateway.js" import { CloudflareWorkersAI } from "../../src/providers/cloudflare-workers-ai.js" import { compileRequest } from "../../src/route/client.js" @@ -28,14 +28,14 @@ describe("Cloudflare", () => { apiKey: "test-token", }) const responses = yield* compileRequest( - LLM.request({ model: gateway.model("openai/gpt-5.4"), prompt: "Say hello." }), + ai.request({ model: gateway.model("openai/gpt-5.4"), prompt: "Say hello." }), ) const messages = yield* compileRequest( - LLM.request({ model: gateway.model("anthropic/claude-haiku-4.5"), prompt: "Say hello." }), + ai.request({ model: gateway.model("anthropic/claude-haiku-4.5"), prompt: "Say hello." }), ) - const chat = yield* compileRequest(LLM.request({ model: gateway.model("xai/grok-4.6"), prompt: "Say hello." })) + const chat = yield* compileRequest(ai.request({ model: gateway.model("xai/grok-4.6"), prompt: "Say hello." })) const workers = yield* compileRequest( - LLM.request({ model: gateway.model("workers-ai/@cf/meta/llama-3.3-70b-instruct"), prompt: "Say hello." }), + ai.request({ model: gateway.model("workers-ai/@cf/meta/llama-3.3-70b-instruct"), prompt: "Say hello." }), ) expect(responses.route).toBe("cloudflare-ai-gateway-responses") @@ -62,46 +62,50 @@ describe("Cloudflare", () => { it.effect("posts to the Cloudflare REST API with gateway options", () => Effect.gen(function* () { - const response = yield* LLM.generate( - LLM.request({ - model: CloudflareAIGateway.configure({ - accountId: "test-account", - gatewayId: "test-gateway", - apiKey: "test-token", - cacheKey: "cache-key", - cacheTtl: 300, - collectLog: false, - metadata: { invoked_by: "test" }, - skipCache: true, - }).model("xai/grok-4.6"), - prompt: "Say hello.", - }), - ).pipe( - Effect.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") - expect(web.headers.get("authorization")).toBe("Bearer test-token") - expect(web.headers.get("cf-aig-gateway-id")).toBe("test-gateway") - expect(web.headers.get("cf-aig-cache-key")).toBe("cache-key") - expect(web.headers.get("cf-aig-cache-ttl")).toBe("300") - expect(web.headers.get("cf-aig-collect-log")).toBe("false") - expect(web.headers.get("cf-aig-metadata")).toBe('{"invoked_by":"test"}') - expect(web.headers.get("cf-aig-skip-cache")).toBe("true") - expect(decodeJson(input.text)).toMatchObject({ - model: "xai/grok-4.6", - stream: true, - messages: [{ role: "user", content: "Say hello." }], - }) - return input.respond( - sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), - { headers: { "content-type": "text/event-stream" } }, - ) - }), + const response = yield* ai + .generate( + ai.request({ + model: CloudflareAIGateway.configure({ + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + cacheKey: "cache-key", + cacheTtl: 300, + collectLog: false, + metadata: { invoked_by: "test" }, + skipCache: true, + }).model("xai/grok-4.6"), + prompt: "Say hello.", + }), + ) + .pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe( + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions", + ) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(web.headers.get("cf-aig-gateway-id")).toBe("test-gateway") + expect(web.headers.get("cf-aig-cache-key")).toBe("cache-key") + expect(web.headers.get("cf-aig-cache-ttl")).toBe("300") + expect(web.headers.get("cf-aig-collect-log")).toBe("false") + expect(web.headers.get("cf-aig-metadata")).toBe('{"invoked_by":"test"}') + expect(web.headers.get("cf-aig-skip-cache")).toBe("true") + expect(decodeJson(input.text)).toMatchObject({ + model: "xai/grok-4.6", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), ), - ), - ) + ) expect(response.text).toBe("Hello") }), @@ -117,14 +121,12 @@ describe("Cloudflare", () => { return input.respond("", { status: 500 }) }), ) - yield* LLM.generate(LLM.request({ model: gateway.model("anthropic/claude-sonnet-4.6"), prompt: "Hi" })).pipe( - Effect.provide(capture), - Effect.ignore, - ) - yield* LLM.generate(LLM.request({ model: gateway.model("openai/gpt-5-nano"), prompt: "Hi" })).pipe( - Effect.provide(capture), - Effect.ignore, - ) + yield* ai + .generate(ai.request({ model: gateway.model("anthropic/claude-sonnet-4.6"), prompt: "Hi" })) + .pipe(Effect.provide(capture), Effect.ignore) + yield* ai + .generate(ai.request({ model: gateway.model("openai/gpt-5-nano"), prompt: "Hi" })) + .pipe(Effect.provide(capture), Effect.ignore) expect(requests.map((request) => request.url)).toEqual([ "https://gateway.ai.cloudflare.com/v1/test-account/default/anthropic/v1/messages", @@ -143,7 +145,7 @@ describe("Cloudflare", () => { it.effect("sends the default gateway ID to the REST API", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: CloudflareAIGateway.configure({ accountId: "test-account", apiKey: "test-token" }).model( "workers-ai/@cf/meta/llama-3.3-70b-instruct", ), @@ -176,24 +178,26 @@ describe("Cloudflare", () => { index: 0, }, ] - const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe( - Effect.provide( - dynamicResponse((input) => - Effect.succeed( - input.respond( - sseEvents( - deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }), - deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }), - deltaChunk({ reasoning_details: [details[2]] }), - deltaChunk({ content: "Hello" }), - deltaChunk({}, "stop"), + const response = yield* ai + .generate(ai.request({ model, prompt: "Say hello." })) + .pipe( + Effect.provide( + dynamicResponse((input) => + Effect.succeed( + input.respond( + sseEvents( + deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }), + deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }), + deltaChunk({ reasoning_details: [details[2]] }), + deltaChunk({ content: "Hello" }), + deltaChunk({}, "stop"), + ), + { headers: { "content-type": "text/event-stream" } }, ), - { headers: { "content-type": "text/event-stream" } }, ), ), ), - ), - ) + ) expect(response.reasoning).toBe("Thinking") expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2) @@ -201,7 +205,7 @@ describe("Cloudflare", () => { "cloudflare-ai-gateway": { reasoningField: "reasoning", reasoningDetails: merged }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged }, ]) @@ -211,7 +215,7 @@ describe("Cloudflare", () => { it.effect("allows a fully configured baseURL override", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: CloudflareAIGateway.configure({ baseURL: "https://gateway.proxy.test/v1", apiKey: "test-token", @@ -239,7 +243,7 @@ describe("Cloudflare", () => { }) expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1") - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("cloudflare-workers-ai") expect(prepared.body).toMatchObject({ @@ -252,60 +256,30 @@ describe("Cloudflare", () => { it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () => Effect.gen(function* () { - const response = yield* LLM.generate( - LLM.request({ - model: CloudflareWorkersAI.configure({ - accountId: "test-account", - apiKey: "test-token", - }).model("@cf/meta/llama-3.1-8b-instruct"), - prompt: "Say hello.", - }), - ).pipe( - Effect.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") - expect(web.headers.get("authorization")).toBe("Bearer test-token") - expect(decodeJson(input.text)).toMatchObject({ - model: "@cf/meta/llama-3.1-8b-instruct", - stream: true, - messages: [{ role: "user", content: "Say hello." }], - }) - return input.respond( - sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), - { headers: { "content-type": "text/event-stream" } }, - ) - }), - ), - ), - ) - - expect(response.text).toBe("Hello") - }), - ) - - it.effect("supports direct Workers AI token aliases through auth config", () => - Effect.gen(function* () { - yield* Effect.forEach(["CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"], (name) => - LLM.generate( - LLM.request({ + const response = yield* ai + .generate( + ai.request({ model: CloudflareWorkersAI.configure({ accountId: "test-account", + apiKey: "test-token", }).model("@cf/meta/llama-3.1-8b-instruct"), prompt: "Say hello.", }), - ).pipe( - withEnv({ - CLOUDFLARE_API_KEY: undefined, - CLOUDFLARE_WORKERS_AI_TOKEN: name === "CLOUDFLARE_WORKERS_AI_TOKEN" ? "test-token" : undefined, - CLOUDFLARE_API_TOKEN: name === "CLOUDFLARE_API_TOKEN" ? "test-token" : undefined, - }), + ) + .pipe( Effect.provide( dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe( + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions", + ) expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) return input.respond( sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }, @@ -313,7 +287,43 @@ describe("Cloudflare", () => { }), ), ), - ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("supports direct Workers AI token aliases through auth config", () => + Effect.gen(function* () { + yield* Effect.forEach(["CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"], (name) => + ai + .generate( + ai.request({ + model: CloudflareWorkersAI.configure({ + accountId: "test-account", + }).model("@cf/meta/llama-3.1-8b-instruct"), + prompt: "Say hello.", + }), + ) + .pipe( + withEnv({ + CLOUDFLARE_API_KEY: undefined, + CLOUDFLARE_WORKERS_AI_TOKEN: name === "CLOUDFLARE_WORKERS_AI_TOKEN" ? "test-token" : undefined, + CLOUDFLARE_API_TOKEN: name === "CLOUDFLARE_API_TOKEN" ? "test-token" : undefined, + }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ), ) }), ) diff --git a/packages/ai/test/provider/cohere.recorded.test.ts b/packages/ai/test/provider/cohere.recorded.test.ts index 7769cf65944e..689c30fc52d8 100644 --- a/packages/ai/test/provider/cohere.recorded.test.ts +++ b/packages/ai/test/provider/cohere.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, SystemPart } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, SystemPart } from "../../src/effect.js" import { Cohere } from "../../src/providers/cohere.js" import { LLMClient } from "../../src/route.js" import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js" @@ -14,7 +14,7 @@ recorded.effect( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: cohere.model("command-a-03-2025"), prompt: "Reply exactly: OK", generation: { maxTokens: 64 }, @@ -34,7 +34,7 @@ recorded.effect( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: cohere.model("command-a-reasoning-08-2025"), prompt: "What is 17 times 23? Answer briefly.", providerOptions: { thinking: { type: "enabled", tokenBudget: 128 } }, @@ -81,7 +81,7 @@ recorded.effect( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: cohere.chat("command-a-reasoning-08-2025"), prompt: "What is 17 times 23? Answer briefly.", providerOptions: { reasoningEffort: "high" }, diff --git a/packages/ai/test/provider/cohere.test.ts b/packages/ai/test/provider/cohere.test.ts index 608c5d247de6..af057ba64a28 100644 --- a/packages/ai/test/provider/cohere.test.ts +++ b/packages/ai/test/provider/cohere.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { Effect } from "effect" -import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, isRetryable } from "../../src/effect.js" +import { ai, isRetryable, LLMClient, LLMEvent, Media, Message, SystemPart } from "../../src/effect.js" import { Cohere } from "../../src/providers/cohere.js" import { compileRequest } from "../../src/route/client.js" import { it } from "../lib/effect.js" @@ -21,7 +21,7 @@ test("Cohere exposes native and compatible endpoints without Core remapping", () it.effect("Cohere lowers native history, thinking, tools, and sampling", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: cohere.model("command-a-reasoning-08-2025"), system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")], messages: [ @@ -65,7 +65,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () => it.effect("Cohere compatibility omits unsupported OpenAI fields", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: cohere.chat("command-a-reasoning-08-2025"), prompt: "Hello", providerOptions: { reasoningEffort: "high" }, @@ -82,7 +82,7 @@ it.effect("Cohere compatibility omits unsupported OpenAI fields", () => it.effect("Cohere maps inclusive usage while retaining distinct billed units", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), + ai.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), ).pipe( Effect.provide( fixedResponse( @@ -127,7 +127,7 @@ it.effect("Cohere maps inclusive usage while retaining distinct billed units", ( it.effect("Cohere rejects incomplete streams", () => Effect.gen(function* () { const error = yield* LLMClient.generate( - LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), + ai.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), ).pipe(Effect.provide(fixedResponse(sseEvents({ type: "message-start" }))), Effect.flip) expect(error.message).toContain("without message-end") }), @@ -136,7 +136,7 @@ it.effect("Cohere rejects incomplete streams", () => it.effect("Cohere ignores unknown stream events but rejects malformed known ones", () => Effect.gen(function* () { const generate = (...events: ReadonlyArray) => - LLMClient.generate(LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" })).pipe( + LLMClient.generate(ai.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" })).pipe( Effect.provide(fixedResponse(sseEvents(...events))), ) const response = yield* generate( @@ -160,7 +160,7 @@ it.effect("Cohere ignores unknown stream events but rejects malformed known ones it.effect("Cohere preserves native tool plans in continued history", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), + ai.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), ).pipe( Effect.provide( fixedResponse( @@ -185,7 +185,7 @@ it.effect("Cohere preserves native tool plans in continued history", () => ) expect(response.toolCalls[0]?.input).toEqual({ city: "Paris" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: cohere.model("command-a-03-2025"), messages: [response.message, Message.tool({ id: "lookup-1", name: "lookup", result: { sunny: true } })], }), @@ -200,7 +200,7 @@ it.effect("Cohere preserves native tool plans in continued history", () => it.effect("Cohere rejects unsupported media instead of silently dropping it", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model: cohere.model("command-a-03-2025"), messages: [Message.user([{ type: "media", media: Media.base64("Zm9v", "audio/wav") }])], }), @@ -212,7 +212,7 @@ it.effect("Cohere rejects unsupported media instead of silently dropping it", () it.effect("Cohere thinking budgets must be positive integers", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model: cohere.model("command-a-reasoning-08-2025"), providerOptions: { thinking: { tokenBudget: 0 } }, }), @@ -224,7 +224,7 @@ it.effect("Cohere thinking budgets must be positive integers", () => it.effect("Cohere fits thinking budgets under the output limit", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: cohere.model("command-a-reasoning-08-2025"), prompt: "Hi", providerOptions: { thinking: { tokenBudget: 31_999 } }, @@ -275,7 +275,7 @@ it.effect("Cohere HTTP errors map to AI error reasons", () => Effect.forEach(errors, (item) => Effect.gen(function* () { const error = yield* LLMClient.generate( - LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), + ai.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }), ).pipe( Effect.provide( fixedResponse(JSON.stringify({ id: "fixture", error_type: item.error_type, message: item.message }), { diff --git a/packages/ai/test/provider/compaction-websocket.test.ts b/packages/ai/test/provider/compaction-websocket.test.ts index 639d1d7530cb..1c22ea12c9aa 100644 --- a/packages/ai/test/provider/compaction-websocket.test.ts +++ b/packages/ai/test/provider/compaction-websocket.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Stream } from "effect" -import { LLM, LLMRequest, Message } from "../../src/effect.js" +import { ai, LLMRequest, Message } from "../../src/effect.js" import { LLMClient, WebSocketTransport } from "../../src/route.js" import { OpenAI } from "../../src/providers.js" import { testEffect } from "../lib/effect.js" @@ -33,7 +33,7 @@ testEffect(fixedResponse("unexpected HTTP fallback")).effect( close: Effect.void, }), }) - const request = LLM.request({ + const request = ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello", providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] }, diff --git a/packages/ai/test/provider/compaction.recorded.test.ts b/packages/ai/test/provider/compaction.recorded.test.ts index 578a088c71f0..a3a406ed16dc 100644 --- a/packages/ai/test/provider/compaction.recorded.test.ts +++ b/packages/ai/test/provider/compaction.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMRequest, Message } from "../../src/effect.js" +import { ai, LLMRequest, Message } from "../../src/effect.js" import { LLMClient } from "../../src/route/client.js" import { OpenAI, XAI, Anthropic } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" @@ -28,8 +28,8 @@ for (const provider of [ "compacts and continues with the provider checkpoint", () => Effect.gen(function* () { - const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } }) - const compacted = yield* LLMClient.compact(request) + const request = ai.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } }) + const compacted = yield* ai.compact(request) const result = yield* LLMClient.generate( LLMRequest.update(request, { messages: [ @@ -56,7 +56,7 @@ recordedTests({ const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model( "claude-sonnet-4-6", ) - const request = LLM.request({ + const request = ai.request({ model, messages: [ Message.user( diff --git a/packages/ai/test/provider/compaction.test.ts b/packages/ai/test/provider/compaction.test.ts index 18ef297813a5..ba0418260717 100644 --- a/packages/ai/test/provider/compaction.test.ts +++ b/packages/ai/test/provider/compaction.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, LLMRequest, Message } from "../../src/effect.js" +import { ai, LLMRequest, Message } from "../../src/effect.js" import { LLMClient } from "../../src/route/client.js" import { OpenAI, Azure, XAI } from "../../src/providers/index.js" import { testEffect } from "../lib/effect.js" @@ -32,7 +32,7 @@ for (const model of [ ), ).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, prompt: "hello", providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] }, @@ -63,7 +63,7 @@ testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { out () => Effect.gen(function* () { const result = yield* LLMClient.generate( - LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), ) expect(result.message.content).toHaveLength(1) expect(result.message.content[0]?.type).toBe("compaction") @@ -75,7 +75,7 @@ testEffect( ).effect("rejects incomplete compaction payloads without publishing a checkpoint", () => Effect.gen(function* () { const error = yield* LLMClient.generate( - LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), ).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.reason.body).toContain("cmp_bad") @@ -103,7 +103,10 @@ for (const completed of [false, true]) { ), ).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () => Effect.gen(function* () { - const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }) + const request = ai.request({ + model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), + prompt: "hello", + }) if (completed) { const response = yield* LLMClient.generate(request) expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"]) @@ -128,7 +131,7 @@ testEffect( ).effect("mints an id for terminal checkpoints that omit one", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }), + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }), ) const part = response.message.content[0] expect(part?.type).toBe("compaction") diff --git a/packages/ai/test/provider/conversation-lowering.test.ts b/packages/ai/test/provider/conversation-lowering.test.ts index eed4b6112157..f4762b9e3c2a 100644 --- a/packages/ai/test/provider/conversation-lowering.test.ts +++ b/packages/ai/test/provider/conversation-lowering.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, Message } from "../../src/effect.js" +import { ai, Message } from "../../src/effect.js" import { OpenAI } from "../../src/providers.js" import { OpenResponses } from "../../src/protocols/open-responses.js" import { it } from "../lib/effect.js" @@ -8,7 +8,7 @@ import { it } from "../lib/effect.js" it.effect("conversation lowering excludes generation settings and tool definitions", () => Effect.gen(function* () { const body = yield* OpenResponses.lowerConversation( - LLM.request({ + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), system: "Keep the context", messages: [Message.user("hello"), Message.assistant("hi")], diff --git a/packages/ai/test/provider/decision.recorded.test.ts b/packages/ai/test/provider/decision.recorded.test.ts index bba41adbd419..396bbbb9eeb4 100644 --- a/packages/ai/test/provider/decision.recorded.test.ts +++ b/packages/ai/test/provider/decision.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Decision, type DecisionModel, type DecisionOptions } from "../../src/effect.js" +import { ai, type DecisionModel, type DecisionOptions } from "../../src/effect.js" import { Baseten, CloudflareAIGateway, @@ -171,10 +171,10 @@ describe("Decision recorded", () => { const assertDecision = ( model: DecisionModel, - input: Parameters[0]["input"] = [{ type: "text", text: ticket }], + input: Parameters[0]["input"] = [{ type: "text", text: ticket }], ) => Effect.gen(function* () { - const response = yield* Decision.generate({ model, input, questions }) + const response = yield* ai.decision.generate({ model, input, questions }) const [department, urgency, refund] = response.answers if (department.type === "error" || urgency.type === "error" || refund.type === "error") throw new Error("expected every question to be answered") diff --git a/packages/ai/test/provider/deepgram-speech.recorded.test.ts b/packages/ai/test/provider/deepgram-speech.recorded.test.ts index fee287306bb4..c2dc19e2a894 100644 --- a/packages/ai/test/provider/deepgram-speech.recorded.test.ts +++ b/packages/ai/test/provider/deepgram-speech.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Speech } from "../../src/effect.js" +import { Speech } from "../../src/experimental.js" import { Deepgram } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TEXT, collectSpeech } from "./speech-recording.js" diff --git a/packages/ai/test/provider/deepgram-transcription.recorded.test.ts b/packages/ai/test/provider/deepgram-transcription.recorded.test.ts index fb38b989e54e..f127d0f2d651 100644 --- a/packages/ai/test/provider/deepgram-transcription.recorded.test.ts +++ b/packages/ai/test/provider/deepgram-transcription.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { Transcription } from "../../src/effect.js" +import { Transcription } from "../../src/experimental.js" import { Deepgram } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js" diff --git a/packages/ai/test/provider/digitalocean.recorded.test.ts b/packages/ai/test/provider/digitalocean.recorded.test.ts index ca7d4364f0f0..e44ea16cf2d1 100644 --- a/packages/ai/test/provider/digitalocean.recorded.test.ts +++ b/packages/ai/test/provider/digitalocean.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMRequest } from "../../src/effect.js" +import { ai, LLMRequest } from "../../src/effect.js" import { DigitalOcean } from "../../src/providers/digitalocean.js" import { LLMClient } from "../../src/route.js" import { @@ -33,7 +33,7 @@ for (const item of [ { tags: ["cache", "usage"], metadata: { model: item.id } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, system: LARGE_CACHEABLE_SYSTEM, prompt: "Reply exactly: OK", diff --git a/packages/ai/test/provider/digitalocean.test.ts b/packages/ai/test/provider/digitalocean.test.ts index a14483713d04..2067510e11b3 100644 --- a/packages/ai/test/provider/digitalocean.test.ts +++ b/packages/ai/test/provider/digitalocean.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM } from "../../src/effect.js" +import { ai, CacheHint } from "../../src/effect.js" import { compileRequest } from "../../src/route/client.js" import { DigitalOcean } from "../../src/providers/digitalocean.js" import { it } from "../lib/effect.js" @@ -32,7 +32,7 @@ describe("DigitalOcean", () => { }) expect(model.route.endpoint.baseURL).toBe("https://inference.do-ai.run/v1") - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "Say hello.", cache: "none" })) expect(prepared.route).toBe("digitalocean") expect(prepared.body).toMatchObject({ @@ -46,7 +46,7 @@ describe("DigitalOcean", () => { it.effect("lowers the native cache policy to DigitalOcean cache_control markers", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1"), system: [ { type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttl: "1h" }) }, @@ -80,7 +80,7 @@ describe("DigitalOcean", () => { it.effect("parses DigitalOcean cache usage fields into AI.Usage", () => Effect.gen(function* () { const model = DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say OK" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Say OK" })).pipe( Effect.provide( fixedResponse( sseEvents( diff --git a/packages/ai/test/provider/elevenlabs-speech.recorded.test.ts b/packages/ai/test/provider/elevenlabs-speech.recorded.test.ts index fd8c778bd1a4..421a60a04e2d 100644 --- a/packages/ai/test/provider/elevenlabs-speech.recorded.test.ts +++ b/packages/ai/test/provider/elevenlabs-speech.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Speech, SpeechEvent } from "../../src/effect.js" +import { Speech, SpeechEvent } from "../../src/experimental.js" import { ElevenLabs } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TEXT, collectSpeech } from "./speech-recording.js" diff --git a/packages/ai/test/provider/elevenlabs-transcription.recorded.test.ts b/packages/ai/test/provider/elevenlabs-transcription.recorded.test.ts index d36f9b26c8b8..e6969eb8ab36 100644 --- a/packages/ai/test/provider/elevenlabs-transcription.recorded.test.ts +++ b/packages/ai/test/provider/elevenlabs-transcription.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { Transcription } from "../../src/effect.js" +import { Transcription } from "../../src/experimental.js" import { ElevenLabs } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js" diff --git a/packages/ai/test/provider/empty-tool-choice.test.ts b/packages/ai/test/provider/empty-tool-choice.test.ts index 88f05dbf6aff..cad0ab466a66 100644 --- a/packages/ai/test/provider/empty-tool-choice.test.ts +++ b/packages/ai/test/provider/empty-tool-choice.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, Message, ToolCallPart } from "../../src/effect.js" +import { ai, Message, ToolCallPart } from "../../src/effect.js" import { OpenAIChat } from "../../src/protocols/openai-chat.js" import { OpenAIResponses } from "../../src/protocols/openai-responses.js" import { Auth } from "../../src/route/auth.js" @@ -31,7 +31,7 @@ for (const route of [OpenAIChat.route, OpenAIResponses.route]) { ], ]) { const prepared = yield* compileRequest( - LLM.request({ model, messages, tools: [], toolChoice: choice, cache: "none" }), + ai.request({ model, messages, tools: [], toolChoice: choice, cache: "none" }), ) expect(prepared.body.tool_choice).toBeUndefined() } @@ -41,7 +41,7 @@ for (const route of [OpenAIChat.route, OpenAIResponses.route]) { it.effect(`${route.id} preserves ${choice.type} tool choice with active tools`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model, prompt: "Look up the value.", tools: [tool], toolChoice: choice }), + ai.request({ model, prompt: "Look up the value.", tools: [tool], toolChoice: choice }), ) expect(prepared.body.tool_choice).toEqual( choice.type !== "tool" @@ -62,7 +62,7 @@ it.effect("OpenAI Responses omits allowed tool choice without tool definitions", .model({ id: "gpt-4.1-mini" }) for (const tools of [[], [tool]]) { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Look up the value.", tools, diff --git a/packages/ai/test/provider/error-retention.test.ts b/packages/ai/test/provider/error-retention.test.ts index d512da0a2070..89556f6c40d3 100644 --- a/packages/ai/test/provider/error-retention.test.ts +++ b/packages/ai/test/provider/error-retention.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { Anthropic, Google, OpenAI } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { it } from "../lib/effect.js" @@ -43,7 +43,7 @@ describe("provider error retention", () => { it.effect(`retains the complete ${entry.name} event and HTTP context`, () => Effect.gen(function* () { const body = JSON.stringify({ ...entry.event, trace: { opaque: "outer" } }) - const error = yield* LLMClient.generate(LLM.request({ model: entry.model, prompt: "hello" })).pipe( + const error = yield* LLMClient.generate(ai.request({ model: entry.model, prompt: "hello" })).pipe( Effect.provide( fixedResponse(sseEvents(body), { headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" }, @@ -69,7 +69,7 @@ describe("provider error retention", () => { trace: { opaque: "outer" }, }) const error = yield* LLMClient.generate( - LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), + ai.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), ).pipe( Effect.provide( fixedResponse(sseEvents(body), { @@ -91,7 +91,7 @@ describe("provider error retention", () => { Effect.gen(function* () { const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } }) const error = yield* LLMClient.generate( - LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), + ai.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), ).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") @@ -105,7 +105,7 @@ describe("provider error retention", () => { Effect.gen(function* () { const body = JSON.stringify({ error: null, trace: { opaque: "outer" } }) const error = yield* LLMClient.generate( - LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), + ai.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), ).pipe( Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })), Effect.flip, @@ -122,7 +122,7 @@ describe("provider error retention", () => { Effect.gen(function* () { const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}' const error = yield* LLMClient.generate( - LLM.request({ model: Anthropic.configure(options).model("claude"), prompt: "hello" }), + ai.request({ model: Anthropic.configure(options).model("claude"), prompt: "hello" }), ).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.reason.body).toBe(body) @@ -135,7 +135,7 @@ describe("provider error retention", () => { Effect.gen(function* () { const body = '{"type":"error","error":{"code":"rate_limit_exceeded","message":"Slow down","extra":42}}' const error = yield* LLMClient.generate( - LLM.request({ model: OpenAI.configure(options).responses("gpt"), prompt: "hello" }), + ai.request({ model: OpenAI.configure(options).responses("gpt"), prompt: "hello" }), { webSocket: { execute: (exchange) => Effect.succeed({ frames: exchange.fallback(), complete: Effect.void }), diff --git a/packages/ai/test/provider/explicit-compaction.test.ts b/packages/ai/test/provider/explicit-compaction.test.ts index 0574132585e2..53cd410bed72 100644 --- a/packages/ai/test/provider/explicit-compaction.test.ts +++ b/packages/ai/test/provider/explicit-compaction.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, LLMRequest, Message, ToolDefinition } from "../../src/effect.js" +import { ai, LLMRequest, Message, ToolDefinition } from "../../src/effect.js" import { LLMClient, Route } from "../../src/route/client.js" import { Auth } from "../../src/route/auth.js" import { Endpoint } from "../../src/route/endpoint.js" @@ -67,7 +67,7 @@ testEffect( http: { body: { instructions: "default instructions" } }, }, }) - const request = LLM.request({ + const request = ai.request({ model: route.model({ id: "fixture" }), prompt: "hello", system: "system instructions", @@ -83,14 +83,14 @@ testEffect( }, }, }) - const options: Parameters[1] = { + const options: Parameters[1] = { http: (request, next) => { middleware.push(new URL(request.url).pathname) return next(request) }, } yield* LLMClient.generate(request, options) - yield* LLMClient.compact(request, options) + yield* ai.compact(request, options) expect(headers).toEqual(["fixture", "fixture"]) expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"]) }), @@ -121,7 +121,7 @@ for (const model of [ ), ).effect(`${model.provider} validates tools but ignores unrelated unsupported generation settings`, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, system: "Keep the context", messages: [ @@ -157,11 +157,11 @@ for (const model of [ const error = yield* LLMClient.generate(candidate).pipe(Effect.flip) expect(error.reason._tag).toBe(tag) if (candidate.tools.length > 0) { - const compactError = yield* LLMClient.compact(candidate).pipe(Effect.flip) + const compactError = yield* ai.compact(candidate).pipe(Effect.flip) expect(compactError.reason._tag).toBe("InvalidRequest") continue } - const response = yield* LLMClient.compact(candidate) + const response = yield* ai.compact(candidate) expect(response.replacement[0]?.content[0]?.type).toBe("compaction") } }), @@ -198,7 +198,7 @@ const retainedItems = [ content: [ { type: "input_image", image_url: "https://example.com/image.png" }, { type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" }, - { type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" }, + { type: "input_file", file_url: "https://example.com/report.pdf", detail: "low" }, ], }, checkpoint, @@ -231,11 +231,11 @@ for (const model of [ ), ).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, prompt: "hello", }) - const compacted = yield* LLMClient.compact(request) + const compacted = yield* ai.compact(request) expect(compacted.replacement.map((message) => message.role)).toEqual([ "user", "assistant", @@ -280,8 +280,8 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: }), ), ).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () => - LLMClient.compact( - LLM.request({ + ai.compact( + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello", promptCacheKey: "affinity", @@ -345,9 +345,11 @@ for (const item of [ `rejects unsupported compact output: ${JSON.stringify(item)}`, () => Effect.gen(function* () { - const error = yield* LLMClient.compact( - LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), - ).pipe(Effect.flip) + const error = yield* ai + .compact( + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), + ) + .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.reason.body).toContain(JSON.stringify(item)) expect(error.reason.http?.status).toBe(200) @@ -381,8 +383,8 @@ for (const model of [ ), ).effect(`${model.provider} preserves retained image detail through serialization and replay`, () => Effect.gen(function* () { - const request = LLM.request({ model, prompt: "hello" }) - const compacted = yield* LLMClient.compact(request) + const request = ai.request({ model, prompt: "hello" }) + const compacted = yield* ai.compact(request) const codec = Schema.fromJsonString(Schema.Array(Message)) const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement)) yield* LLMClient.generate(LLMRequest.update(request, { messages })) @@ -393,12 +395,12 @@ for (const model of [ testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () => Effect.gen(function* () { const request = LLMRequest.update( - LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }), + ai.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }), { providerOptions: { contextManagement: [{ type: "compaction" }] } }, ) const error = yield* LLMClient.generate(request).pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") - expect(error.message).toContain("LLMClient.compact") + expect(error.message).toContain("ai.compact") if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction") }), ) @@ -445,8 +447,8 @@ for (const model of [ ), ).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () => Effect.gen(function* () { - const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } }) - const compacted = yield* LLMClient.compact(request) + const request = ai.request({ model, prompt: "original", system: "system", http: { body: { store: false } } }) + const compacted = yield* ai.compact(request) expect(compacted.usage?.totalTokens).toBe(1010) expect(compacted.replacement.map((message) => message.role)).toEqual(["user", "assistant"]) expect(compacted.replacement[0]?.content).toEqual([{ type: "text", text: "retained" }]) @@ -469,7 +471,7 @@ for (const model of [ () => Effect.gen(function* () { // @ts-expect-error Untyped callers must still receive the runtime capability error. - const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip) + const error = yield* ai.compact(ai.request({ model, prompt: "hello" })).pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("does not support explicit compaction") if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compact") @@ -481,9 +483,9 @@ testEffect( fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })), ).effect("invalid explicit compaction preserves the original response and HTTP context", () => Effect.gen(function* () { - const error = yield* LLMClient.compact( - LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }), - ).pipe(Effect.flip) + const error = yield* ai + .compact(ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" })) + .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.reason.body).toContain("original payload") expect(error.reason.http?.status).toBe(200) diff --git a/packages/ai/test/provider/freeform-tools.test.ts b/packages/ai/test/provider/freeform-tools.test.ts index 26027dfc51af..90b4cbbe71aa 100644 --- a/packages/ai/test/provider/freeform-tools.test.ts +++ b/packages/ai/test/provider/freeform-tools.test.ts @@ -1,13 +1,13 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { - LLM, + ai, + type Freeform, + type LanguageModel, LLMEvent, Message, - ToolCallPart, toDefinitions, - type Freeform, - type LanguageModel, + ToolCallPart, } from "../../src/effect.js" import * as AmazonBedrockMantle from "../../src/providers/amazon-bedrock-mantle.js" import * as Anthropic from "../../src/providers/anthropic.js" @@ -21,7 +21,7 @@ import * as Venice from "../../src/providers/venice.js" import * as VercelAIGateway from "../../src/providers/vercel-ai-gateway.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" -import { Tool } from "../../src/tool.js" +import { tool } from "../../src/tool.js" import { it } from "../lib/effect.js" import { fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" @@ -30,7 +30,7 @@ const GRAMMAR = 'start: "ZZ" /[0-9]{3}/' const tools = (freeform?: Freeform) => toDefinitions({ - run: Tool.make({ + run: tool({ description: "Run code", parameters: Schema.Struct({ code: Schema.String }), success: Schema.String, @@ -43,7 +43,7 @@ const lark = tools({ type: "lark", grammar: GRAMMAR }) const plain = tools() const body = (model: LanguageModel, freeform = lark) => - compileRequest(LLM.request({ model, prompt: "Run it.", tools: freeform, toolChoice: "run" })).pipe( + compileRequest(ai.request({ model, prompt: "Run it.", tools: freeform, toolChoice: "run" })).pipe( Effect.map((prepared) => prepared.body), ) @@ -151,9 +151,7 @@ describe("freeform tools", () => { it.effect("streams Responses custom calls as JSON argument deltas", () => Effect.gen(function* () { const item = { type: "custom_tool_call", id: "ctc_1", call_id: "call_1", name: "run", input: "" } - const response = yield* LLMClient.generate( - LLM.request({ model: responses, prompt: "Run it.", tools: lark }), - ).pipe( + const response = yield* LLMClient.generate(ai.request({ model: responses, prompt: "Run it.", tools: lark })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -181,7 +179,7 @@ describe("freeform tools", () => { ]) expect(response.finishReason.normalized).toBe("tool-calls") - const replay = yield* compileRequest(LLM.request({ model: responses, messages: [response.message], tools: lark })) + const replay = yield* compileRequest(ai.request({ model: responses, messages: [response.message], tools: lark })) expect(replay.body.input[0]).toEqual({ type: "custom_tool_call", id: "ctc_1", @@ -195,9 +193,7 @@ describe("freeform tools", () => { it.effect("parses a Responses custom call that arrives only when done", () => Effect.gen(function* () { const item = { type: "custom_tool_call", id: "ctc_1", call_id: "call_1", name: "run", input: "ZZ123" } - const response = yield* LLMClient.generate( - LLM.request({ model: responses, prompt: "Run it.", tools: lark }), - ).pipe( + const response = yield* LLMClient.generate(ai.request({ model: responses, prompt: "Run it.", tools: lark })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -219,7 +215,7 @@ describe("freeform tools", () => { id: "chatcmpl_1", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, ...call }] }, finish_reason: finish }], }) - const response = yield* LLMClient.generate(LLM.request({ model: chat, prompt: "Run it.", tools: lark })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: chat, prompt: "Run it.", tools: lark })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -256,7 +252,7 @@ describe("freeform tools", () => { Message.tool({ id: "call_1", name: "run", result: "ok" }), ] const input = (declared: typeof lark, metadata: Record) => - compileRequest(LLM.request({ model: responses, messages: history(metadata), tools: declared })).pipe( + compileRequest(ai.request({ model: responses, messages: history(metadata), tools: declared })).pipe( Effect.map((prepared) => prepared.body.input.slice(1)), ) const custom = { itemId: "ctc_1", custom: true } @@ -279,7 +275,7 @@ describe("freeform tools", () => { ]) const messages = (model: LanguageModel, declared = lark) => - compileRequest(LLM.request({ model, messages: history(custom), tools: declared })).pipe( + compileRequest(ai.request({ model, messages: history(custom), tools: declared })).pipe( Effect.map((prepared) => prepared.body.messages.slice(1)), ) expect(yield* messages(chat)).toMatchObject([ @@ -303,7 +299,7 @@ describe("freeform tools", () => { it.effect("rejects freeform tools without exactly one required string parameter", () => Effect.sync(() => { const make = (parameters: Schema.Struct) => - Tool.make({ + tool({ description: "Run code", jsonSchema: Schema.toJsonSchemaDocument(parameters).schema, freeform: { type: "text" }, diff --git a/packages/ai/test/provider/gemini-cache.recorded.test.ts b/packages/ai/test/provider/gemini-cache.recorded.test.ts index ec53eb864527..2230adbd1401 100644 --- a/packages/ai/test/provider/gemini-cache.recorded.test.ts +++ b/packages/ai/test/provider/gemini-cache.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import * as Google from "../../src/providers/google.js" import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios.js" @@ -15,7 +15,7 @@ const model = Google.configure({ // API is out-of-band and intentionally not wired up). This test exists to // pin the usage-parsing path: `cachedContentTokenCount` should surface as // `cacheReadInputTokens` on the second identical call. -const cacheRequest = LLM.request({ +const cacheRequest = ai.request({ id: "recorded_gemini_cache", model, system: LARGE_CACHEABLE_SYSTEM, diff --git a/packages/ai/test/provider/gemini-openai-chat.recorded.test.ts b/packages/ai/test/provider/gemini-openai-chat.recorded.test.ts index c3df87a7ca6b..a38451260f9b 100644 --- a/packages/ai/test/provider/gemini-openai-chat.recorded.test.ts +++ b/packages/ai/test/provider/gemini-openai-chat.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolRuntime, toDefinitions } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Message, toDefinitions, ToolRuntime } from "../../src/effect.js" import * as OpenAICompatible from "../../src/providers/openai-compatible.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -29,7 +29,7 @@ describe("Gemini OpenAI-compatible Chat recorded", () => { () => Effect.gen(function* () { const tools = { [weatherToolName]: weatherRuntimeTool } - const request = LLM.request({ + const request = ai.request({ model, system: "Call get_weather for every requested city in parallel, then answer in one short sentence.", prompt: "What is the weather in Paris and in Tokyo?", diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 51f8b006aaca..a408907e80e4 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber } from "effect" import * as TestClock from "effect/testing/TestClock" -import { LLM, AIError, LLMEvent, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage, Media, isRetryable } from "../../src/effect.js" +import { ai, AIError, isRetryable, LLMEvent, LLMRequest, Media, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/effect.js" import { Auth, LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import * as Gemini from "../../src/protocols/gemini.js" @@ -24,7 +24,7 @@ const gemini3 = Gemini.route }) .model({ id: "gemini-3-flash-preview" }) -const request = LLM.request({ +const request = ai.request({ id: "req_1", model, system: "You are concise.", @@ -112,7 +112,7 @@ describe("Gemini route", () => { it.effect("forwards standard Gemini generation options", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Say hello.", generation: { @@ -145,7 +145,7 @@ describe("Gemini route", () => { it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], }), @@ -161,7 +161,7 @@ describe("Gemini route", () => { it.effect("keeps system updates separate from function responses", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), @@ -202,7 +202,7 @@ describe("Gemini route", () => { it.effect("merges parallel tool results into one function-response turn", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -247,7 +247,7 @@ describe("Gemini route", () => { it.effect("lowers function call ids for gemini 3 models", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), @@ -288,9 +288,9 @@ describe("Gemini route", () => { Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }), ] - const legacy = yield* compileRequest(LLM.request({ model, messages })) + const legacy = yield* compileRequest(ai.request({ model, messages })) const older = yield* compileRequest( - LLM.request({ + ai.request({ model: Gemini.route .with({ endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, @@ -316,7 +316,7 @@ describe("Gemini route", () => { it.effect("includes function call ids for non-gemini model ids", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: Gemini.route .with({ endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, @@ -345,7 +345,7 @@ describe("Gemini route", () => { it.effect("prepares multimodal user input and tool history", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result", model, tools: [ @@ -413,7 +413,7 @@ describe("Gemini route", () => { it.effect("continues media tool results as inline model input without base64 text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]), @@ -462,7 +462,7 @@ describe("Gemini route", () => { it.effect("strips matching data URLs to raw base64 inlineData", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user({ type: "media", media: Media.fromDataUrl("data:image/png;base64,AAEC") }), @@ -505,7 +505,7 @@ describe("Gemini route", () => { Effect.gen(function* () { const uri = "data:image/png;base64,AAECAw==" const value = { error: { type: "tool.execution", message: "snapshot failed" }, content: [] } - const request = LLM.request({ + const request = ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), @@ -566,7 +566,7 @@ describe("Gemini route", () => { it.effect("nests media inside function responses for gemini 3", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, messages: [ Message.assistant([ @@ -631,7 +631,7 @@ describe("Gemini route", () => { result: { type: "content", value: [{ type: "file", uri, mime: "video/mp4" }] }, }), ] - const gemini = yield* compileRequest(LLM.request({ model: gemini3, messages })) + const gemini = yield* compileRequest(ai.request({ model: gemini3, messages })) expect(gemini.body).toEqual({ contents: [ { role: "user", parts: [{ fileData: { mimeType: "video/mp4", fileUri: uri } }] }, @@ -661,7 +661,7 @@ describe("Gemini route", () => { }) const interactions = yield* compileRequest( - LLM.request({ + ai.request({ model: Google.configure({ apiKey: "test" }).interactions("gemini-3-flash-preview"), messages, }), @@ -684,7 +684,7 @@ describe("Gemini route", () => { }) const error = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, prompt: Message.media(Media.url("https://example.test/image.png", { mediaType: "image/png" })), }), @@ -696,7 +696,7 @@ describe("Gemini route", () => { it.effect("flushes pending media before system update text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]), @@ -734,7 +734,7 @@ describe("Gemini route", () => { it.effect("collects legacy tool media into one turn after merged responses", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -787,7 +787,7 @@ describe("Gemini route", () => { it.effect("passes encoded media through without local validation", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user([ @@ -814,7 +814,7 @@ describe("Gemini route", () => { it.effect("keeps tools and sends function calling mode NONE", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_choice_none", model, prompt: "Say hello.", @@ -834,7 +834,7 @@ describe("Gemini route", () => { it.effect("normalizes the JSON Schema shapes Gemini rejects", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_schema_patch", model, prompt: "Use the tool.", @@ -913,7 +913,7 @@ describe("Gemini route", () => { it.effect("preserves nested empty object tool schemas", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Use the tool.", tools: [ @@ -976,7 +976,7 @@ describe("Gemini route", () => { $defs: { Item: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Use the tool.", tools: [{ name: "filter", description: "Filter values", inputSchema }], @@ -1277,7 +1277,7 @@ describe("Gemini route", () => { ) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, messages: [ Message.assistant([ @@ -1344,7 +1344,7 @@ describe("Gemini route", () => { }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }]), @@ -1385,7 +1385,7 @@ describe("Gemini route", () => { it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, messages: [ Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]), @@ -1423,7 +1423,7 @@ describe("Gemini route", () => { it.effect("leaves unsigned parallel calls unchanged after a signed Gemini 3 call", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, messages: [ Message.assistant([ @@ -1463,7 +1463,7 @@ describe("Gemini route", () => { it.effect("adds the validator bypass sentinel to every call in an unsigned Gemini 3 batch", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: gemini3, messages: [ Message.assistant([ @@ -1960,7 +1960,7 @@ describe("Gemini route", () => { it.effect("replays generated assistant media as model inline data", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_media", model, messages: [ @@ -1990,7 +1990,7 @@ describe("Gemini route", () => { }) describe("Gemini response continuation", () => { - const continuedRequest = LLM.request({ + const continuedRequest = ai.request({ model: Gemini.route .with({ endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, diff --git a/packages/ai/test/provider/google-images.recorded.test.ts b/packages/ai/test/provider/google-images.recorded.test.ts index f1efab6a07ed..e47a9629489c 100644 --- a/packages/ai/test/provider/google-images.recorded.test.ts +++ b/packages/ai/test/provider/google-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image, Media } from "../../src/effect.js" +import { ai, Media } from "../../src/effect.js" import { Google } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -19,7 +19,7 @@ const recorded = recordedTests({ describe("Google Images recorded", () => { recorded.effect("generates an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "A simple flat blue circle centered on a plain white background.", aspect: "1:1", @@ -33,7 +33,7 @@ describe("Google Images recorded", () => { recorded.effect("edits an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "Transform this minimal source into a bright orange sun icon with eight rounded rays on a pale blue background.", diff --git a/packages/ai/test/provider/google-interactions.recorded.test.ts b/packages/ai/test/provider/google-interactions.recorded.test.ts index 38bf07eed98b..5fc00755ed81 100644 --- a/packages/ai/test/provider/google-interactions.recorded.test.ts +++ b/packages/ai/test/provider/google-interactions.recorded.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, LLMEvent, Message } from "../../src/effect.js" +import { ai, LLMEvent, Message } from "../../src/effect.js" import { Google } from "../../src/providers.js" import { LLMClient, RequestExecutor } from "../../src/route.js" import { recordedTests } from "../recorded-test.js" @@ -20,7 +20,7 @@ describe("Google Interactions recorded", () => { recorded.effect("streams text and reports usage", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, prompt: "Reply with exactly one word: hello", generation: { maxTokens: 2048 }, @@ -43,7 +43,7 @@ describe("Google Interactions recorded", () => { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, prompt: "Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.", @@ -71,7 +71,7 @@ describe("Google Interactions recorded", () => { "replays native tool results and signatures statelessly", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, system: "Use get_weather for weather questions. Answer concisely after receiving the result.", prompt: "What is the weather in Paris?", @@ -91,7 +91,7 @@ describe("Google Interactions recorded", () => { expect(call.input).toEqual({ city: "Paris" }) expect(call.providerMetadata?.google).toHaveProperty("interactionSignature") const second = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, system: request.system, tools: [weatherTool], @@ -118,7 +118,7 @@ describe("Google Interactions recorded", () => { "continues tool results with previous interaction id", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, system: "Use get_weather for weather questions. Answer concisely after receiving the result.", prompt: "What is the weather in Paris?", @@ -145,7 +145,7 @@ describe("Google Interactions recorded", () => { const call = first.toolCalls[0] if (!call) throw new Error("Missing recorded weather tool call") const second = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, system: request.system, tools: [weatherTool], diff --git a/packages/ai/test/provider/google-speech.recorded.test.ts b/packages/ai/test/provider/google-speech.recorded.test.ts index 4406ec80a290..3e162ca9456c 100644 --- a/packages/ai/test/provider/google-speech.recorded.test.ts +++ b/packages/ai/test/provider/google-speech.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Speech } from "../../src/effect.js" +import { Speech } from "../../src/experimental.js" import { Google } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TEXT, collectSpeech } from "./speech-recording.js" diff --git a/packages/ai/test/provider/google-transcription.recorded.test.ts b/packages/ai/test/provider/google-transcription.recorded.test.ts index 966c51ee04c4..b3d3ffc90b46 100644 --- a/packages/ai/test/provider/google-transcription.recorded.test.ts +++ b/packages/ai/test/provider/google-transcription.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { Transcription, TranscriptionEvent } from "../../src/effect.js" +import { Transcription, TranscriptionEvent } from "../../src/experimental.js" import { Google } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js" diff --git a/packages/ai/test/provider/google-vertex-interactions.test.ts b/packages/ai/test/provider/google-vertex-interactions.test.ts index 1e8caf3ef543..e3b34d021696 100644 --- a/packages/ai/test/provider/google-vertex-interactions.test.ts +++ b/packages/ai/test/provider/google-vertex-interactions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Redacted } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, Message, ToolCallPart } from "../../src/effect.js" +import { ai, Message, ToolCallPart } from "../../src/effect.js" import { GoogleVertexInteractions } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { Auth } from "../../src/route/auth.js" @@ -14,7 +14,7 @@ describe("Google Vertex Interactions", () => { it.effect("streams through the global project endpoint with OAuth and Vertex service tiers", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertexInteractions.configure({ accessToken: "vertex-token", location: "global", @@ -80,7 +80,7 @@ describe("Google Vertex Interactions", () => { it.effect("uses the project-free Express endpoint with API-key auth", () => Effect.gen(function* () { yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertexInteractions.configure({ apiKey: "vertex-key" }).model("gemini-3.8-flash"), prompt: "Say hello.", }), @@ -113,7 +113,7 @@ describe("Google Vertex Interactions", () => { auth: Auth.effect(Effect.sync(() => Redacted.make(tokens.shift() ?? "unexpected-token"))).bearer(), }).model("gemini-3.8-flash") const headers: string[] = [] - yield* Effect.forEach(["First.", "Second."], (prompt) => LLMClient.generate(LLM.request({ model, prompt }))).pipe( + yield* Effect.forEach(["First.", "Second."], (prompt) => LLMClient.generate(ai.request({ model, prompt }))).pipe( Effect.provide( dynamicResponse((input) => Effect.sync(() => { @@ -167,7 +167,7 @@ describe("Google Vertex Interactions", () => { Effect.gen(function* () { const model = GoogleVertexInteractions.configure({ apiKey: "vertex-key" }).model("gemini-3.8-flash") const generate = (...events: ReadonlyArray) => - LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + LLMClient.generate(ai.request({ model, prompt: "Hi" })).pipe( Effect.provide(fixedResponse(sseEvents(...events))), ) const response = yield* generate( @@ -205,7 +205,7 @@ describe("Google Vertex Interactions", () => { ] for (const item of cases) { const event = { event_type: "interaction.completed", interaction: { status: "failed", errors: item.errors } } - const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + const error = yield* LLMClient.generate(ai.request({ model, prompt: "Hi" })).pipe( Effect.provide(fixedResponse(sseEvents(event))), Effect.flip, ) @@ -224,7 +224,7 @@ describe("Google Vertex Interactions", () => { accessToken: "vertex-token", project: "vertex-project", }).model("gemini-3.8-flash") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Check the weather." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -243,7 +243,7 @@ describe("Google Vertex Interactions", () => { const call = response.toolCalls[0] expect(call.providerMetadata).toEqual({ vertex: { interactionSignature: "tool_sig" } }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make(call)]), diff --git a/packages/ai/test/provider/google-vertex.recorded.test.ts b/packages/ai/test/provider/google-vertex.recorded.test.ts index 8185ca778481..e8b7b5a53ec3 100644 --- a/packages/ai/test/provider/google-vertex.recorded.test.ts +++ b/packages/ai/test/provider/google-vertex.recorded.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/effect.js" +import { ai, Message, ToolCallPart, ToolDefinition } from "../../src/effect.js" import { GoogleVertex } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { recordedTests } from "../recorded-test.js" @@ -26,7 +26,7 @@ const recorded = recordedTests({ describe("Google Vertex Gemini recorded", () => { recorded.effect("streams text", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Reply with exactly one word: hello" })) + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Reply with exactly one word: hello" })) expect(response.text.toLowerCase()).toContain("hello") }), @@ -35,7 +35,7 @@ describe("Google Vertex Gemini recorded", () => { recorded.effect("calls a tool", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, prompt: "What is the weather in Paris? Use the lookup_weather tool.", tools: [lookupWeather], @@ -51,7 +51,7 @@ describe("Google Vertex Gemini recorded", () => { recorded.effect("continues after a tool result", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, messages: [ Message.user("What is the weather in Paris?"), diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index 119db0f2afe6..b92bf935b786 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/http" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { LanguageModel, LLM, Message, ToolCallPart } from "../../src/effect.js" +import { ai, LanguageModel, Message, ToolCallPart } from "../../src/effect.js" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -68,7 +68,7 @@ describe("Google Vertex providers", () => { it.effect("sends Gemini requests to the global Vertex endpoint", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertex.configure({ accessToken: "vertex-token", location: "global", @@ -111,7 +111,7 @@ describe("Google Vertex providers", () => { it.effect("adds billing labels to Vertex Gemini requests", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: GoogleVertex.configure({ accessToken: "vertex-token", project: "vertex-project", @@ -132,7 +132,7 @@ describe("Google Vertex providers", () => { it.effect("maps service tiers to the Vertex shared PayGo header", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertex.configure({ accessToken: "vertex-token", location: "global", @@ -171,7 +171,7 @@ describe("Google Vertex providers", () => { it.effect("preserves function call ids in lowered Vertex bodies", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: GoogleVertex.configure({ accessToken: "vertex-token", project: "vertex-project", @@ -220,7 +220,7 @@ describe("Google Vertex providers", () => { accessToken: "vertex-token", project: "vertex-project", }).model("gemini-3.5-flash") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Check the weather." })).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -264,7 +264,7 @@ describe("Google Vertex providers", () => { }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -318,7 +318,7 @@ describe("Google Vertex providers", () => { project: "vertex-project", }).model("claude-sonnet-4-6") const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, prompt: "Say hello.", }), @@ -363,7 +363,7 @@ describe("Google Vertex providers", () => { it.effect("sends MaaS requests through Vertex Chat Completions", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertexChat.configure({ accessToken: "vertex-token", location: "global", @@ -402,7 +402,7 @@ describe("Google Vertex providers", () => { it.effect("ignores keepalives sent as data while a partner model reasons", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertexChat.configure({ accessToken: "vertex-token", location: "global", @@ -429,7 +429,7 @@ describe("Google Vertex providers", () => { it.effect("sends Grok requests through Vertex Responses", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertexResponses.configure({ accessToken: "vertex-token", location: "global", @@ -473,9 +473,9 @@ describe("Google Vertex providers", () => { Effect.gen(function* () { const vertex = { accessToken: "vertex-token", location: "us-central1", project: "vertex-project" } const inputSchema = { type: "object", required: ["query", "missing"], properties: { query: { type: "string" } } } - const request = (model: Parameters[0]["model"]) => + const request = (model: Parameters[0]["model"]) => compileRequest( - LLM.request({ + ai.request({ model, prompt: "Use the tool.", tools: [{ name: "lookup", description: "Lookup.", inputSchema }], @@ -503,7 +503,7 @@ describe("Google Vertex providers", () => { it.effect("routes tuned Gemini models through their deployed endpoint", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertex.configure({ accessToken: "vertex-token", location: "us-central1", @@ -555,7 +555,7 @@ describe("Google Vertex providers", () => { sseEvents({ candidates: [{ content: { role: "model", parts: [{ text: "lo." }] }, finishReason: "STOP" }] }), ] const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: GoogleVertex.configure({ accessToken: "vertex-token", project: "vertex-project", diff --git a/packages/ai/test/provider/google-video.recorded.test.ts b/packages/ai/test/provider/google-video.recorded.test.ts index 5f9c5e0cee95..3704601ddc57 100644 --- a/packages/ai/test/provider/google-video.recorded.test.ts +++ b/packages/ai/test/provider/google-video.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Video } from "../../src/effect.js" +import { Video } from "../../src/experimental.js" import { Google } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { queuedPoll } from "./queued-recording.js" diff --git a/packages/ai/test/provider/groq.recorded.test.ts b/packages/ai/test/provider/groq.recorded.test.ts index 8e4741325594..5bece17d9ec1 100644 --- a/packages/ai/test/provider/groq.recorded.test.ts +++ b/packages/ai/test/provider/groq.recorded.test.ts @@ -1,7 +1,7 @@ import { configure } from "@opencode/ai/providers/groq" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import { recordedTests } from "../recorded-test.js" @@ -31,7 +31,7 @@ describe("Groq recorded", () => { { tags: ["text", "usage"], metadata: { model: "openai/gpt-oss-20b" } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: configure({ apiKey, providerOptions: { @@ -91,7 +91,7 @@ describe("Groq recorded", () => { }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: item.model, prompt: `Look up the current weather in ${item.cities.join(" and ")}. Call lookup_weather once for each city in the same response before answering. After receiving all results, report each city's weather in one short sentence.`, tools: [weather], @@ -153,7 +153,7 @@ describe("Groq recorded", () => { { tags: ["reasoning", "usage"], metadata: { model: "qwen/qwen3.6-27b" } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: configure({ apiKey, providerOptions: { reasoningEffort: "default" }, diff --git a/packages/ai/test/provider/groq.test.ts b/packages/ai/test/provider/groq.test.ts index eac3b09d9038..f15f70cd74bd 100644 --- a/packages/ai/test/provider/groq.test.ts +++ b/packages/ai/test/provider/groq.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LanguageModel, LLM, Message } from "../../src/effect.js" +import { ai, LanguageModel, Message } from "../../src/effect.js" import { OpenAIChat } from "../../src/protocols/openai-chat.js" import { Groq } from "../../src/providers/groq.js" import { compileRequest } from "../../src/route/client.js" @@ -13,7 +13,7 @@ it.effect("Groq reuses Chat streaming and defaults to parsed reasoning", () => const model = Groq.configure({ apiKey: "fixture" }).model("llama-3.3-70b-versatile") expect(model.route.endpoint.baseURL).toBe("https://api.groq.com/openai/v1") const compiled = yield* compileRequest( - LLM.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }), + ai.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }), ) expect(compiled.body).toMatchObject({ max_completion_tokens: 64, @@ -42,7 +42,7 @@ it.effect("Groq lowers its own options for custom catalog identities and endpoin { provider: "custom-groq" }, ) const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", tools: [weatherTool], @@ -69,7 +69,7 @@ it.effect("Groq honors disableParallelToolUse and sends parallel_tool_calls only Effect.gen(function* () { const model = Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b") const withTools = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", tools: [weatherTool], @@ -78,7 +78,7 @@ it.effect("Groq honors disableParallelToolUse and sends parallel_tool_calls only ) expect(withTools.body.parallel_tool_calls).toBe(false) const withoutTools = yield* compileRequest( - LLM.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: true } }), + ai.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: true } }), ) expect(withoutTools.body).not.toHaveProperty("parallel_tool_calls") }), @@ -87,7 +87,7 @@ it.effect("Groq honors disableParallelToolUse and sends parallel_tool_calls only it.effect("Groq replays reasoning only when present and preserves explicit reasoning exclusion", () => Effect.gen(function* () { const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model: Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b"), messages: [ Message.user("Think"), @@ -114,7 +114,7 @@ it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () => Effect.gen(function* () { for (const id of ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b"]) { const compiled = yield* compileRequest( - LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }), + ai.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }), ) expect(compiled.body.reasoning_format).toBeUndefined() expect(compiled.body.include_reasoning).toBeUndefined() @@ -125,7 +125,7 @@ it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () => it.effect("Groq validates option types", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model: Groq.configure({ apiKey: "fixture" }).model("qwen"), prompt: "Hello", providerOptions: { includeReasoning: "false" }, diff --git a/packages/ai/test/provider/image-detail.test.ts b/packages/ai/test/provider/image-detail.test.ts index 93771ac4e163..b4781988cd7a 100644 --- a/packages/ai/test/provider/image-detail.test.ts +++ b/packages/ai/test/provider/image-detail.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, Media, Message } from "../../src/effect.js" +import { ai, Media, Message } from "../../src/effect.js" import { OpenAI, Azure, XAI } from "../../src/providers.js" import { compileRequest } from "../../src/route/client.js" import { it } from "../lib/effect.js" @@ -23,7 +23,7 @@ for (const model of [ ) const codec = Schema.fromJsonString(Message) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))], }), @@ -42,7 +42,7 @@ for (const model of [ it.effect("rejects malformed image detail instead of silently discarding it", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), messages: [ Message.user({ diff --git a/packages/ai/test/provider/media-entrypoints.test.ts b/packages/ai/test/provider/media-entrypoints.test.ts index c3cad5781a4d..79a1e33e24d1 100644 --- a/packages/ai/test/provider/media-entrypoints.test.ts +++ b/packages/ai/test/provider/media-entrypoints.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { Decision, DecisionClient, Image, ImageClient, type DecisionModel, type ImageModel } from "../../src/effect.js" +import { ai, DecisionClient, type DecisionModel, ImageClient, type ImageModel } from "../../src/effect.js" import { it } from "../lib/effect.js" import { dynamicResponse, observe, type Call } from "../lib/http.js" @@ -13,21 +13,22 @@ const record = (calls: Array) => const sentImage = (model: ImageModel) => Effect.gen(function* () { const calls: Array = [] - yield* Image.generate({ model, prompt: "A red circle" }).pipe( - Effect.exit, - Effect.provide(ImageClient.layer.pipe(Layer.provide(record(calls)))), - ) + yield* ai.image + .generate({ model, prompt: "A red circle" }) + .pipe(Effect.exit, Effect.provide(ImageClient.layer.pipe(Layer.provide(record(calls))))) return calls }) const sentDecision = (model: DecisionModel) => Effect.gen(function* () { const calls: Array = [] - yield* Decision.generate({ - model, - input: [{ type: "text", text: "The sky is red." }], - questions: [{ name: "red", type: "predicate", instructions: "Is the sky red?" }], - }).pipe(Effect.exit, Effect.provide(DecisionClient.layer.pipe(Layer.provide(record(calls))))) + yield* ai.decision + .generate({ + model, + input: [{ type: "text", text: "The sky is red." }], + questions: [{ name: "red", type: "predicate", instructions: "Is the sky red?" }], + }) + .pipe(Effect.exit, Effect.provide(DecisionClient.layer.pipe(Layer.provide(record(calls))))) return calls }) diff --git a/packages/ai/test/provider/meta-capabilities.test.ts b/packages/ai/test/provider/meta-capabilities.test.ts index b6c18c15fbc2..50a335bba973 100644 --- a/packages/ai/test/provider/meta-capabilities.test.ts +++ b/packages/ai/test/provider/meta-capabilities.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { Effect, Layer } from "effect" import { HttpClientRequest } from "effect/http" -import { Image, ImageClient, LLM, LLMEvent, LLMRequest, Media, Message, ToolDefinition } from "../../src/effect.js" +import { ai, ImageClient, LLMEvent, LLMRequest, Media, Message, ToolDefinition } from "../../src/effect.js" import { Meta } from "../../src/providers/meta.js" import { MetaMessages } from "../../src/protocols/meta-messages.js" import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js" @@ -16,7 +16,7 @@ it.effect("Meta selects Messages and lowers native search alongside ordinary fun expect(model.route.endpoint).toMatchObject({ baseURL: "https://gateway.example/v1", path: "/messages" }) expect(MetaMessages.protocol.stream).toBe(AnthropicMessages.protocol.stream) const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Search", tools: [ @@ -62,7 +62,7 @@ it.effect("Meta rejects unsupported native tools instead of sending them as loca { model: Meta.messages("muse-spark-1.3"), tool: Meta.webSearch({ searchContextSize: "low" }) }, ]) { const error = yield* compileRequest( - LLM.request({ model: input.model, prompt: "Hello", tools: [input.tool] }), + ai.request({ model: input.model, prompt: "Hello", tools: [input.tool] }), ).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") } @@ -71,7 +71,7 @@ it.effect("Meta rejects unsupported native tools instead of sending them as loca it.effect("Meta Images preserves request overlays, bearer auth, JSON edit inputs and URL output formats", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: Meta.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1", @@ -127,12 +127,14 @@ it.effect("Meta Images preserves request overlays, bearer auth, JSON edit inputs it.effect("Meta Images validates the final output format before sending the request", () => Effect.gen(function* () { - const error = yield* Image.generate({ - model: Meta.configure({ apiKey: "fixture" }).image("muse-image-1.0"), - prompt: "Draw", - format: "png", - http: { body: { output_format: 42 } }, - }).pipe(Effect.flip) + const error = yield* ai.image + .generate({ + model: Meta.configure({ apiKey: "fixture" }).image("muse-image-1.0"), + prompt: "Draw", + format: "png", + http: { body: { output_format: 42 } }, + }) + .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") }).pipe( Effect.provide( @@ -149,11 +151,11 @@ for (const streamed of [false, true]) { () => Effect.gen(function* () { const item = { type: "image_generation_call", id: "ig_signed", status: "completed", result: "iVBORw0KGgo=" } - const request = LLM.request({ + const request = ai.request({ model: Meta.configure({ apiKey: "fixture" }).responses("muse-image-1.0"), prompt: "Draw", }) - const response = yield* LLM.generate(request).pipe( + const response = yield* ai.generate(request).pipe( Effect.provide( fixedResponse( sseEvents( @@ -190,22 +192,24 @@ for (const streamed of [false, true]) { it.effect("Meta rejects malformed image data from terminal-only Responses output", () => Effect.gen(function* () { - const error = yield* LLM.generate( - LLM.request({ model: Meta.configure({ apiKey: "fixture" }).responses("muse-image-1.0"), prompt: "Draw" }), - ).pipe( - Effect.provide( - fixedResponse( - sseEvents({ - type: "response.completed", - response: { - id: "resp_invalid", - output: [{ type: "image_generation_call", id: "ig_invalid", result: "!not-base64!" }], - }, - }), + const error = yield* ai + .generate( + ai.request({ model: Meta.configure({ apiKey: "fixture" }).responses("muse-image-1.0"), prompt: "Draw" }), + ) + .pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "response.completed", + response: { + id: "resp_invalid", + output: [{ type: "image_generation_call", id: "ig_invalid", result: "!not-base64!" }], + }, + }), + ), ), - ), - Effect.flip, - ) + Effect.flip, + ) expect(error.reason._tag).toBe("InvalidProviderOutput") }), ) diff --git a/packages/ai/test/provider/meta-images.recorded.test.ts b/packages/ai/test/provider/meta-images.recorded.test.ts index d610d441c157..56a60a48bb15 100644 --- a/packages/ai/test/provider/meta-images.recorded.test.ts +++ b/packages/ai/test/provider/meta-images.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { Image, LLM, LLMEvent, LLMRequest, Media, Message } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Media, Message } from "../../src/effect.js" import { Meta } from "../../src/providers/meta.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -25,7 +25,7 @@ recorded.effect.with( { protocol: "meta-images", tags: ["generation", "webp"] }, () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: meta.image(modelID), prompt: "A flat black square centered on a plain white background. No text.", n: 1, @@ -46,7 +46,7 @@ recorded.effect.with( { protocol: "meta-images", tags: ["editing", "png"] }, () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: meta.image(modelID), prompt: "Change the shape to bright purple. Keep the plain white background.", images: [ @@ -69,7 +69,7 @@ recorded.effect.with( { protocol: "meta-responses", tags: ["hosted", "continuation", "editing"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: meta.responses(modelID), prompt: "A simple flat black square centered on a plain white background. No text.", tools: [ diff --git a/packages/ai/test/provider/meta-messages.recorded.test.ts b/packages/ai/test/provider/meta-messages.recorded.test.ts index 659a9ddc7981..e8bec051274a 100644 --- a/packages/ai/test/provider/meta-messages.recorded.test.ts +++ b/packages/ai/test/provider/meta-messages.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/effect.js" import { Meta } from "../../src/providers/meta.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -21,7 +21,7 @@ for (const mode of ["adaptive", "enabled"] as const) { { tags: ["text", "reasoning", mode] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 2048 }, @@ -60,7 +60,7 @@ recorded.effect.with( { tags: ["tool", "tool-loop", "reasoning"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model, prompt: "Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.", diff --git a/packages/ai/test/provider/meta-search.recorded.test.ts b/packages/ai/test/provider/meta-search.recorded.test.ts index 14f9b0654eb2..73d438a0000b 100644 --- a/packages/ai/test/provider/meta-search.recorded.test.ts +++ b/packages/ai/test/provider/meta-search.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Message } from "../../src/effect.js" import { Meta } from "../../src/providers/meta.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -21,7 +21,7 @@ for (const api of ["responses", "messages"] as const) { { protocol: `meta-${api}` }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: meta[api]("muse-spark-1.3"), prompt: "Use web search to find NASA's page identifying the first person to walk on the Moon. Answer in one sentence with a source citation.", diff --git a/packages/ai/test/provider/meta.recorded.test.ts b/packages/ai/test/provider/meta.recorded.test.ts index 9a209b00f491..3ddfd89d3122 100644 --- a/packages/ai/test/provider/meta.recorded.test.ts +++ b/packages/ai/test/provider/meta.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, LLMResponse, Message, ToolDefinition } from "../../src/effect.js" import { Meta } from "../../src/providers/meta.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -35,7 +35,7 @@ for (const api of ["responses", "chat"] as const) { { tags: ["text", "reasoning", "usage", `effort:${effort ?? "default"}`] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: meta[api](modelID), prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 1024 }, @@ -85,7 +85,7 @@ for (const api of ["responses", "chat"] as const) { { tags: ["tool", "tool-loop", "reasoning", "usage", "effort:low"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: meta[api](modelID), prompt: "Look up the current weather in Paris using lookup_weather before answering. After receiving the result, report Paris's weather in one short sentence.", diff --git a/packages/ai/test/provider/meta.test.ts b/packages/ai/test/provider/meta.test.ts index b0855b666364..25e3b66ddee2 100644 --- a/packages/ai/test/provider/meta.test.ts +++ b/packages/ai/test/provider/meta.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { Auth, LLM, LLMClient } from "../../src/effect.js" +import { ai, Auth, LLMClient } from "../../src/effect.js" import { Meta } from "../../src/providers/index.js" import { OpenAIChat } from "../../src/protocols/openai-chat.js" import { OpenResponses } from "../../src/protocols/open-responses.js" @@ -27,7 +27,7 @@ it.effect("Meta composes baseline protocols with provider-owned endpoints and de } expect(responses.route.endpoint.path).toBe("/responses") expect(chat.route.endpoint.path).toBe("/chat/completions") - const compiled = yield* compileRequest(LLM.request({ model: responses, prompt: "Hello" })) + const compiled = yield* compileRequest(ai.request({ model: responses, prompt: "Hello" })) expect(compiled.protocol).toBe("meta-responses") expect(compiled.body).toMatchObject({ store: false, include: ["reasoning.encrypted_content"] }) expect(compiled.body.reasoning).toBeUndefined() @@ -38,7 +38,7 @@ it.effect("Meta Responses stays on HTTP when a WebSocket executor is supplied", Effect.gen(function* () { for (const baseURL of ["https://api.meta.ai/v1", "https://gateway.example/v1"]) { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: Meta.configure({ apiKey: "fixture", baseURL }).responses("muse-spark-1.3"), prompt: "Hello", }), @@ -93,7 +93,7 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu expect(model.route.defaults.headers).toEqual({ "x-client": "test" }) expect(model.route.defaults.http?.body).toEqual({ custom: "value" }) const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", generation: { maxTokens: 64 }, @@ -127,7 +127,7 @@ it.effect("Meta resolves environment credentials and accepts explicit auth overr ]) { const model = scenario.provider[api]("muse-spark-1.3") const headers = yield* model.route.auth.apply({ - request: LLM.request({ model, prompt: "Hello" }), + request: ai.request({ model, prompt: "Hello" }), method: "POST", url: "https://api.meta.ai/v1/responses", body: "{}", diff --git a/packages/ai/test/provider/minimax-images.recorded.test.ts b/packages/ai/test/provider/minimax-images.recorded.test.ts index fa25aa8a0072..a90f5eee5907 100644 --- a/packages/ai/test/provider/minimax-images.recorded.test.ts +++ b/packages/ai/test/provider/minimax-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image, Media } from "../../src/effect.js" +import { ai, Media } from "../../src/effect.js" import { MiniMax } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -17,7 +17,7 @@ const recorded = recordedTests({ describe("MiniMax Images recorded", () => { recorded.effect("generates an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "A simple flat red circle centered on a plain white background.", aspect: "1:1", @@ -33,7 +33,7 @@ describe("MiniMax Images recorded", () => { recorded.effect("generates from a subject reference", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "The same woman reading a book in a quiet library, soft window light.", // MiniMax's own documentation portrait. diff --git a/packages/ai/test/provider/minimax-images.test.ts b/packages/ai/test/provider/minimax-images.test.ts index bc921e2c6962..d3ba12c1478d 100644 --- a/packages/ai/test/provider/minimax-images.test.ts +++ b/packages/ai/test/provider/minimax-images.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { HttpClientRequest } from "effect/http" -import { Image, ImageClient, Media } from "../../src/effect.js" +import { ai, ImageClient, Media } from "../../src/effect.js" import type { ImageRequestInput } from "../../src/image.js" import { MiniMax } from "../../src/providers.js" import { it } from "../lib/effect.js" @@ -17,7 +17,7 @@ const sent = ( ) => Effect.gen(function* () { let body: unknown - yield* Image.generate({ model: minimax.image(model), prompt: "test", ...request }).pipe( + yield* ai.image.generate({ model: minimax.image(model), prompt: "test", ...request }).pipe( Effect.provide( ImageClient.layer.pipe( Layer.provide( @@ -37,19 +37,21 @@ const failure = ( model = "image-01", response = imageResponse, ) => - Image.generate({ model: minimax.image(model), prompt: "test", ...request }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide(fixedResponse(response, { headers: { "content-type": "application/json" } })), + ai.image + .generate({ model: minimax.image(model), prompt: "test", ...request }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide(fixedResponse(response, { headers: { "content-type": "application/json" } })), + ), ), - ), - Effect.flip, - ) + Effect.flip, + ) describe("MiniMax Images", () => { it.effect("generates through the MiniMax image API", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: MiniMax.configure({ apiKey: "test", baseURL: "https://api.minimax.test/v1", diff --git a/packages/ai/test/provider/minimax.recorded.test.ts b/packages/ai/test/provider/minimax.recorded.test.ts index 2b1d589563e7..b3a235bcb5d8 100644 --- a/packages/ai/test/provider/minimax.recorded.test.ts +++ b/packages/ai/test/provider/minimax.recorded.test.ts @@ -1,14 +1,14 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { - LLM, + ai, + type LanguageModel, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition, - type LanguageModel, } from "../../src/effect.js" import { MiniMax } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" @@ -92,7 +92,7 @@ describe("MiniMax recorded", () => { { tags: ["text", "usage", item.reasoning ? "reasoning" : "thinking-off"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: item.model, prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 1536 }, @@ -126,7 +126,7 @@ describe("MiniMax recorded", () => { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: minimax.model("MiniMax-M3"), prompt: "Use get_weather to look up the current weather in Paris.", tools: [weather], @@ -177,7 +177,7 @@ describe("MiniMax recorded", () => { { tags: ["tool", "tool-loop", "reasoning", "continuation", "usage"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: item.model, prompt: "Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.", diff --git a/packages/ai/test/provider/minimax.test.ts b/packages/ai/test/provider/minimax.test.ts index 52f6da1e9692..b9e31434d959 100644 --- a/packages/ai/test/provider/minimax.test.ts +++ b/packages/ai/test/provider/minimax.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { MiniMax } from "../../src/providers.js" import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js" import { OpenResponses } from "../../src/protocols/open-responses.js" @@ -26,7 +26,7 @@ describe("MiniMax provider", () => { { model: minimax.chat("MiniMax-M3"), path: "/v1/chat/completions" }, { model: minimax.responses("MiniMax-M3"), path: "/v1/responses" }, ]) { - const request = LLM.request({ model: item.model, prompt: "Hello" }) + const request = ai.request({ model: item.model, prompt: "Hello" }) const compiled = yield* compileRequest(request) expect(item.model.provider).toBe("minimax") expect(item.model.route.providerMetadataKey).toBe("minimax") @@ -51,7 +51,7 @@ describe("MiniMax provider", () => { baseURL: "https://gateway.example/anthropic/v1", auth: Auth.header("x-api-key", "gateway-key"), }).model("custom-model") - const request = LLM.request({ model, prompt: "Hello" }) + const request = ai.request({ model, prompt: "Hello" }) const compiled = yield* compileRequest(request) expect(Endpoint.render(model.route.endpoint, { request, body: compiled.body }).toString()).toBe( "https://gateway.example/anthropic/v1/messages", @@ -73,12 +73,12 @@ describe("MiniMax provider", () => { Effect.gen(function* () { const minimax = MiniMax.configure({ apiKey: "fixture" }) const messages = yield* compileRequest( - LLM.request({ model: minimax.model("MiniMax-M3"), providerOptions: { thinking: { type: "adaptive" } } }), + ai.request({ model: minimax.model("MiniMax-M3"), providerOptions: { thinking: { type: "adaptive" } } }), ) expect(messages.body.thinking).toEqual({ type: "adaptive" }) expect(messages.body.output_config).toBeUndefined() const chat = yield* compileRequest( - LLM.request({ + ai.request({ model: minimax.chat("MiniMax-M3"), generation: { maxTokens: 128 }, providerOptions: { thinking: { type: "disabled" }, reasoningSplit: false }, @@ -92,7 +92,7 @@ describe("MiniMax provider", () => { }) expect(chat.body.store).toBeUndefined() const responses = yield* compileRequest( - LLM.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "minimal" } }), + ai.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "minimal" } }), ) expect(responses.body.reasoning).toEqual({ effort: "minimal" }) expect(responses.body.include).toBeUndefined() diff --git a/packages/ai/test/provider/missing-tool-input.test.ts b/packages/ai/test/provider/missing-tool-input.test.ts index 2c249657a3a6..242e32793e22 100644 --- a/packages/ai/test/provider/missing-tool-input.test.ts +++ b/packages/ai/test/provider/missing-tool-input.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, Message, ToolCallPart } from "../../src/effect.js" +import { ai, Message, ToolCallPart } from "../../src/effect.js" import { OpenAIChat } from "../../src/protocols/openai-chat.js" import { OpenAIResponses } from "../../src/protocols/openai-responses.js" import { Auth } from "../../src/route/auth.js" @@ -21,7 +21,7 @@ for (const route of [OpenAIChat.route, OpenAIResponses.route]) { }) expect(Schema.is(Message)(message)).toBe(true) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Look up the value."), @@ -57,7 +57,7 @@ for (const route of [OpenAIChat.route, OpenAIResponses.route]) { [{ value: "original" }, '{"value":"original"}'], ] as const) { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input })]), diff --git a/packages/ai/test/provider/mistral-chat.test.ts b/packages/ai/test/provider/mistral-chat.test.ts index b72a47048e5c..3f4cd8e3d7de 100644 --- a/packages/ai/test/provider/mistral-chat.test.ts +++ b/packages/ai/test/provider/mistral-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { ConfigProvider, Effect } from "effect" import { HttpClientRequest } from "effect/http" -import { LanguageModel, LLM, LLMEvent, Message, SystemPart, ToolCallPart, ToolDefinition, Media } from "../../src/effect.js" +import { ai, LanguageModel, LLMEvent, Message, SystemPart, ToolCallPart, ToolDefinition, Media } from "../../src/effect.js" import { Mistral } from "../../src/providers/index.js" import { MistralChat } from "../../src/protocols/index.js" import { LLMClient } from "../../src/route.js" @@ -11,7 +11,7 @@ import { dynamicResponse, fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" const model = Mistral.configure({ apiKey: "fixture" }).model("mistral-large-latest") -const request = LLM.request({ model, prompt: "Hello" }) +const request = ai.request({ model, prompt: "Hello" }) const chunk = (delta: object, finishReason: string | null = null, usage?: object) => ({ choices: [{ delta, finish_reason: finishReason }], usage, @@ -39,7 +39,7 @@ describe("Mistral Chat", () => { it.effect("lowers native messages, media, tool choice, options, and replay IDs", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")], messages: [ @@ -144,7 +144,7 @@ describe("Mistral Chat", () => { it.effect("preserves valid replay IDs", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }), @@ -162,7 +162,7 @@ describe("Mistral Chat", () => { it.effect("applies trailing prefix, cache, and reasoning options without changing earlier assistants", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, promptCacheKey: "common-key", messages: [Message.assistant("Earlier"), Message.user("Continue"), Message.assistant("Prefix")], @@ -178,7 +178,7 @@ describe("Mistral Chat", () => { ]) const uncached = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", promptCacheKey: "common-key", @@ -189,7 +189,7 @@ describe("Mistral Chat", () => { expect(uncached.body.prompt_cache_key).toBeUndefined() const unsupported = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }), prompt: "Hello", promptCacheKey: "common-key", @@ -200,7 +200,7 @@ describe("Mistral Chat", () => { const longKey = "cache-key-".repeat(10) const unbounded = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", promptCacheKey: longKey, @@ -209,7 +209,7 @@ describe("Mistral Chat", () => { expect(unbounded.body.prompt_cache_key).toBe(longKey) const conflict = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high", promptMode: "reasoning" }, @@ -222,7 +222,7 @@ describe("Mistral Chat", () => { it.effect("omits empty assistant history unless it carries a tool call", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant(" \n "), @@ -242,7 +242,7 @@ describe("Mistral Chat", () => { it.effect("preserves remote media URLs and structured tool-result media", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user({ @@ -286,7 +286,7 @@ describe("Mistral Chat", () => { const uri = "data:image/png;base64,AAECAw==" const value = { error: { type: "tool.execution", message: "snapshot failed" }, content: [] } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "Ab12Cd34E", name: "snapshot", input: {} })]), @@ -327,7 +327,7 @@ describe("Mistral Chat", () => { it.effect("concatenates text-only user and tool content without separators", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user([ @@ -356,7 +356,7 @@ describe("Mistral Chat", () => { it.effect("keeps a whitespace separator when joining text-only tool results", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.tool({ @@ -424,7 +424,7 @@ describe("Mistral Chat", () => { { type: "text", text: "Answer" }, ]) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", @@ -467,7 +467,7 @@ describe("Mistral Chat", () => { }, ]) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", @@ -783,7 +783,7 @@ describe("Mistral Chat", () => { it.effect("uses environment bearer auth and custom package settings", () => LLMClient.generate( - LLM.request({ + ai.request({ model: Mistral.model("fixture-model", { baseURL: "https://mistral.test/v1", headers: { "x-app": "test" }, diff --git a/packages/ai/test/provider/mistral.recorded.test.ts b/packages/ai/test/provider/mistral.recorded.test.ts index 823a6ca917c5..c33d458aabe7 100644 --- a/packages/ai/test/provider/mistral.recorded.test.ts +++ b/packages/ai/test/provider/mistral.recorded.test.ts @@ -1,7 +1,7 @@ import { configure } from "@opencode/ai/providers/mistral" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import { recordedTests } from "../recorded-test.js" @@ -38,7 +38,7 @@ describe("Mistral recorded", () => { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest"), prompt: "Reply with exactly one word: hello", generation: { maxTokens: 40, temperature: 0 }, @@ -59,7 +59,7 @@ describe("Mistral recorded", () => { () => Effect.gen(function* () { const model = configure({ apiKey, providerOptions: { reasoningEffort: "high" } }).model("mistral-small-latest") - const firstRequest = LLM.request({ + const firstRequest = ai.request({ model, prompt: "Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.", generation: { maxTokens: 512, temperature: 0 }, @@ -95,7 +95,7 @@ describe("Mistral recorded", () => { () => Effect.gen(function* () { const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest") - const firstRequest = LLM.request({ + const firstRequest = ai.request({ model, system: [ SystemPart.make("Call lookup_weather exactly once with Paris."), @@ -140,7 +140,7 @@ describe("Mistral hosted GLM recorded", () => { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: configure({ apiKey }).model("zai-glm-5-2"), system: "Call lookup_weather exactly once with Paris.", prompt: "What is the weather?", diff --git a/packages/ai/test/provider/moonshot-capabilities.recorded.test.ts b/packages/ai/test/provider/moonshot-capabilities.recorded.test.ts index 5d6f339eaf13..530d826d38a8 100644 --- a/packages/ai/test/provider/moonshot-capabilities.recorded.test.ts +++ b/packages/ai/test/provider/moonshot-capabilities.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/effect.js" +import { ai, LLMEvent, Media, Message, ToolDefinition } from "../../src/effect.js" import { Moonshot } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { recordedTests } from "../recorded-test.js" @@ -39,7 +39,7 @@ for (const api of ["chat", "messages", "responses"] as const) { () => Effect.gen(function* () { const generate = LLMClient.generate( - LLM.request({ + ai.request({ model, prompt: "Use get_weather to look up the current weather in Paris.", tools: [weather], @@ -80,7 +80,7 @@ for (const api of ["chat", "messages", "responses"] as const) { Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(), ) const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, messages: [ Message.user([ @@ -105,7 +105,7 @@ for (const api of ["chat", "messages", "responses"] as const) { Effect.gen(function* () { const format = { type: "json_schema", name: "location", schema, strict: true } const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model, prompt: "Return a JSON object containing the capital city of France.", generation: { maxTokens: 4096 }, diff --git a/packages/ai/test/provider/moonshot.recorded.test.ts b/packages/ai/test/provider/moonshot.recorded.test.ts index ed47f8c5d71d..6b00002824e5 100644 --- a/packages/ai/test/provider/moonshot.recorded.test.ts +++ b/packages/ai/test/provider/moonshot.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, type LLMResponse, Message, ToolDefinition } from "../../src/effect.js" import { Moonshot } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -18,7 +18,7 @@ for (const api of ["chat", "messages", "responses"] as const) { { tags: ["text", "reasoning", "usage"], metadata: { model: "kimi-k3" } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: Moonshot.configure({ apiKey, providerOptions: api === "messages" ? { effort } : { reasoningEffort: effort }, @@ -66,7 +66,7 @@ for (const item of [ () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: Moonshot.configure({ apiKey, providerOptions: { thinking: item.thinking } }).chat(item.model), prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 4096 }, @@ -106,7 +106,7 @@ for (const item of [ { tags: ["tool", "tool-loop", "reasoning", "continuation", "usage"], metadata: { model: item.model } }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: Moonshot.configure({ apiKey, providerOptions: item.options })[item.api](item.model), prompt: "Use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.", diff --git a/packages/ai/test/provider/moonshot.test.ts b/packages/ai/test/provider/moonshot.test.ts index 7b95297caf07..6367b220e041 100644 --- a/packages/ai/test/provider/moonshot.test.ts +++ b/packages/ai/test/provider/moonshot.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { Auth, LLM, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/effect.js" +import { ai, Auth, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/effect.js" import { Moonshot } from "../../src/providers.js" import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js" import { OpenResponses } from "../../src/protocols/open-responses.js" @@ -22,7 +22,7 @@ it.effect("Moonshot composes shared protocols with explicit endpoints and bearer { model: moonshot.messages("kimi-k3"), path: "/anthropic/v1/messages" }, { model: moonshot.responses("kimi-k3"), path: "/v1/responses" }, ]) { - const request = LLM.request({ model: item.model, prompt: "Hello" }) + const request = ai.request({ model: item.model, prompt: "Hello" }) const compiled = yield* compileRequest(request) const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString() expect(url).toBe(`https://api.moonshot.ai${item.path}`) @@ -54,7 +54,7 @@ it.effect("Moonshot preserves default reasoning across model IDs on custom endpo }) for (const id of ["kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3", "future-model"]) { const model = moonshot.chat(id) - const request = LLM.request({ model, prompt: "Hello", generation: { maxTokens: 100 } }) + const request = ai.request({ model, prompt: "Hello", generation: { maxTokens: 100 } }) const compiled = yield* compileRequest(request) expect(compiled.body).toMatchObject({ model: id, max_tokens: 100, stream: true }) expect(compiled.body.max_completion_tokens).toBeUndefined() @@ -77,7 +77,7 @@ it.effect("Moonshot preserves default reasoning across model IDs on custom endpo it.effect("Moonshot honors fallback credentials and final HTTP body overlays", () => LLMClient.generate( - LLM.request({ + ai.request({ model: Moonshot.configure({ providerOptions: { reasoningEffort: "high" } }).responses("kimi-k3"), prompt: "Hello", providerOptions: { reasoningEffort: "low" }, @@ -104,20 +104,20 @@ it.effect("Moonshot lowers API-specific reasoning options without adding unrelat const moonshot = Moonshot.configure({ apiKey: "fixture" }) for (const thinking of [{ type: "disabled" }, { type: "enabled", keep: "all" }, { type: "enabled", keep: null }]) { const result = yield* compileRequest( - LLM.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking } }), + ai.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking } }), ) expect(result.body.thinking).toEqual(thinking) expect(result.body.reasoning_effort).toBeUndefined() } for (const effort of ["low", "high", "max", "future-effort"]) { const chat = yield* compileRequest( - LLM.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: effort } }), + ai.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: effort } }), ) const messages = yield* compileRequest( - LLM.request({ model: moonshot.messages("kimi-k3"), providerOptions: { effort } }), + ai.request({ model: moonshot.messages("kimi-k3"), providerOptions: { effort } }), ) const responses = yield* compileRequest( - LLM.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: effort } }), + ai.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: effort } }), ) expect(chat.body.reasoning_effort).toBe(effort) expect(messages.body.output_config).toEqual({ effort }) @@ -135,7 +135,7 @@ it.effect("Moonshot validates native Chat thinking options", () => .chat("kimi-k2.6") .route.with({ providerOptions: { thinking: { type: "enabled", keep: 42 } } }) .model({ id: "kimi-k2.6" }) - const failure = yield* compileRequest(LLM.request({ model: invalid })).pipe(Effect.flip) + const failure = yield* compileRequest(ai.request({ model: invalid })).pipe(Effect.flip) expect(failure.reason._tag).toBe("InvalidRequest") expect(failure.message).toContain("keep") }), @@ -144,7 +144,7 @@ it.effect("Moonshot validates native Chat thinking options", () => it.effect("Moonshot retains unsigned and signed Messages reasoning and projects tool schemas", () => Effect.gen(function* () { const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model: Moonshot.configure({ apiKey: "fixture" }).messages("kimi-k3"), messages: [ Message.user("Hello"), @@ -195,7 +195,7 @@ it.effect("Moonshot retains unsigned and signed Messages reasoning and projects it.effect("Moonshot Responses uses HTTP even with a WebSocket executor", () => LLMClient.generate( - LLM.request({ model: Moonshot.configure({ apiKey: "fixture" }).responses("kimi-k3"), prompt: "Hello" }), + ai.request({ model: Moonshot.configure({ apiKey: "fixture" }).responses("kimi-k3"), prompt: "Hello" }), { webSocket: { execute: () => Effect.die("Unexpected WebSocket request") }, }, diff --git a/packages/ai/test/provider/native-providers.test.ts b/packages/ai/test/provider/native-providers.test.ts index 49e182b7918b..cb5a45c607a9 100644 --- a/packages/ai/test/provider/native-providers.test.ts +++ b/packages/ai/test/provider/native-providers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { ConfigProvider, Effect } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, Message, ToolDefinition } from "../../src/effect.js" +import { ai, Message, ToolDefinition } from "../../src/effect.js" import { AmazonBedrock, AmazonBedrockMantle, @@ -113,9 +113,9 @@ describe("native OpenAI-compatible providers", () => { generation: { maxTokens: 48 }, tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], } - const native = yield* compileRequest(LLM.request({ ...input, model: selected })) + const native = yield* compileRequest(ai.request({ ...input, model: selected })) const generic = yield* compileRequest( - LLM.request({ + ai.request({ ...input, model: OpenAICompatible.configure({ ...settings, provider: provider.id }).model("test-model"), }), @@ -138,7 +138,7 @@ describe("native OpenAI-compatible providers", () => { it.effect("applies native provider request defaults even with a custom gateway URL", () => Effect.gen(function* () { const together = yield* compileRequest( - LLM.request({ + ai.request({ model: TogetherAI.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("llama"), prompt: "Use a tool.", generation: { maxTokens: 32 }, @@ -159,7 +159,7 @@ describe("native OpenAI-compatible providers", () => { expect(together.body.tools?.[0]?.function).not.toHaveProperty("strict") const cerebras = yield* compileRequest( - LLM.request({ + ai.request({ model: Cerebras.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("qwen"), generation: { maxTokens: 48 }, messages: [ @@ -272,7 +272,7 @@ describe("native OpenAI-compatible providers", () => { ] yield* Effect.forEach(scenarios, (scenario) => - LLM.generate(LLM.request({ model: scenario.model, prompt: "Say hello." })).pipe( + ai.generate(ai.request({ model: scenario.model, prompt: "Say hello." })).pipe( Effect.provide( dynamicResponse((input) => Effect.gen(function* () { diff --git a/packages/ai/test/provider/open-responses-errors.test.ts b/packages/ai/test/provider/open-responses-errors.test.ts index d7cf2a5612a4..dc48e9030095 100644 --- a/packages/ai/test/provider/open-responses-errors.test.ts +++ b/packages/ai/test/provider/open-responses-errors.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMClient } from "../../src/effect.js" +import { ai, LLMClient } from "../../src/effect.js" import { OpenResponses } from "../../src/protocols/open-responses.js" import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js" import { WebSocketTransport } from "../../src/route.js" @@ -100,7 +100,7 @@ for (const model of [ open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }), }) for (const options of [{ webSocket }, {}]) { - const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe( + const error = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" }), options).pipe( Effect.provide(fixedResponse(sseEvents(raw))), Effect.flip, ) @@ -128,7 +128,7 @@ it.effect("retains classification and original error bodies through Meta and gen "example-model", ), ]) { - const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const error = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))), Effect.flip, ) diff --git a/packages/ai/test/provider/open-responses-finals.test.ts b/packages/ai/test/provider/open-responses-finals.test.ts index 38accf349af4..b7b9850ed94d 100644 --- a/packages/ai/test/provider/open-responses-finals.test.ts +++ b/packages/ai/test/provider/open-responses-finals.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent } from "../../src/effect.js" +import { ai, LLMEvent } from "../../src/effect.js" import { OpenResponses } from "../../src/protocols/open-responses.js" import { configure } from "../../src/providers/openai-compatible-responses.js" import { LLMClient } from "../../src/route.js" @@ -8,7 +8,7 @@ import { it } from "../lib/effect.js" import { fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" -const request = LLM.request({ +const request = ai.request({ model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"), prompt: "Respond.", }) diff --git a/packages/ai/test/provider/open-responses-lifecycle.test.ts b/packages/ai/test/provider/open-responses-lifecycle.test.ts index fc12fd705e85..8da94aaa7802 100644 --- a/packages/ai/test/provider/open-responses-lifecycle.test.ts +++ b/packages/ai/test/provider/open-responses-lifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { LLM, LLMEvent } from "../../src/effect.js" +import { ai, LLMEvent } from "../../src/effect.js" import { OpenResponses } from "../../src/protocols/open-responses.js" import { configure } from "../../src/providers/openai-compatible-responses.js" import { LLMClient } from "../../src/route.js" @@ -8,7 +8,7 @@ import { it } from "../lib/effect.js" import { fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" -const request = LLM.request({ +const request = ai.request({ model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"), prompt: "Respond.", }) diff --git a/packages/ai/test/provider/open-responses-replay.test.ts b/packages/ai/test/provider/open-responses-replay.test.ts index fe439c634fd3..71b1bb4739c7 100644 --- a/packages/ai/test/provider/open-responses-replay.test.ts +++ b/packages/ai/test/provider/open-responses-replay.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, Message, ToolCallPart } from "../../src/effect.js" +import { ai, LLMEvent, Message, ToolCallPart } from "../../src/effect.js" import { OpenAI } from "../../src/providers.js" import { configure } from "../../src/providers/openai-compatible-responses.js" import { compileRequest, LLMClient } from "../../src/route/client.js" @@ -18,7 +18,7 @@ for (const model of [ const uri = "data:image/png;base64,AAECAw==" const value = { error: { type: "tool.execution", message: "snapshot failed" }, content: [] } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), @@ -58,7 +58,7 @@ for (const model of [ it.effect("marks assistant text completed regardless of stored status", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ ...[undefined, "in_progress", "incomplete", "completed"].map((status, index) => @@ -113,7 +113,7 @@ for (const model of [ it.effect("replays truncated text as completed while retaining the response finish reason", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Respond" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -142,7 +142,7 @@ for (const model of [ expect(response.finishReason.normalized).toBe("length") expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1) const prepared = yield* compileRequest( - LLM.request({ model, messages: [response.message, Message.user("Continue")] }), + ai.request({ model, messages: [response.message, Message.user("Continue")] }), ) expect(prepared.body.input).toEqual([ { diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts index 7e3660ab463a..a061536bde02 100644 --- a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts +++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMResponse, LanguageModel } from "../../src/effect.js" +import { ai, LanguageModel, LLMEvent, LLMResponse } from "../../src/effect.js" import * as OpenAICompatible from "../../src/providers/openai-compatible.js" import * as OpenRouter from "../../src/providers/openrouter.js" import { LLMClient } from "../../src/route.js" @@ -56,7 +56,7 @@ for (const item of cases) { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: item.model, system: "Think through the arithmetic, then reply with only the final integer.", prompt: "What is 173 multiplied by 219?", @@ -86,7 +86,7 @@ for (const item of cases) { ), ).toBe(true) - const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model: item.model, messages: [response.message] })) expect(replay.body.messages).toMatchObject([ { role: "assistant", diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index c6debd0f9dd1..3a4cc2e0607f 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -2,15 +2,15 @@ import { describe, expect } from "bun:test" import { Effect, Ref, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/http" import { - Media, - HttpOptions, - LLM, + ai, AIError, + HttpOptions, + LanguageModel, LLMEvent, LLMRequest, LLMResponse, + Media, Message, - LanguageModel, ToolCallPart, ToolDefinition, Usage, @@ -36,7 +36,7 @@ const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }) -const request = LLM.request({ +const request = ai.request({ id: "req_1", model, system: "You are concise.", @@ -67,7 +67,10 @@ describe("OpenAI Chat route", () => { it.effect("omits stream usage options when compatibility disables them", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: LanguageModel.update(model, { compatibility: { streamUsage: false } }), prompt: "Hello" }), + ai.request({ + model: LanguageModel.update(model, { compatibility: { streamUsage: false } }), + prompt: "Hello", + }), ) expect(prepared.body).not.toHaveProperty("stream_options") @@ -78,10 +81,14 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const compatible = OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "test" }) const omitted = yield* compileRequest( - LLM.request({ model: compatible.model("compatible-model"), prompt: "Hello", providerOptions: { store: true } }), + ai.request({ + model: compatible.model("compatible-model"), + prompt: "Hello", + providerOptions: { store: true }, + }), ) const optedIn = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(compatible.model("compatible-model"), { compatibility: { supportsStore: true }, }), @@ -89,7 +96,7 @@ describe("OpenAI Chat route", () => { }), ) const openaiHost = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatible.configure({ baseURL: "https://api.openai.com/v1", apiKey: "test" }).model( "gpt-4o-mini", ), @@ -97,7 +104,7 @@ describe("OpenAI Chat route", () => { }), ) const azure = yield* compileRequest( - LLM.request({ + ai.request({ model: Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat( "gpt-4o-mini", ), @@ -115,7 +122,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Before."), @@ -138,7 +145,7 @@ describe("OpenAI Chat route", () => { it.effect("omits empty and whitespace-only assistant messages", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Before."), @@ -160,7 +167,7 @@ describe("OpenAI Chat route", () => { it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -178,7 +185,7 @@ describe("OpenAI Chat route", () => { it.effect("concatenates assistant text parts without adding separators", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -196,7 +203,7 @@ describe("OpenAI Chat route", () => { it.effect("writes reasoning to a configured custom field on every assistant message", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), messages: [ Message.assistant([ @@ -222,7 +229,7 @@ describe("OpenAI Chat route", () => { it.effect("preserves observed reasoning fields when reasoning is required", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { requireReasoning: true } }), messages: [ Message.assistant([ @@ -248,7 +255,7 @@ describe("OpenAI Chat route", () => { it.effect("omits empty configured reasoning fields when reasoning is explicitly optional", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { reasoningField: "reasoning_text", requireReasoning: false }, }), @@ -272,7 +279,7 @@ describe("OpenAI Chat route", () => { it.effect("rejects reasoning fields that conflict with assistant message fields", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { reasoningField: "content" } }), messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], }), @@ -285,7 +292,7 @@ describe("OpenAI Chat route", () => { it.effect("maps OpenAI provider options to Chat options", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), prompt: "think", providerOptions: { reasoningEffort: "max" }, @@ -300,7 +307,7 @@ describe("OpenAI Chat route", () => { it.effect("keeps valid Chat options when a sibling option is malformed", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), prompt: "think", providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 }, @@ -315,7 +322,7 @@ describe("OpenAI Chat route", () => { it.effect("maps shared OpenAI options to Chat fields", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-5-nano"), prompt: "Hello", tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], @@ -366,7 +373,7 @@ describe("OpenAI Chat route", () => { it.effect("maps the request prompt cache key when the compatibility flag is set", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAIChat.route .with({ endpoint: { baseURL: "https://api.compatible.test/v1" }, auth: Auth.bearer("test") }) .model({ id: "compatible-model", compatibility: { supportsPromptCacheKey: true } }), @@ -382,7 +389,7 @@ describe("OpenAI Chat route", () => { it.effect("sends the cache TTL as a request option when no markers carry it", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "test" }).model( "compatible-model", ), @@ -398,7 +405,7 @@ describe("OpenAI Chat route", () => { it.effect("omits the prompt cache key without the compatibility flag", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "test", @@ -415,7 +422,7 @@ describe("OpenAI Chat route", () => { it.effect("omits the prompt cache key when caching is disabled", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", promptCacheKey: "session_123", @@ -429,7 +436,7 @@ describe("OpenAI Chat route", () => { it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () => LLMClient.generate( - LLM.request({ + ai.request({ model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"), prompt: "Hello", promptCacheKey: "session_123", @@ -455,7 +462,7 @@ describe("OpenAI Chat route", () => { it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "think", providerOptions: { reasoningEffort: "experimental" }, @@ -551,7 +558,7 @@ describe("OpenAI Chat route", () => { it.effect("prepares assistant tool-call and tool-result messages", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result", model, messages: [ @@ -590,7 +597,7 @@ describe("OpenAI Chat route", () => { it.effect("replays Gemini thought signatures as tool call extra content", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Weather in Paris and Tokyo?"), @@ -637,7 +644,7 @@ describe("OpenAI Chat route", () => { yield* Effect.forEach(models, (selected) => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: selected, messages: [ Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]), @@ -665,7 +672,7 @@ describe("OpenAI Chat route", () => { { type: "file" as const, uri: image, mime: "image/png", name: "local.png" }, ] const success = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), @@ -674,7 +681,7 @@ describe("OpenAI Chat route", () => { }), ) const failure = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), @@ -705,7 +712,7 @@ describe("OpenAI Chat route", () => { const uri = "data:image/png;base64,AAECAw==" const value = { error: { type: "tool.execution", message: "snapshot failed" }, content: [] } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), @@ -744,7 +751,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const error = { error: { type: "unknown", message: "Tool execution interrupted" } } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), @@ -764,7 +771,7 @@ describe("OpenAI Chat route", () => { it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]), @@ -808,7 +815,7 @@ describe("OpenAI Chat route", () => { it.effect("bridges image tool results before their synthetic user message when required", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }), messages: [ Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]), @@ -832,7 +839,7 @@ describe("OpenAI Chat route", () => { it.effect("orders parallel tool responses before one aggregated vision message", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -882,7 +889,7 @@ describe("OpenAI Chat route", () => { it.effect("aggregates consecutive tool images with a following system update", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.tool({ @@ -923,7 +930,7 @@ describe("OpenAI Chat route", () => { it.effect("appends system updates without replacing multipart user content", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user({ type: "media", media: Media.base64("AAEC", "image/png") }), @@ -946,7 +953,7 @@ describe("OpenAI Chat route", () => { it.effect("passes encoded image media through without local validation", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user([ @@ -974,7 +981,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const urls = ["https://example.com/image.png?size=64#preview", "http://example.com/image.jpg"] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: urls.map((url) => Message.media(Media.url(url, { mediaType: "image/png" }))), }), @@ -989,7 +996,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const url = "https://example.com/tool-image.png?version=2" const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Describe the image."), @@ -1021,7 +1028,7 @@ describe("OpenAI Chat route", () => { it.effect("rejects non-image media that cannot be lowered", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.user({ type: "media", media: Media.base64("AAECAw==", "audio/mpeg") })], }), @@ -1033,7 +1040,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers inline PDFs as file parts", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user([ @@ -1060,7 +1067,7 @@ describe("OpenAI Chat route", () => { it.effect("moves PDFs from tool results into a follow-up user message", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Read the report."), @@ -1099,7 +1106,7 @@ describe("OpenAI Chat route", () => { it.effect("requires inline data for PDF files", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user({ @@ -1116,7 +1123,7 @@ describe("OpenAI Chat route", () => { it.effect("prepares raw and data URL image media as vision input", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_media", model, messages: [ @@ -1143,7 +1150,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers reasoning-only assistant history", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_reasoning", model, messages: [Message.assistant({ type: "reasoning", text: "hidden" })], @@ -1251,7 +1258,7 @@ describe("OpenAI Chat route", () => { expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" }) expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }]) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }]) }), ) @@ -1323,7 +1330,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: field }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }]) } }), @@ -1391,7 +1398,7 @@ describe("OpenAI Chat route", () => { vendor: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, }) - const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model: selected, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details }, ]) @@ -1424,7 +1431,7 @@ describe("OpenAI Chat route", () => { deepseek: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, }) - const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model: selected, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }]) }), ) @@ -1449,7 +1456,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "vendor_reasoning" }, }) - const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model: custom, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }]) }), ) @@ -1492,7 +1499,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning", reasoningDetails: details }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages[0]).toEqual({ role: "assistant", content: null, @@ -1533,7 +1540,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: details }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }]) }), ) @@ -1560,7 +1567,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning", reasoningDetails: [] }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: [] }, ]) @@ -1607,7 +1614,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning_content", reasoningDetails: stored }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages[0]).toEqual({ role: "assistant", content: null, @@ -1639,7 +1646,7 @@ describe("OpenAI Chat route", () => { expect(response.reasoning).toBe("Plan tools") - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", @@ -1658,7 +1665,7 @@ describe("OpenAI Chat route", () => { it.effect("strips the streaming index from previously stored Kimi details", () => Effect.gen(function* () { const replay = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -1739,7 +1746,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: merged }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: merged }]) }), ) @@ -1837,7 +1844,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: [] }, }) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }]) }), ) @@ -1898,7 +1905,7 @@ describe("OpenAI Chat route", () => { response.events.findIndex(LLMEvent.is.textStart), ) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged }, ]) @@ -1925,7 +1932,7 @@ describe("OpenAI Chat route", () => { expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }]) }), ) @@ -1956,7 +1963,7 @@ describe("OpenAI Chat route", () => { const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 } const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 } const replay = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -1985,7 +1992,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 } const replay = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -2010,7 +2017,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }] const replay = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.make({ diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index 9b001a0bd900..45287f2b7e03 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, LLMEvent, LLMRequest, Media, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Media, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/effect.js" import { Auth, LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat.js" @@ -20,7 +20,7 @@ const model = OpenAICompatibleChat.route }) .model({ id: "deepseek-chat" }) -const request = LLM.request({ +const request = ai.request({ id: "req_1", model, system: "You are concise.", @@ -46,7 +46,7 @@ describe("OpenAI-compatible Chat route", () => { const inline = "data:video/mp4;base64,AAEC" const url = "https://example.com/clip.mp4" const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatibleChat.route .with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } }) .model({ id: "video-model" }), @@ -143,7 +143,7 @@ describe("OpenAI-compatible Chat route", () => { Effect.gen(function* () { const prepare = (cacheFormat: "anthropic" | "alibaba" | undefined) => compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatibleChat.route .with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } }) .model({ id: "claude-sonnet-5", compatibility: cacheFormat === undefined ? undefined : { cacheFormat } }), @@ -182,7 +182,7 @@ describe("OpenAI-compatible Chat route", () => { .with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } }) .model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } }) const prepared = yield* compileRequest( - LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }), + ai.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }), ) expect(prepared.body).toMatchObject({ max_completion_tokens: 20 }) @@ -194,7 +194,7 @@ describe("OpenAI-compatible Chat route", () => { Effect.gen(function* () { const prepare = (provider: string, baseURL: string, id: string) => compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }), prompt: "Use a tool.", tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })], @@ -216,7 +216,7 @@ describe("OpenAI-compatible Chat route", () => { it.effect("matches AI SDK compatible tool request body fixture", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_parity", model, tools: [ @@ -288,7 +288,7 @@ describe("OpenAI-compatible Chat route", () => { yield* Effect.forEach(cases, (item) => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAICompatibleChat.route .with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } }) .model({ id: item.model }), @@ -330,7 +330,7 @@ describe("OpenAI-compatible Chat route", () => { compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined, }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: selected, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), @@ -379,7 +379,7 @@ describe("OpenAI-compatible Chat route", () => { .with({ provider: item.provider, endpoint: { baseURL: item.baseURL } }) .model({ id: item.id, compatibility: "compatibility" in item ? item.compatibility : undefined }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: selected, messages: [ Message.assistant("Hello"), @@ -683,7 +683,7 @@ describe("OpenAI-compatible Chat route", () => { expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan( response.events.findIndex(LLMEvent.is.toolCall), ) - const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(replay.body.messages[0]).toMatchObject({ role: "assistant", content: "Hello world (refusal)", diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index c1dfd62ebd13..85234e6c70f0 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/effect.js" +import { ai, LLMEvent, Media, Message, ToolDefinition } from "../../src/effect.js" import { configure } from "../../src/providers/openai-compatible-responses.js" import { OpenAI } from "../../src/providers.js" import { OpenResponses } from "../../src/protocols/open-responses.js" @@ -25,7 +25,7 @@ describe("Open Responses-compatible route", () => { provider: "example", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: "You are concise.", prompt: "Say hello.", @@ -64,7 +64,7 @@ describe("Open Responses-compatible route", () => { provider: "example", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ model, prompt: "Say hello.", providerOptions: { store: true, include: [] } }), + ai.request({ model, prompt: "Say hello.", providerOptions: { store: true, include: [] } }), ) expect(prepared.body.store).toBe(true) @@ -80,7 +80,7 @@ describe("Open Responses-compatible route", () => { provider: "example", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, system: "Initial instructions.", messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")], @@ -104,7 +104,7 @@ describe("Open Responses-compatible route", () => { provider: "example", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.user("Before."), Message.user([]), Message.user("After.")], }), @@ -126,7 +126,7 @@ describe("Open Responses-compatible route", () => { }).model("example-model") const pdf = "data:application/pdf;base64,JVBERi0xLjQ=" const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user([{ type: "media", media: Media.fromDataUrl(pdf), filename: "input.pdf" }]), @@ -164,7 +164,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", }).model("example-model") const error = yield* compileRequest( - LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }), + ai.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }), ).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") @@ -178,7 +178,7 @@ describe("Open Responses-compatible route", () => { "example-model", ) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, tools: [ { @@ -222,7 +222,7 @@ describe("Open Responses-compatible route", () => { "example-model", ) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }), @@ -245,7 +245,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Read the file.", tools: [ @@ -279,7 +279,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -350,7 +350,7 @@ describe("Open Responses-compatible route", () => { { type: "file_search_call", id: "fs_1", queries: "not-an-array" }, ] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: items.map((item) => Message.assistant({ @@ -380,7 +380,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -405,7 +405,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -427,7 +427,7 @@ describe("Open Responses-compatible route", () => { ) describe("stream validation", () => { - const request = LLM.request({ + const request = ai.request({ model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"), prompt: "Respond.", }) @@ -774,7 +774,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", provider: "example", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Look it up." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -815,7 +815,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Think it through." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -848,7 +848,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Think it through." })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -880,7 +880,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant({ @@ -912,7 +912,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", provider: "example", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Unsafe request" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -949,7 +949,7 @@ describe("Open Responses-compatible route", () => { }, ]) - const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const prepared = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(prepared.body.input).toEqual([ { type: "message", @@ -982,7 +982,7 @@ describe("Open Responses-compatible route", () => { }, }).model("example-model") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Think.", generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 }, @@ -1019,7 +1019,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", provider: "example", }).model("example-model") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Search." })).pipe( Effect.provide( fixedResponse( sseEvents( diff --git a/packages/ai/test/provider/openai-images.recorded.test.ts b/packages/ai/test/provider/openai-images.recorded.test.ts index 4a851ab28171..d3e4ffc0895b 100644 --- a/packages/ai/test/provider/openai-images.recorded.test.ts +++ b/packages/ai/test/provider/openai-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { Image, ImageEvent, Media } from "../../src/effect.js" +import { ai, ImageEvent, Media } from "../../src/effect.js" import { OpenAI } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -19,7 +19,7 @@ const recorded = recordedTests({ describe("OpenAI Images recorded", () => { recorded.effect("generates an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "A simple flat black circle centered on a plain white background.", aspect: "1:1", @@ -45,7 +45,7 @@ describe("OpenAI Images recorded", () => { }, () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "Keep the simple shape and change it from black to bright green.", images: [ @@ -65,13 +65,15 @@ describe("OpenAI Images recorded", () => { recorded.effect("streams a partial image before the final image", () => Effect.gen(function* () { const events = Array.from( - yield* Image.stream({ - model, - prompt: "A simple flat black circle centered on a plain white background.", - aspect: "1:1", - resolution: "1K", - providerOptions: { outputFormat: "jpeg", quality: "low", outputCompression: 10, partialImages: 1 }, - }).pipe(Stream.runCollect), + yield* ai.image + .stream({ + model, + prompt: "A simple flat black circle centered on a plain white background.", + aspect: "1:1", + resolution: "1K", + providerOptions: { outputFormat: "jpeg", quality: "low", outputCompression: 10, partialImages: 1 }, + }) + .pipe(Stream.runCollect), ) expect(events.map((event) => event.type)).toEqual(["image-partial", "image", "finish"]) diff --git a/packages/ai/test/provider/openai-responses-cache.recorded.test.ts b/packages/ai/test/provider/openai-responses-cache.recorded.test.ts index f83c43d99f65..a0f38369393d 100644 --- a/packages/ai/test/provider/openai-responses-cache.recorded.test.ts +++ b/packages/ai/test/provider/openai-responses-cache.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import * as OpenAI from "../../src/providers/openai.js" import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios.js" @@ -14,7 +14,7 @@ const model = OpenAI.configure({ // `CacheHint` is a no-op for the wire body. The stable signal is the // `prompt_cache_key` routing hint, which keeps repeated calls on the same shard // so cache hits are observable. -const cacheRequest = LLM.request({ +const cacheRequest = ai.request({ id: "recorded_openai_responses_cache", model, system: LARGE_CACHEABLE_SYSTEM, diff --git a/packages/ai/test/provider/openai-responses-images.recorded.test.ts b/packages/ai/test/provider/openai-responses-images.recorded.test.ts index 2b8b756564e3..e832f35b211d 100644 --- a/packages/ai/test/provider/openai-responses-images.recorded.test.ts +++ b/packages/ai/test/provider/openai-responses-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, Message } from "../../src/effect.js" +import { ai, LLMEvent, Message } from "../../src/effect.js" import { OpenAI } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" @@ -29,8 +29,8 @@ describe("OpenAI Responses image generation recorded", () => { partialImages: 0, }), ] - const response = yield* LLM.generate( - LLM.request({ + const response = yield* ai.generate( + ai.request({ model: openai.responses("gpt-5-mini"), messages: [initial], tools, @@ -49,8 +49,8 @@ describe("OpenAI Responses image generation recorded", () => { expect(result.result.value[0].mime).toBe("image/jpeg") expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true) - const edited = yield* LLM.generate( - LLM.request({ + const edited = yield* ai.generate( + ai.request({ model: openai.responses("gpt-5-mini"), messages: [initial, response.message, Message.user("Now make the triangle blue.")], tools, diff --git a/packages/ai/test/provider/openai-responses-websocket.recorded.test.ts b/packages/ai/test/provider/openai-responses-websocket.recorded.test.ts index b8015dc33f38..fc1937346a3d 100644 --- a/packages/ai/test/provider/openai-responses-websocket.recorded.test.ts +++ b/packages/ai/test/provider/openai-responses-websocket.recorded.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" import { Socket } from "effect/socket" -import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/effect.js" +import { ai, LLMRequest, Message, ToolRuntime } from "../../src/effect.js" import { LLMClient, WebSocketTransport, @@ -115,7 +115,7 @@ describe("OpenAI Responses WebSocket recorded", () => { recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () => Effect.gen(function* () { const channel = yield* makeChannel - const request = LLM.request({ + const request = ai.request({ id: "recorded_openai_responses_websocket_tool", model, system: "Call get_weather once, then reply exactly: Paris is sunny.", @@ -153,7 +153,7 @@ describe("OpenAI Responses WebSocket recorded", () => { recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () => Effect.gen(function* () { const channel = yield* makeChannel - const request = LLM.request({ + const request = ai.request({ id: "recorded_openai_responses_websocket_reconnect", model, system: "Follow the user's exact reply instruction.", @@ -186,7 +186,7 @@ describe("OpenAI Responses WebSocket recorded", () => { recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () => Effect.gen(function* () { const channel = yield* makeChannel - const request = LLM.request({ + const request = ai.request({ id: "recorded_openai_responses_websocket_rejection", model, system: "Follow the user's exact reply instruction.", diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index cd323d212835..1b44de219813 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2,15 +2,15 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Logger, Ref, Schema, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/http" import { - Media, - LLM, + ai, AIError, HttpContext, HttpOptions, + LanguageModel, LLMEvent, LLMRequest, + Media, Message, - LanguageModel, ToolCallPart, ToolDefinition, ToolNamespace, @@ -114,7 +114,7 @@ const checkpoint = (observation: ChannelObservation) => { return observation.checkpoint } -const request = LLM.request({ +const request = ai.request({ id: "req_1", model, system: "You are concise.", @@ -158,7 +158,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers the hosted OpenAI image generation tool", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Show me a rooftop garden.", tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })], @@ -176,7 +176,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers tool namespaces without flattening leaf names", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Find a customer and their orders.", tools: [ @@ -221,7 +221,7 @@ describe("OpenAI Responses route", () => { it.effect("flattens nested levels within a native tool namespace", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, tools: [ { @@ -263,7 +263,7 @@ describe("OpenAI Responses route", () => { it.effect("defaults tool namespace descriptions", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, tools: [ { @@ -284,7 +284,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects invalid hosted image generation options locally", () => Effect.gen(function* () { const error = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Show me a rooftop garden.", tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })], @@ -441,7 +441,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers chronological system updates to developer messages in order", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")], }), @@ -519,7 +519,7 @@ describe("OpenAI Responses route", () => { }), }) const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test", @@ -579,7 +579,7 @@ describe("OpenAI Responses route", () => { ) const errors = yield* Effect.forEach(["late", "mismatch"], (prompt) => - LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe( + LLMClient.generate(ai.request({ model, prompt }), { webSocket }).pipe( Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), Effect.flip, ), @@ -617,7 +617,7 @@ describe("OpenAI Responses route", () => { "gpt-4.1-mini", ) - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "hi" }), { webSocket }).pipe( Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), ) expect(response.finishReason?.normalized).toBe("stop") @@ -1066,7 +1066,7 @@ describe("OpenAI Responses route", () => { const message = yield* Ref.make("") const body = yield* Ref.make("") const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"), prompt: "Say hello.", http: { @@ -1131,7 +1131,7 @@ describe("OpenAI Responses route", () => { const message = yield* Ref.make("") const body = yield* Ref.make("") yield* LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"), prompt: "Say \uD800hello \u{1F600}.", http: { body: { metadata: { source: "overlay\uDC00" } } }, @@ -1177,7 +1177,7 @@ describe("OpenAI Responses route", () => { RequestExecutor.Service, RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }), ) - const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), { + const response = yield* LLMClient.generate(ai.request({ model: xaiModel, prompt: "Say hello." }), { webSocket: { execute: (exchange) => Effect.gen(function* () { @@ -1227,7 +1227,7 @@ describe("OpenAI Responses route", () => { ] yield* Effect.forEach(cases, (item) => - LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), { + LLMClient.generate(ai.request({ model: item.model, prompt: "Say hello." }), { webSocket: { execute: (exchange) => Effect.gen(function* () { @@ -1288,7 +1288,7 @@ describe("OpenAI Responses route", () => { warnings.push(String(Array.isArray(entry.message) ? entry.message[0] : entry.message)) }) yield* Effect.forEach(cases, (item) => - LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), { + LLMClient.generate(ai.request({ model: item.model, prompt: "Say hello." }), { webSocket: { execute: () => Effect.die("unexpected WebSocket request") }, }).pipe( Effect.provide( @@ -1349,7 +1349,7 @@ describe("OpenAI Responses route", () => { }) yield* LLMClient.stream( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"), prompt: "Say hello.", }), @@ -1392,7 +1392,7 @@ describe("OpenAI Responses route", () => { const errors = yield* Effect.forEach(events, (event) => LLMClient.generate( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses( "gpt-4.1-mini", ), @@ -1465,7 +1465,7 @@ describe("OpenAI Responses route", () => { ) const errors = yield* Effect.forEach(["first", "second"], (prompt) => - LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe( + LLMClient.generate(ai.request({ model, prompt }), { webSocket }).pipe( Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), Effect.flip, ), @@ -1590,7 +1590,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares function call and function output input items", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result", model, messages: [ @@ -1628,7 +1628,7 @@ describe("OpenAI Responses route", () => { structured: {}, } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]), @@ -1651,7 +1651,7 @@ describe("OpenAI Responses route", () => { const uri = "data:image/png;base64,AAECAw==" const value = { error: { type: "tool.execution", message: "snapshot failed" }, content: [] } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "snapshot", input: {} })]), @@ -1687,7 +1687,7 @@ describe("OpenAI Responses route", () => { it.effect("keeps primitive tool errors as plain text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), @@ -1703,7 +1703,7 @@ describe("OpenAI Responses route", () => { it.effect("keeps non-JSON tool errors as plain text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), @@ -1721,7 +1721,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers image tool-result content as structured input_image items", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_image", model, messages: [ @@ -1750,7 +1750,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers single-image tool-result content as structured input_image array", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_image_only", model, messages: [ @@ -1774,7 +1774,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers PDF tool-result content as structured input_file array", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_pdf", model, messages: [ @@ -1811,7 +1811,7 @@ describe("OpenAI Responses route", () => { const base64 = "A".repeat(8_125_844) const dataUrl = `data:application/pdf;base64,${base64}` const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_large_pdf", model, messages: [ @@ -1835,7 +1835,7 @@ describe("OpenAI Responses route", () => { it.effect("uses standard inline file encoding for xAI PDF tool results", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: xaiModel, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]), @@ -1869,7 +1869,7 @@ describe("OpenAI Responses route", () => { it.effect("passes non-image tool-result content through as an input file", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_tool_result_unsupported_media", model, messages: [ @@ -1893,7 +1893,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers remote tool-result media URLs without base64 wrapping", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]), @@ -1912,7 +1912,7 @@ describe("OpenAI Responses route", () => { expect(expectToolOutput(prepared.body).output).toEqual([ { type: "input_image", image_url: "https://example.com/image.png" }, - { type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" }, + { type: "input_file", file_url: "https://example.com/report.pdf" }, ]) }), ) @@ -1939,7 +1939,7 @@ describe("OpenAI Responses route", () => { .with({ provider: "custom", endpoint: { baseURL: "https://provider.example/v1" } }) .model({ id: "video-model" }) expect( - (yield* compileRequest(LLM.request({ model: custom, messages }))).body.input, + (yield* compileRequest(ai.request({ model: custom, messages }))).body.input, "unknown provider uses input_video for user and tool video", ).toMatchObject( expectedInput( @@ -1948,7 +1948,7 @@ describe("OpenAI Responses route", () => { ), ) expect( - (yield* compileRequest(LLM.request({ model: xaiModel, messages }))).body.input, + (yield* compileRequest(ai.request({ model: xaiModel, messages }))).body.input, "xAI uses input_file for user and tool video", ).toMatchObject( expectedInput( @@ -1958,7 +1958,7 @@ describe("OpenAI Responses route", () => { ) const meta = Meta.configure({ apiKey: "test" }).responses("muse-spark-1.3") - const metaRequest = LLM.request({ + const metaRequest = ai.request({ model: meta, messages: [ Message.assistant([ @@ -2060,12 +2060,12 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }) const options = yield* compileRequest( - LLM.request({ model: model.model("gpt-5.6"), prompt: "hi", cache: { ttl: "30m" } }), + ai.request({ model: model.model("gpt-5.6"), prompt: "hi", cache: { ttl: "30m" } }), ) const retention = yield* compileRequest( - LLM.request({ model: model.model("gpt-5.5"), prompt: "hi", cache: { ttl: "24h" } }), + ai.request({ model: model.model("gpt-5.5"), prompt: "hi", cache: { ttl: "24h" } }), ) - const unset = yield* compileRequest(LLM.request({ model: model.model("gpt-5.6"), prompt: "hi" })) + const unset = yield* compileRequest(ai.request({ model: model.model("gpt-5.6"), prompt: "hi" })) expect(options.body).toMatchObject({ prompt_cache_options: { ttl: "30m" } }) expect(options.body).not.toHaveProperty("prompt_cache_retention") @@ -2079,7 +2079,7 @@ describe("OpenAI Responses route", () => { it.effect("maps OpenAI provider options to Responses options", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), prompt: "think", promptCacheKey: "session_123", @@ -2133,7 +2133,7 @@ describe("OpenAI Responses route", () => { it.effect("drops a malformed provider option without discarding its siblings", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "hi", providerOptions: { @@ -2157,7 +2157,7 @@ describe("OpenAI Responses route", () => { it.effect("accepts the full ResponseIncludable union", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "hi", providerOptions: { @@ -2177,7 +2177,7 @@ describe("OpenAI Responses route", () => { it.effect("passes forward-compatible includable values through", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "hi", providerOptions: { include: ["reasoning.encrypted_content", "bogus.thing"] }, @@ -2190,7 +2190,7 @@ describe("OpenAI Responses route", () => { it.effect("treats an explicit empty include as no include at all", () => Effect.gen(function* () { - const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { include: [] } })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "hi", providerOptions: { include: [] } })) expect(prepared.body.include).toBeUndefined() }), @@ -2199,7 +2199,7 @@ describe("OpenAI Responses route", () => { it.effect("passes an unknown includable value through", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model, prompt: "hi", providerOptions: { include: ["bogus.thing"] } }), + ai.request({ model, prompt: "hi", providerOptions: { include: ["bogus.thing"] } }), ) expect(prepared.body.include).toEqual(["bogus.thing"]) @@ -2208,7 +2208,7 @@ describe("OpenAI Responses route", () => { it.effect("requests encrypted reasoning by default", () => Effect.gen(function* () { - const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { store: false } })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "hi", providerOptions: { store: false } })) expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) }), @@ -2221,7 +2221,7 @@ describe("OpenAI Responses route", () => { // turn cannot replay reasoning state, so the facade also opts into // `reasoning.encrypted_content` automatically. const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), prompt: "hi", }), @@ -2236,7 +2236,7 @@ describe("OpenAI Responses route", () => { it.effect("lets callers opt out of the GPT-5 default include", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), prompt: "hi", providerOptions: { include: [] }, @@ -2250,7 +2250,7 @@ describe("OpenAI Responses route", () => { it.effect("maps the request prompt cache key", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test", @@ -2267,7 +2267,7 @@ describe("OpenAI Responses route", () => { it.effect("omits the prompt cache key when caching is disabled", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", promptCacheKey: "request_cache", @@ -2282,7 +2282,7 @@ describe("OpenAI Responses route", () => { it.effect("omits the prompt cache key when compatibility disables it", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }), prompt: "Hello", promptCacheKey: "request_cache", @@ -2292,7 +2292,7 @@ describe("OpenAI Responses route", () => { expect(prepared.body).not.toHaveProperty("prompt_cache_key") expect(model.compatibility?.supportsPromptCacheKey).toBeUndefined() expect( - (yield* compileRequest(LLM.request({ model, prompt: "Hello", promptCacheKey: "request_cache" }))).body, + (yield* compileRequest(ai.request({ model, prompt: "Hello", promptCacheKey: "request_cache" }))).body, ).toHaveProperty("prompt_cache_key", "request_cache") }), ) @@ -2453,7 +2453,7 @@ describe("OpenAI Responses route", () => { }, ]) - const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const prepared = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(prepared.body.input).toEqual([ { type: "message", @@ -2536,7 +2536,7 @@ describe("OpenAI Responses route", () => { }, ]) - const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const prepared = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(prepared.body.input).toEqual([ { type: "message", @@ -2677,7 +2677,7 @@ describe("OpenAI Responses route", () => { ]) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ response.message, @@ -3518,7 +3518,7 @@ describe("OpenAI Responses route", () => { ]) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [response.message], providerOptions: { store: false, include: ["reasoning.encrypted_content"] }, @@ -3604,7 +3604,7 @@ describe("OpenAI Responses route", () => { it.effect("continues a stateless reasoning conversation", () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ id: "req_reasoning_continue", model, messages: [ @@ -3665,7 +3665,7 @@ describe("OpenAI Responses route", () => { it.effect("preserves assistant content order around reasoning items", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_reasoning_order", model, messages: [ @@ -3709,7 +3709,7 @@ describe("OpenAI Responses route", () => { it.effect("replays complete reasoning items when storage is enabled", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -3739,7 +3739,7 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const item = { type: "web_search_call", id: "ws_1", status: "completed" } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -3776,7 +3776,7 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const item = { type: "web_search_call", id: "ws_1", status: "completed" } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Search."), @@ -3819,7 +3819,7 @@ describe("OpenAI Responses route", () => { { type: "future_call", id: "future_1", status: "completed" }, ] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: items.map((item) => Message.assistant({ @@ -3846,7 +3846,7 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const item = { type: "web_search_call", id: "ws_1", status: "completed" } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: xaiModel, messages: [ Message.assistant([ @@ -3886,7 +3886,7 @@ describe("OpenAI Responses route", () => { it.effect("does not replay hosted tool items whose result id differs from provider metadata", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -3916,7 +3916,7 @@ describe("OpenAI Responses route", () => { it.effect("preserves provider-issued item ids and removes malformed ids without dropping items", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -4034,7 +4034,7 @@ describe("OpenAI Responses route", () => { ] } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))], providerOptions: { store: true }, @@ -4056,7 +4056,7 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const imageTool = OpenAI.imageGeneration({ action: "edit" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Generate a black triangle."), @@ -4097,7 +4097,7 @@ describe("OpenAI Responses route", () => { it.effect("preserves foreign hosted images as portable image content when storage is enabled", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: xaiModel, messages: [ Message.assistant([ @@ -4133,7 +4133,7 @@ describe("OpenAI Responses route", () => { it.effect("joins streamed summary blocks into one continuation reasoning item", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_multi_summary_continuation", model, messages: [ @@ -4171,7 +4171,7 @@ describe("OpenAI Responses route", () => { it.effect("replays stateless reasoning without encrypted state", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_reasoning_without_encrypted_state", model, messages: [ @@ -4215,7 +4215,7 @@ describe("OpenAI Responses route", () => { it.effect("drops empty reasoning items without summary or encrypted content", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_reasoning_empty_shell", model, messages: [ @@ -4339,7 +4339,7 @@ describe("OpenAI Responses route", () => { }, ]) - const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + const prepared = yield* compileRequest(ai.request({ model, messages: [response.message] })) expect(prepared.body.input[0]).toEqual({ type: "function_call", id: "fc_item_1", @@ -4881,7 +4881,7 @@ describe("OpenAI Responses route", () => { it.effect("replays assistant media parts as portable user image input", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Draw a cat"), @@ -4938,7 +4938,7 @@ describe("OpenAI Responses route", () => { }) const prepared = yield* Effect.forEach([false, true], (store) => - compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })), + compileRequest(ai.request({ model, messages: [response.message], providerOptions: { store } })), ) expect(prepared.map((request) => request.body.input)).toEqual([ [ @@ -4984,7 +4984,7 @@ describe("OpenAI Responses route", () => { }) const prepared = yield* compileRequest( - LLM.request({ model, messages: [response.message], providerOptions: { store: true } }), + ai.request({ model, messages: [response.message], providerOptions: { store: true } }), ) expect(prepared.body.input).toEqual([ { @@ -5058,7 +5058,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers user image and PDF content", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_media", model, messages: [ @@ -5090,7 +5090,7 @@ describe("OpenAI Responses route", () => { it.effect("uses standard inline file encoding for xAI user PDFs", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: xaiModel, messages: [ Message.user({ @@ -5121,7 +5121,7 @@ describe("OpenAI Responses route", () => { it.effect("passes non-image user media through as an input file", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ id: "req_media", model, messages: [Message.user({ type: "media", media: Media.base64("AAECAw==", "application/x-tar") })], @@ -5147,7 +5147,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers remote user media URLs without base64 wrapping", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user([ @@ -5168,7 +5168,7 @@ describe("OpenAI Responses route", () => { role: "user", content: [ { type: "input_image", image_url: "https://example.com/image.png" }, - { type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" }, + { type: "input_file", file_url: "https://example.com/report.pdf" }, ], }, ]) diff --git a/packages/ai/test/provider/openai-speech.recorded.test.ts b/packages/ai/test/provider/openai-speech.recorded.test.ts index e97b67304dd6..9651e31d3fec 100644 --- a/packages/ai/test/provider/openai-speech.recorded.test.ts +++ b/packages/ai/test/provider/openai-speech.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Speech } from "../../src/effect.js" +import { Speech } from "../../src/experimental.js" import { OpenAI } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TEXT, collectSpeech } from "./speech-recording.js" diff --git a/packages/ai/test/provider/openai-transcription.recorded.test.ts b/packages/ai/test/provider/openai-transcription.recorded.test.ts index a84c6bcfbc57..766c3b1b31dd 100644 --- a/packages/ai/test/provider/openai-transcription.recorded.test.ts +++ b/packages/ai/test/provider/openai-transcription.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { Transcription, TranscriptionEvent } from "../../src/effect.js" +import { Transcription, TranscriptionEvent } from "../../src/experimental.js" import { OpenAI } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { TRANSCRIPT, audio, audioRecording } from "./transcription-recording.js" diff --git a/packages/ai/test/provider/openrouter-images.recorded.test.ts b/packages/ai/test/provider/openrouter-images.recorded.test.ts index 88fbf26fbb35..383c53b54a25 100644 --- a/packages/ai/test/provider/openrouter-images.recorded.test.ts +++ b/packages/ai/test/provider/openrouter-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image, Media } from "../../src/effect.js" +import { ai, Media } from "../../src/effect.js" import { OpenRouter } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -21,7 +21,7 @@ describe("OpenRouter Images recorded", () => { "renders Gemini at the requested 2K tier", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openrouter.image("google/gemini-3.1-flash-image"), prompt: "A simple flat black circle centered on a plain white background.", aspect: "16:9", @@ -37,7 +37,7 @@ describe("OpenRouter Images recorded", () => { "renders FLUX 3 from an aspect ratio and tier", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openrouter.image("black-forest-labs/flux-3-image-klein"), prompt: "A simple flat black circle centered on a plain white background.", aspect: "16:9", @@ -54,7 +54,7 @@ describe("OpenRouter Images recorded", () => { "edits an image through input references", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: openrouter.image("openai/gpt-image-1-mini"), prompt: "Keep the simple shape and change it from black to bright green.", images: [ diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index 22eb27c4e8f8..9376b6583506 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -1,11 +1,11 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LanguageModel, LLM, Message } from "../../src/effect.js" +import { ai, CacheHint, LanguageModel, Message } from "../../src/effect.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import * as OpenRouter from "../../src/providers/openrouter.js" import { it } from "../lib/effect.js" -import { fixedResponse } from "../lib/http.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" describe("OpenRouter", () => { @@ -20,7 +20,7 @@ describe("OpenRouter", () => { }) expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("openrouter") expect(prepared.body).toMatchObject({ @@ -35,7 +35,7 @@ describe("OpenRouter", () => { it.effect("places default cache breakpoints on tools, system boundaries, and the conversation tail", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), system: [ { type: "text", text: "Base agent" }, @@ -71,7 +71,7 @@ describe("OpenRouter", () => { it.effect("places default cache breakpoints on OpenRouter latest-model aliases", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("~anthropic/claude-sonnet-latest"), system: "Base agent", tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }], @@ -90,7 +90,7 @@ describe("OpenRouter", () => { it.effect("skips the tool breakpoint for Qwen, which caches tools with the system prompt", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"), system: "Base agent", tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }], @@ -109,7 +109,7 @@ describe("OpenRouter", () => { it.effect("places the default Qwen conversation-tail breakpoint inside tool-result text", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"), messages: [ Message.user("Call the tool"), @@ -131,7 +131,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const model = OpenRouter.configure({ apiKey: "test-key" }).model("vendor/claude-proxy") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: LanguageModel.update(model, { compatibility: { cacheFormat: "anthropic" } }), system: "Base agent", tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }], @@ -152,7 +152,7 @@ describe("OpenRouter", () => { const openrouter = OpenRouter.configure({ apiKey: "test-key" }) const bodies = yield* Effect.forEach(["google/gemini-2.5-flash", "openai/gpt-5-mini"], (id) => compileRequest( - LLM.request({ + ai.request({ model: openrouter.model(id), system: "Base agent", tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }], @@ -174,7 +174,7 @@ describe("OpenRouter", () => { it.effect("lowers the native cache policy to OpenRouter cache controls", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), system: [ { type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttl: "1h" }) }, @@ -208,7 +208,7 @@ describe("OpenRouter", () => { it.effect("lowers manual assistant and tool-result cache hints", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ @@ -250,7 +250,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: "none", system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })), @@ -271,7 +271,7 @@ describe("OpenRouter", () => { it.effect("does not emit text cache markers on reasoning-only assistant messages", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: { messages: "latest-assistant" }, messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])], @@ -289,7 +289,7 @@ describe("OpenRouter", () => { it.effect("counts wrapped system-update markers once so all four default breakpoints survive", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), system: [ { type: "text", text: "Base agent" }, @@ -318,7 +318,7 @@ describe("OpenRouter", () => { it.effect("allows usage accounting to be disabled explicitly", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key", providerOptions: { usage: false }, @@ -336,7 +336,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const reasoning = (maxTokens: number | undefined, value: Record) => compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3.8-flash"), cache: "none", prompt: "Hello", @@ -356,7 +356,7 @@ describe("OpenRouter", () => { it.effect("applies OpenRouter payload options from the model helper", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key", providerOptions: { @@ -391,10 +391,50 @@ describe("OpenRouter", () => { }), ) + it.effect("sends OpenRouter routing options on the Responses route", () => + LLMClient.generate( + ai.request({ + model: OpenRouter.configure({ + apiKey: "test-key", + providerOptions: { + models: ["openai/gpt-5.4-mini"], + provider: { only: ["openai"], allow_fallbacks: false }, + plugins: [{ id: "response-healing" }], + debug: { echo_upstream_body: true }, + user: "user_123", + reasoningEffort: "low", + }, + }).responses("openai/gpt-5.4"), + prompt: "Hello", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.sync(() => { + expect(input.request.url).toBe("https://openrouter.ai/api/v1/responses") + expect(JSON.parse(input.text)).toMatchObject({ + model: "openai/gpt-5.4", + store: false, + reasoning: { effort: "low" }, + models: ["openai/gpt-5.4-mini"], + provider: { only: ["openai"], allow_fallbacks: false }, + plugins: [{ id: "response-healing" }], + debug: { echo_upstream_body: true }, + user: "user_123", + }) + return input.respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + it.effect("omits the prompt cache key when caching is disabled", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini"), prompt: "Hello", promptCacheKey: "session_123", @@ -421,7 +461,7 @@ describe("OpenRouter", () => { future_option: { enabled: true }, } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key", providerOptions: invalid, @@ -446,7 +486,7 @@ describe("OpenRouter", () => { it.effect("preserves the upstream provider finish reason", () => Effect.gen(function* () { const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -463,7 +503,7 @@ describe("OpenRouter", () => { it.effect("fails on a mid-stream provider error", () => Effect.gen(function* () { const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini") - const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + const error = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -488,7 +528,7 @@ describe("OpenRouter", () => { { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 }, ] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ @@ -524,7 +564,7 @@ describe("OpenRouter", () => { { type: "reasoning.encrypted", id: "state", data: "opaque" }, ] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ @@ -565,7 +605,7 @@ describe("OpenRouter", () => { { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" }, ] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ @@ -594,7 +634,7 @@ describe("OpenRouter", () => { it.effect("omits scalar reasoning without continuation details", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), cache: "none", messages: [Message.assistant({ type: "reasoning", text: "Thinking" })], diff --git a/packages/ai/test/provider/pdf.recorded.test.ts b/packages/ai/test/provider/pdf.recorded.test.ts index 2106ab76fe82..cece09c08670 100644 --- a/packages/ai/test/provider/pdf.recorded.test.ts +++ b/packages/ai/test/provider/pdf.recorded.test.ts @@ -1,9 +1,9 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMResponse, Message, ToolDefinition, type LanguageModel, Media } from "../../src/effect.js" +import { ai, type LanguageModel, LLMResponse, Media, Message, ToolDefinition } from "../../src/effect.js" import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" -import { Tool } from "../../src/tool.js" +import { tool } from "../../src/tool.js" import { runTools } from "../lib/tool-runtime.js" import { recordedTests } from "../recorded-test.js" @@ -89,7 +89,7 @@ const readPdf = ToolDefinition.make({ description: "Read the attached PDF.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }) -const readPdfRuntime = Tool.make({ +const readPdfRuntime = tool({ description: readPdf.description, parameters: Schema.Struct({ path: Schema.String }), success: Schema.String, @@ -124,7 +124,7 @@ describe("PDF recorded", () => { Effect.gen(function* () { expectCode( yield* LLMClient.generate( - LLM.request({ + ai.request({ id: `recorded_pdf_${target.id}_user_input`, model: target.model, cache: "none", @@ -154,7 +154,7 @@ describe("PDF recorded", () => { if (target.id === "gemini") { const events = Array.from( yield* runTools({ - request: LLM.request({ + request: ai.request({ id: "recorded_pdf_gemini_tool_result", model: target.model, system: @@ -173,7 +173,7 @@ describe("PDF recorded", () => { expectCode( yield* LLMClient.generate( - LLM.request({ + ai.request({ id: `recorded_pdf_${target.id}_tool_result`, model: target.model, system: "Read the PDF returned by the tool and follow the user's response format exactly.", diff --git a/packages/ai/test/provider/responses-routes.recorded.test.ts b/packages/ai/test/provider/responses-routes.recorded.test.ts index 203d6e78e120..7a142877f32d 100644 --- a/packages/ai/test/provider/responses-routes.recorded.test.ts +++ b/packages/ai/test/provider/responses-routes.recorded.test.ts @@ -1,14 +1,14 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, LLMEvent, toDefinitions } from "../../src/effect.js" +import { ai, LLMEvent, toDefinitions } from "../../src/effect.js" import { DigitalOcean, OpenRouter, Venice } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" -import { Tool } from "../../src/tool.js" +import { tool } from "../../src/tool.js" import { expectWeatherToolLoop, runWeatherToolLoop, weatherToolLoopRequest } from "../recorded-scenarios.js" import { recordedTests } from "../recorded-test.js" const run = toDefinitions({ - run: Tool.make({ + run: tool({ description: "Run a Python snippet.", parameters: Schema.Struct({ code: Schema.String }), success: Schema.String, @@ -48,7 +48,7 @@ for (const item of [ recorded.effect.with("streams text", { tags: ["text", "usage"] }, () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ model: item.model, prompt: "Reply exactly: OK", generation: { maxTokens: 64 } }), + ai.request({ model: item.model, prompt: "Reply exactly: OK", generation: { maxTokens: 64 } }), ) expect(response.text.trim()).toMatch(/^OK\.?$/) expect(response.events.some(LLMEvent.is.textDelta)).toBe(true) @@ -74,7 +74,7 @@ for (const item of [ recorded.effect.with("calls a freeform text tool", { tags: ["tool", "freeform"] }, () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: item.model, prompt: "Use the run tool to execute exactly this code: print(1)", tools: run, diff --git a/packages/ai/test/provider/runway-video.recorded.test.ts b/packages/ai/test/provider/runway-video.recorded.test.ts index e14cb4d31baa..44e558e50a01 100644 --- a/packages/ai/test/provider/runway-video.recorded.test.ts +++ b/packages/ai/test/provider/runway-video.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Video } from "../../src/effect.js" +import { Video } from "../../src/experimental.js" import { Runway } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { queuedPoll } from "./queued-recording.js" diff --git a/packages/ai/test/provider/speech-recording.ts b/packages/ai/test/provider/speech-recording.ts index 3b6a165b48c4..d5d4cc1bb9f7 100644 --- a/packages/ai/test/provider/speech-recording.ts +++ b/packages/ai/test/provider/speech-recording.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Stream } from "effect" -import { SpeechEvent } from "../../src/effect.js" +import { SpeechEvent } from "../../src/experimental.js" export const TEXT = "Hello from OpenCode." diff --git a/packages/ai/test/provider/venice.recorded.test.ts b/packages/ai/test/provider/venice.recorded.test.ts index 0fcae459c678..2e1b582a7311 100644 --- a/packages/ai/test/provider/venice.recorded.test.ts +++ b/packages/ai/test/provider/venice.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/effect.js" import { Venice } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -38,7 +38,7 @@ for (const item of [ }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: Venice.configure({ apiKey }).chat(item.model), system: "Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.", @@ -133,7 +133,7 @@ recorded.effect.with( { tags: ["tool", "parallel", "signature"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: Venice.configure({ apiKey }).chat("gemini-3-8-flash"), prompt: "Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.", @@ -205,7 +205,7 @@ recorded.effect.with( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: Venice.configure({ apiKey }).chat("qwen3-6-27b"), prompt: "What is 23 multiplied by 17 plus 9? Reply with just the number.", providerOptions: { reasoning: { enabled: false } }, @@ -221,7 +221,7 @@ recorded.effect.with( recorded.effect.with("surfaces Venice model errors", { tags: ["error"] }, () => Effect.gen(function* () { const result = yield* LLMClient.generate( - LLM.request({ model: Venice.configure({ apiKey }).chat("no-such-model-xyz"), prompt: "Hello" }), + ai.request({ model: Venice.configure({ apiKey }).chat("no-such-model-xyz"), prompt: "Hello" }), ).pipe(Effect.result) expect(result._tag).toBe("Failure") if (result._tag === "Failure") { diff --git a/packages/ai/test/provider/venice.test.ts b/packages/ai/test/provider/venice.test.ts index 0ec9f537ddf2..7c3f12d635ff 100644 --- a/packages/ai/test/provider/venice.test.ts +++ b/packages/ai/test/provider/venice.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { Auth, LLM, LLMClient, Message } from "../../src/effect.js" +import { ai, Auth, LLMClient, Message } from "../../src/effect.js" import { OpenAI, Venice } from "../../src/providers.js" import { compileRequest } from "../../src/route/client.js" import { Endpoint } from "../../src/route/endpoint.js" @@ -18,7 +18,7 @@ it.effect("Venice owns its endpoint, environment auth, aliases, and SDK-compatib Effect.gen(function* () { expect(Venice.provider.model).toBe(Venice.provider.chat) const selected = Venice.chat("qwen3-6-27b") - const request = LLM.request({ model: selected, prompt: "Hello", promptCacheKey: "session-123" }) + const request = ai.request({ model: selected, prompt: "Hello", promptCacheKey: "session-123" }) const compiled = yield* compileRequest(request) const url = Endpoint.render(selected.route.endpoint, { request, body: compiled.body }).toString() expect(url).toBe("https://api.venice.ai/api/v1/chat/completions") @@ -49,7 +49,7 @@ it.effect("Venice owns its endpoint, environment auth, aliases, and SDK-compatib it.effect("Venice lowers typed options, model defaults, and explicit reasoning overrides", () => Effect.gen(function* () { const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model: Venice.configure({ apiKey: "fixture", providerOptions: { reasoningEffort: "low" } }).model( "qwen3-6-27b", ), @@ -97,11 +97,10 @@ it.effect("Venice lowers typed options, model defaults, and explicit reasoning o expect(compiled.body.reasoning_effort).toBeUndefined() expect(compiled.body.max_tokens).toBeUndefined() expect( - (yield* compileRequest(LLM.request({ model, providerOptions: { reasoning: { enabled: false } } }))).body - .reasoning, + (yield* compileRequest(ai.request({ model, providerOptions: { reasoning: { enabled: false } } }))).body.reasoning, ).toEqual({ enabled: false }) expect( - (yield* compileRequest(LLM.request({ model, cache: "none", promptCacheKey: "session-123" }))).body + (yield* compileRequest(ai.request({ model, cache: "none", promptCacheKey: "session-123" }))).body .prompt_cache_key, ).toBeUndefined() }), @@ -112,7 +111,7 @@ it.effect("Venice hides encrypted trailers across every marker split while prese const marker = "__ENCRYPTED_REASONING__" for (const split of Array.from({ length: marker.length + 1 }, (_, index) => index)) { const raw = `Visible summary\n${marker}id=rs_123\nopaque-payload` - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -141,7 +140,7 @@ it.effect("Venice hides encrypted trailers across every marker split while prese .map((event) => event.text) .join(""), ).toBe("Visible summary\n") - const replay = yield* compileRequest(LLM.request({ model, messages: [Message.user("Hello"), response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [Message.user("Hello"), response.message] })) expect(replay.body.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -166,13 +165,13 @@ it.effect("Venice hides encrypted trailers across every marker split while prese it.effect("Venice preserves opaque-only reasoning and does not eat an incomplete marker prefix", () => Effect.gen(function* () { for (const scalar of ["__ENCRYPTED_REASONING__opaque", "normal _", "normal __ENCRYPTED_"]) { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( fixedResponse(sseEvents(frame({ reasoning_content: scalar }), frame({ content: "42" }, "stop"))), ), ) expect(response.reasoning).toBe(scalar.startsWith("__ENCRYPTED_REASONING__") ? "" : scalar) - const body = (yield* compileRequest(LLM.request({ model, messages: [response.message] }))).body + const body = (yield* compileRequest(ai.request({ model, messages: [response.message] }))).body expect(body.messages).toEqual(expect.arrayContaining([expect.objectContaining({ reasoning_content: scalar })])) } }), @@ -180,7 +179,7 @@ it.effect("Venice preserves opaque-only reasoning and does not eat an incomplete it.effect("Venice encrypted trailers do not change the base OpenAI Chat protocol", () => LLMClient.generate( - LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).chat("gpt-4o"), prompt: "Hello" }), + ai.request({ model: OpenAI.configure({ apiKey: "fixture" }).chat("gpt-4o"), prompt: "Hello" }), ).pipe( Effect.provide( fixedResponse( @@ -196,7 +195,7 @@ it.effect("Venice encrypted trailers do not change the base OpenAI Chat protocol it.effect("Venice keeps late Gemini signatures attached to the correct parallel call", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Lookup" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Lookup" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -215,7 +214,7 @@ it.effect("Venice keeps late Gemini signatures attached to the correct parallel expect(response.toolCalls).toHaveLength(2) expect(response.toolCalls[0].providerMetadata?.venice?.thoughtSignature).toBe("signed-first-call") expect(response.toolCalls[1].providerMetadata?.venice?.thoughtSignature).toBeUndefined() - const replay = yield* compileRequest(LLM.request({ model, messages: [Message.user("Lookup"), response.message] })) + const replay = yield* compileRequest(ai.request({ model, messages: [Message.user("Lookup"), response.message] })) expect(replay.body.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -231,14 +230,14 @@ it.effect("Venice keeps late Gemini signatures attached to the correct parallel it.effect("Venice preserves message signatures even for identical answers and empty intervening messages", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( fixedResponse( sseEvents(frame({ content: "42" }), frame({ thought_signature: "second-signature" }), frame({}, "stop")), ), ), ) - const request = LLM.request({ + const request = ai.request({ model, messages: [ Message.assistant([ @@ -257,7 +256,7 @@ it.effect("Venice preserves message signatures even for identical answers and em it.effect("Venice merges signed Claude reasoning without losing its final signature", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -288,7 +287,7 @@ it.effect("Venice merges signed Claude reasoning without losing its final signat ), ) expect(response.reasoning).toBe("Think carefully") - expect((yield* compileRequest(LLM.request({ model, messages: [response.message] }))).body.messages).toEqual( + expect((yield* compileRequest(ai.request({ model, messages: [response.message] }))).body.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ reasoning_details: [ @@ -307,7 +306,7 @@ it.effect("Venice merges signed Claude reasoning without losing its final signat ) it.effect("Venice maps cache-write usage and retains the raw wire spelling", () => - LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( fixedResponse( sseEvents(frame({ content: "42" }, "stop"), { @@ -342,7 +341,7 @@ it.effect("Venice maps cache-write usage and retains the raw wire spelling", () it.effect("Venice surfaces HTTP string errors and streaming validation details without losing the payload", () => Effect.gen(function* () { - const http = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const http = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( fixedResponse('{"error":"Specified model not found"}', { status: 404, @@ -357,7 +356,7 @@ it.effect("Venice surfaces HTTP string errors and streaming validation details w error: "Invalid request parameters", issues: [{ message: "Unsupported value", path: ["reasoning", "effort"] }], } - const streamed = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const streamed = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide(fixedResponse(sseEvents(payload))), Effect.result, ) diff --git a/packages/ai/test/provider/vercel-ai-gateway-images.recorded.test.ts b/packages/ai/test/provider/vercel-ai-gateway-images.recorded.test.ts index 398696690420..d135fea444dc 100644 --- a/packages/ai/test/provider/vercel-ai-gateway-images.recorded.test.ts +++ b/packages/ai/test/provider/vercel-ai-gateway-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { VercelAIGateway } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -19,7 +19,7 @@ const recorded = recordedTests({ describe("Vercel AI Gateway Images recorded", () => { recorded.effect("generates an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "A simple flat black circle centered on a plain white background.", providerOptions: { outputFormat: "webp" }, @@ -39,7 +39,7 @@ describe("Vercel AI Gateway Images recorded", () => { "sizes Grok through xAI provider options", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: VercelAIGateway.configure({ apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture" }).image( "spacexai/grok-imagine-image", ), diff --git a/packages/ai/test/provider/vercel-ai-gateway.recorded.test.ts b/packages/ai/test/provider/vercel-ai-gateway.recorded.test.ts index 0e3867d51aaa..ffbaac586da1 100644 --- a/packages/ai/test/provider/vercel-ai-gateway.recorded.test.ts +++ b/packages/ai/test/provider/vercel-ai-gateway.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMClient, LLMEvent, LLMRequest } from "../../src/effect.js" +import { ai, LLMClient, LLMEvent, LLMRequest } from "../../src/effect.js" import { isRetryable } from "../../src/provider-error.js" import { VercelAIGateway } from "../../src/providers/vercel-ai-gateway.js" import { recordedTests } from "../recorded-test.js" @@ -87,7 +87,7 @@ recorded.effect( () => Effect.gen(function* () { const error = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: VercelAIGateway.configure({ apiKey: "invalid-gateway-key" }).messages("anthropic/claude-sonnet-5.5"), prompt: "Hello", generation: { maxTokens: 32 }, @@ -103,7 +103,7 @@ recorded.effect( "automatically caches distinct Claude system blocks", () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: gateway.messages("anthropic/claude-sonnet-5.5"), system: [ { diff --git a/packages/ai/test/provider/vercel-ai-gateway.test.ts b/packages/ai/test/provider/vercel-ai-gateway.test.ts index a232764acf5a..6c31b42cad75 100644 --- a/packages/ai/test/provider/vercel-ai-gateway.test.ts +++ b/packages/ai/test/provider/vercel-ai-gateway.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { ConfigProvider, Effect } from "effect" -import { LLM, LLMClient, LLMEvent, Message, CacheHint } from "../../src/effect.js" +import { ai, CacheHint, LLMClient, LLMEvent, Message } from "../../src/effect.js" import { VercelAIGateway } from "../../src/providers/vercel-ai-gateway.js" import { compileRequest } from "../../src/route/client.js" import { it } from "../lib/effect.js" @@ -26,7 +26,7 @@ it.effect("Gateway defaults preserve full model IDs and select the requested fam expect(model.id).toBe(id) expect(model.provider).toBe("vercel-ai-gateway") expect(model.route.endpoint.path).toBe(path) - const compiled = yield* compileRequest(LLM.request({ model, prompt: "Hello" })) + const compiled = yield* compileRequest(ai.request({ model, prompt: "Hello" })) expect(compiled.body.model).toBe(id) expect(compiled.body.providerOptions).toBeUndefined() } @@ -42,20 +42,20 @@ it.effect("Gateway Messages applies inline cache markers only for Anthropic and prompt: "Hello", } const claude = yield* compileRequest( - LLM.request({ ...input, model: gateway.messages("anthropic/claude-sonnet-5.5") }), + ai.request({ ...input, model: gateway.messages("anthropic/claude-sonnet-5.5") }), ) expect(claude.body).toMatchObject({ tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }], system: [{ type: "text", text: "System prompt", cache_control: { type: "ephemeral" } }], messages: [{ role: "user", content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }] }], }) - const qwen = yield* compileRequest(LLM.request({ ...input, model: gateway.messages("alibaba/qwen3.8-max") })) + const qwen = yield* compileRequest(ai.request({ ...input, model: gateway.messages("alibaba/qwen3.8-max") })) expect(qwen.body).toMatchObject({ tools: [{ name: "lookup", cache_control: undefined }], system: [{ type: "text", text: "System prompt", cache_control: { type: "ephemeral" } }], messages: [{ role: "user", content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }] }], }) - const gemini = yield* compileRequest(LLM.request({ ...input, model: gateway.messages("google/gemini-3.8-flash") })) + const gemini = yield* compileRequest(ai.request({ ...input, model: gateway.messages("google/gemini-3.8-flash") })) expect(JSON.stringify(gemini.body)).not.toContain("cache_control") }), ) @@ -63,7 +63,7 @@ it.effect("Gateway Messages applies inline cache markers only for Anthropic and it.effect("Gateway keeps distinct system blocks, cache hints, and upstream-scoped controls", () => Effect.gen(function* () { const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model: VercelAIGateway.configure({ apiKey: "fixture" }).messages("anthropic/claude-sonnet-5.5"), system: [ { type: "text", text: "Stable", cache: new CacheHint({ type: "ephemeral" }) }, @@ -96,18 +96,18 @@ it.effect("Gateway effort settings lower to the selected API", () => Effect.gen(function* () { const gateway = VercelAIGateway.configure({ apiKey: "fixture", providerOptions: { reasoningEffort: "high" } }) const messages = yield* compileRequest( - LLM.request({ model: gateway.messages("anthropic/claude-sonnet-5.5"), prompt: "Hello" }), + ai.request({ model: gateway.messages("anthropic/claude-sonnet-5.5"), prompt: "Hello" }), ) expect(messages.body).toMatchObject({ thinking: { type: "adaptive" }, output_config: { effort: "high" } }) const responses = yield* compileRequest( - LLM.request({ model: gateway.responses("openai/gpt-6-luna"), prompt: "Hello" }), + ai.request({ model: gateway.responses("openai/gpt-6-luna"), prompt: "Hello" }), ) expect(responses.body).toMatchObject({ reasoning: { effort: "high" }, store: false, include: ["reasoning.encrypted_content"], }) - const chat = yield* compileRequest(LLM.request({ model: gateway.chat("google/gemini-3.8-flash"), prompt: "Hello" })) + const chat = yield* compileRequest(ai.request({ model: gateway.chat("google/gemini-3.8-flash"), prompt: "Hello" })) expect(chat.body).toMatchObject({ reasoning_effort: "high" }) }), ) @@ -115,7 +115,7 @@ it.effect("Gateway effort settings lower to the selected API", () => it.effect("Gateway Responses keeps cache-key and cache controls out of upstream namespaces", () => Effect.gen(function* () { const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model: VercelAIGateway.configure({ apiKey: "fixture" }).responses("openai/gpt-6-luna"), prompt: "Hello", promptCacheKey: "opaque-session", @@ -130,7 +130,7 @@ it.effect("Gateway Responses keeps cache-key and cache controls out of upstream it.effect("Gateway Messages preserves signatures and billing metadata through tool continuation", () => Effect.gen(function* () { const model = VercelAIGateway.configure({ apiKey: "fixture" }).messages("anthropic/claude-sonnet-5.5") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( dynamicResponse((input) => Effect.sync(() => { @@ -174,7 +174,7 @@ it.effect("Gateway Messages preserves signatures and billing metadata through to }) expect(response.usage?.cacheReadInputTokens).toBe(20) const compiled = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.user("Hello"), @@ -196,7 +196,7 @@ it.effect("Gateway Messages preserves signatures and billing metadata through to it.effect("Gateway Responses replays encrypted reasoning alongside tool calls", () => Effect.gen(function* () { const model = VercelAIGateway.configure({ apiKey: "fixture" }).responses("openai/gpt-6-luna") - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe( Effect.provide( dynamicResponse((input) => Effect.sync(() => @@ -228,7 +228,7 @@ it.effect("Gateway Responses replays encrypted reasoning alongside tool calls", ), ), ) - const compiled = yield* compileRequest(LLM.request({ model, messages: [Message.user("Hello"), response.message] })) + const compiled = yield* compileRequest(ai.request({ model, messages: [Message.user("Hello"), response.message] })) expect(compiled.body.input).toContainEqual({ type: "reasoning", id: "rs_1", @@ -249,7 +249,7 @@ it.effect("Gateway 401 and 403 failures are terminal and retain HTTP context", ( Effect.gen(function* () { for (const status of [401, 403]) { const error = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: VercelAIGateway.configure({ apiKey: "fixture" }).chat("openai/gpt-6-luna"), prompt: "Hello", }), @@ -276,7 +276,7 @@ it.effect("Gateway authentication supports OIDC fallback and affinity without re Effect.gen(function* () { for (const api of ["messages", "responses", "chat"] as const) { yield* LLMClient.generate( - LLM.request({ + ai.request({ model: VercelAIGateway.configure()[api]("openai/gpt-6-luna"), prompt: "Hello", promptCacheKey: "opaque-session", diff --git a/packages/ai/test/provider/xai-images.recorded.test.ts b/packages/ai/test/provider/xai-images.recorded.test.ts index af41451da8a0..0a9cf6960a87 100644 --- a/packages/ai/test/provider/xai-images.recorded.test.ts +++ b/packages/ai/test/provider/xai-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image, Media } from "../../src/effect.js" +import { ai, Media } from "../../src/effect.js" import { XAI } from "../../src/providers.js" import { dimensions } from "../lib/image.js" import { recordedTests } from "../recorded-test.js" @@ -19,7 +19,7 @@ const recorded = recordedTests({ describe("xAI Images recorded", () => { recorded.effect("generates an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "A simple flat black diamond centered on a plain white background.", aspect: "1:1", @@ -34,7 +34,7 @@ describe("xAI Images recorded", () => { recorded.effect("edits an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "Keep the simple shape and change it from black to bright purple.", images: [ diff --git a/packages/ai/test/provider/xai-images.test.ts b/packages/ai/test/provider/xai-images.test.ts index f126e62f1d96..d6993182d68d 100644 --- a/packages/ai/test/provider/xai-images.test.ts +++ b/packages/ai/test/provider/xai-images.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Headers, HttpClientRequest } from "effect/http" -import { Image, ImageClient } from "../../src/effect.js" +import { ai, ImageClient } from "../../src/effect.js" import { XAI } from "../../src/providers.js" import { Auth } from "../../src/route.js" import { it } from "../lib/effect.js" @@ -10,7 +10,7 @@ import { dynamicResponse } from "../lib/http.js" describe("xAI Images", () => { it.effect("generates through the OpenAI-compatible Images API", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1", @@ -80,30 +80,32 @@ describe("xAI Images", () => { ) it.effect("supports request-level custom auth", () => - Image.generate({ - model: XAI.configure({ - baseURL: "https://api.xai.test/v1", - auth: Auth.custom((input) => - Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)), - ), - }).image("grok-imagine-image"), - prompt: "A robot tending a rooftop garden", - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(request.headers.get("x-custom-auth")).toBe("api.xai.test") - return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), { - headers: { "content-type": "application/json" }, - }) - }), + ai.image + .generate({ + model: XAI.configure({ + baseURL: "https://api.xai.test/v1", + auth: Auth.custom((input) => + Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)), + ), + }).image("grok-imagine-image"), + prompt: "A robot tending a rooftop garden", + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.headers.get("x-custom-auth")).toBe("api.xai.test") + return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), { + headers: { "content-type": "application/json" }, + }) + }), + ), ), ), ), ), - ), ) }) diff --git a/packages/ai/test/provider/xai-responses.test.ts b/packages/ai/test/provider/xai-responses.test.ts index 9dc1250117b7..046797444c6f 100644 --- a/packages/ai/test/provider/xai-responses.test.ts +++ b/packages/ai/test/provider/xai-responses.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Stream } from "effect" -import { LLM, LLMEvent, Message } from "../../src/effect.js" +import { ai, LLMEvent, Message } from "../../src/effect.js" import { XAI } from "../../src/providers.js" import { OpenResponses } from "../../src/protocols/open-responses.js" import { OpenAIResponses } from "../../src/protocols/openai-responses.js" @@ -21,7 +21,7 @@ import { sseEvents } from "../lib/sse.js" const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6") /** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */ -const channelDriver = (request: ReturnType) => +const channelDriver = (request: ReturnType) => Effect.gen(function* () { let driver: WebSocketChannelDriver | undefined yield* LLMClient.generate(request, { @@ -55,7 +55,7 @@ describe("xAI Responses route", () => { expect(XAIResponses.protocol.body).not.toBe(OpenResponses.protocol.body) expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body) - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "Hello" })) expect(prepared.route).toBe("xai-responses") expect(prepared.protocol).toBe("xai-responses") expect(prepared.model.route.providerMetadataKey).toBe("xai") @@ -66,7 +66,7 @@ describe("xAI Responses route", () => { it.effect("allows callers to opt out of encrypted reasoning", () => Effect.gen(function* () { - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", providerOptions: { include: [] } })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "Hello", providerOptions: { include: [] } })) expect(prepared.body.store).toBe(false) expect(prepared.body.include).toBeUndefined() @@ -76,7 +76,7 @@ describe("xAI Responses route", () => { it.effect("sends top_k and omits unsupported penalties", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Hello", generation: { topK: 20, presencePenalty: 0.5, frequencyPenalty: 0.5 }, @@ -91,7 +91,7 @@ describe("xAI Responses route", () => { it.effect("sends min_p from provider options", () => Effect.gen(function* () { - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", providerOptions: { minP: 0.1 } })) + const prepared = yield* compileRequest(ai.request({ model, prompt: "Hello", providerOptions: { minP: 0.1 } })) expect(prepared.body.min_p).toBe(0.1) }), @@ -99,7 +99,7 @@ describe("xAI Responses route", () => { it.effect("parses xAI reasoning summaries", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Think" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -134,7 +134,7 @@ describe("xAI Responses route", () => { it.effect("routes xAI reasoning summaries by output index", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Think" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -172,7 +172,7 @@ describe("xAI Responses route", () => { Effect.gen(function* () { const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } } const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: [ Message.assistant([ @@ -201,7 +201,7 @@ describe("xAI Responses route", () => { { type: "computer_call", id: "computer_1", status: "completed" }, ] const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, messages: items.map((item) => Message.assistant({ @@ -238,7 +238,7 @@ describe("xAI Responses route", () => { open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }), }) - const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe( + const error = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" }), { webSocket }).pipe( Effect.provide( LLMClient.layer.pipe( Layer.provide( @@ -261,7 +261,7 @@ describe("xAI Responses route", () => { it.effect("continues stored responses without instructions and sends unstored steps in full", () => Effect.gen(function* () { const step = (store: boolean, ...prompts: string[]) => - LLM.request({ + ai.request({ model, system: "You are terse.", messages: prompts.map((prompt) => Message.user(prompt)), @@ -299,7 +299,7 @@ describe("xAI Responses route", () => { it.effect("parses xAI hosted tool items", () => Effect.gen(function* () { const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } } - const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Search X" })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -328,7 +328,7 @@ describe("xAI Responses route", () => { it.effect("names the xAI Chat route separately from its OpenAI Chat protocol", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }), + ai.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }), ) expect(prepared.route).toBe("xai-chat") expect(prepared.protocol).toBe("openai-chat") diff --git a/packages/ai/test/provider/xai-video.recorded.test.ts b/packages/ai/test/provider/xai-video.recorded.test.ts index 2dbd7142bd8b..31cad80d0cd2 100644 --- a/packages/ai/test/provider/xai-video.recorded.test.ts +++ b/packages/ai/test/provider/xai-video.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { Video } from "../../src/effect.js" +import { Video } from "../../src/experimental.js" import { XAI } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" import { queuedPoll } from "./queued-recording.js" diff --git a/packages/ai/test/provider/zai-capabilities.recorded.test.ts b/packages/ai/test/provider/zai-capabilities.recorded.test.ts index 8ea7b52bcc4e..023c2d3facf4 100644 --- a/packages/ai/test/provider/zai-capabilities.recorded.test.ts +++ b/packages/ai/test/provider/zai-capabilities.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/effect.js" +import { ai, LLMEvent, Media, Message, ToolDefinition } from "../../src/effect.js" import { ZAI } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { recordedTests } from "../recorded-test.js" @@ -14,7 +14,7 @@ recorded.effect.with( () => Effect.gen(function* () { const error = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey, providerOptions: { thinking: { type: "disabled" } } }).chat("glm-5.3"), prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 4096 }, @@ -35,7 +35,7 @@ for (const modelID of ["glm-4.7", "glm-5.2"]) { () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey, providerOptions: { thinking: { type, clear_thinking: true } } }).chat( modelID, ), @@ -61,7 +61,7 @@ for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max" () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: effort } }).chat("glm-5.2"), prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 4096 }, @@ -87,7 +87,7 @@ recorded.effect.with( Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(), ) const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: "low" } }).chat("glm-5.3-flash"), messages: [ Message.user([ @@ -111,7 +111,7 @@ recorded.effect.with( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: "low", responseFormat: { type: "json_object" } }, @@ -132,7 +132,7 @@ recorded.effect.with( () => Effect.gen(function* () { const response = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey }).chat("glm-4.5"), prompt: "Use get_weather to look up the current weather in Paris.", tools: [ diff --git a/packages/ai/test/provider/zai-images.recorded.test.ts b/packages/ai/test/provider/zai-images.recorded.test.ts index 8f4ffb76ecfa..764501e79a5c 100644 --- a/packages/ai/test/provider/zai-images.recorded.test.ts +++ b/packages/ai/test/provider/zai-images.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Image } from "../../src/effect.js" +import { ai } from "../../src/effect.js" import { ZAI } from "../../src/providers.js" import { recordedTests } from "../recorded-test.js" @@ -16,7 +16,7 @@ const recorded = recordedTests({ describe("Z.ai Images recorded", () => { recorded.effect("generates an image", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model, prompt: "A simple flat red circle centered on a plain white background.", aspect: "1:1", diff --git a/packages/ai/test/provider/zai-images.test.ts b/packages/ai/test/provider/zai-images.test.ts index 3af54e7031d9..cb776c2c7b5e 100644 --- a/packages/ai/test/provider/zai-images.test.ts +++ b/packages/ai/test/provider/zai-images.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { HttpClientRequest } from "effect/http" -import { Image, ImageClient } from "../../src/effect.js" +import { ai, ImageClient } from "../../src/effect.js" import { ZAI } from "../../src/providers.js" import { it } from "../lib/effect.js" import { dynamicResponse, fixedResponse } from "../lib/http.js" @@ -9,7 +9,7 @@ import { dynamicResponse, fixedResponse } from "../lib/http.js" describe("Z.ai Images", () => { it.effect("generates through the Z.ai Images API", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: ZAI.configure({ apiKey: "test", baseURL: "https://api.z.ai.test/api/paas/v4", @@ -84,53 +84,57 @@ describe("Z.ai Images", () => { ) it.effect("sanitizes unpaired surrogates in outbound image requests", () => - Image.generate({ - model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image( - "model", - ), - prompt: "A red circle \uD800 on a white background \u{1F600}", - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toMatchObject({ - prompt: "A red circle \uFFFD on a white background \u{1F600}", - metadata: { source: "default\uFFFD" }, - }) - return Effect.succeed( - input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), { - headers: { "content-type": "application/json" }, - }), - ) - }), + ai.image + .generate({ + model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image( + "model", + ), + prompt: "A red circle \uD800 on a white background \u{1F600}", + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toMatchObject({ + prompt: "A red circle \uFFFD on a white background \u{1F600}", + metadata: { source: "default\uFFFD" }, + }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), ), ), ), - ), ) it.effect("lets raw native options override aliases", () => - Image.generate({ - model: ZAI.configure({ apiKey: "test" }).image("model"), - prompt: "test", - providerOptions: { quality: "future-quality", userID: "x", user_id: "raw-user" }, - }).pipe( - Effect.provide( - ImageClient.layer.pipe( - Layer.provide( - dynamicResponse((input) => { - expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" }) - return Effect.succeed( - input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), { - headers: { "content-type": "application/json" }, - }), - ) - }), + ai.image + .generate({ + model: ZAI.configure({ apiKey: "test" }).image("model"), + prompt: "test", + providerOptions: { quality: "future-quality", userID: "x", user_id: "raw-user" }, + }) + .pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), ), ), ), - ), ) it.effect("rejects invalid response structures", () => @@ -145,7 +149,7 @@ describe("Z.ai Images", () => { ] yield* Effect.forEach(payloads, (payload) => - Image.generate({ model, prompt: "test" }).pipe( + ai.image.generate({ model, prompt: "test" }).pipe( Effect.provide( ImageClient.layer.pipe( Layer.provide( diff --git a/packages/ai/test/provider/zai.recorded.test.ts b/packages/ai/test/provider/zai.recorded.test.ts index 86f2458faeeb..bd85d4124e76 100644 --- a/packages/ai/test/provider/zai.recorded.test.ts +++ b/packages/ai/test/provider/zai.recorded.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/effect.js" +import { ai, LLMEvent, LLMRequest, type LLMResponse, Message, ToolDefinition } from "../../src/effect.js" import { ZAI } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -25,7 +25,7 @@ for (const effort of [undefined, "low", "high", "max"] as const) { { tags: ["text", "reasoning", "usage"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: effort } }).model("glm-5.3"), prompt: "What is 173 multiplied by 219? Reply with only the final integer.", generation: { maxTokens: 4096 }, @@ -48,7 +48,7 @@ recorded.effect.with( { tags: ["tool", "tool-loop", "reasoning", "continuation"] }, () => Effect.gen(function* () { - const request = LLM.request({ + const request = ai.request({ model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: "high", thinking: { type: "enabled", clear_thinking: false } }, diff --git a/packages/ai/test/provider/zai.test.ts b/packages/ai/test/provider/zai.test.ts index bbf4f8b31f5d..34f7b4080c69 100644 --- a/packages/ai/test/provider/zai.test.ts +++ b/packages/ai/test/provider/zai.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { ConfigProvider, Effect } from "effect" import { Headers } from "effect/http" -import { Auth, LLM, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/effect.js" +import { ai, Auth, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/effect.js" import { ZAI, ZAICodingPlan } from "../../src/providers.js" import { compileRequest } from "../../src/route/client.js" import { Endpoint } from "../../src/route/endpoint.js" @@ -25,7 +25,7 @@ it.effect("ZAI owns standard and Coding Plan endpoints and bearer credentials", { model: ZAICodingPlan.messages("glm-5.3"), path: "/anthropic/v1/messages", provider: "zai-coding-plan" }, { model: ZAICodingPlan.responses("glm-5.3"), path: "/v1/responses", provider: "zai-coding-plan" }, ]) { - const request = LLM.request({ model: item.model, prompt: "Hello" }) + const request = ai.request({ model: item.model, prompt: "Hello" }) const compiled = yield* compileRequest(request) const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString() expect(url).toBe(`https://api.z.ai/api${item.path}`) @@ -77,7 +77,7 @@ it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to ["future-model", false], ] as const) { const result = yield* compileRequest( - LLM.request({ + ai.request({ model: provider.chat(modelID), prompt: "Hello", generation: { maxTokens: 123 }, @@ -104,7 +104,7 @@ it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to { type: "enabled", clear_thinking: true }, ]) { const result = yield* compileRequest( - LLM.request({ + ai.request({ model: provider.chat("glm-5.2"), providerOptions: { thinking, @@ -141,7 +141,7 @@ it.effect("ZAI tool streaming defaults require tools and preserve explicit overr { id: "future-model", tools: [tool], toolStream: true, expected: true }, ]) { const result = yield* compileRequest( - LLM.request({ + ai.request({ model: provider.chat(item.id), prompt: "Hello", tools: item.tools, @@ -158,20 +158,20 @@ it.effect("ZAI lowers effort using the selected native API without inventing thi Effect.gen(function* () { for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "future-effort"]) { const standard = yield* compileRequest( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey: "fixture" }).chat("glm-5.3"), providerOptions: { reasoningEffort: effort }, }), ) const coding = ZAICodingPlan.configure({ apiKey: "fixture" }) const chat = yield* compileRequest( - LLM.request({ model: coding.chat("glm-5.3"), providerOptions: { reasoningEffort: effort } }), + ai.request({ model: coding.chat("glm-5.3"), providerOptions: { reasoningEffort: effort } }), ) const messages = yield* compileRequest( - LLM.request({ model: coding.messages("glm-5.3"), providerOptions: { effort } }), + ai.request({ model: coding.messages("glm-5.3"), providerOptions: { effort } }), ) const responses = yield* compileRequest( - LLM.request({ model: coding.responses("glm-5.3"), providerOptions: { reasoningEffort: effort } }), + ai.request({ model: coding.responses("glm-5.3"), providerOptions: { reasoningEffort: effort } }), ) expect(standard.body.reasoning_effort).toBe(effort) expect(chat.body.reasoning_effort).toBe(effort) @@ -181,7 +181,7 @@ it.effect("ZAI lowers effort using the selected native API without inventing thi } for (const type of ["enabled", "adaptive", "disabled"]) { const result = yield* compileRequest( - LLM.request({ + ai.request({ model: ZAICodingPlan.configure({ apiKey: "fixture", providerOptions: { thinking: { type } } }).messages( "glm-5.3", ), @@ -195,7 +195,7 @@ it.effect("ZAI lowers effort using the selected native API without inventing thi it.effect("ZAI Coding Chat sends PDFs from tool results as file parts", () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: ZAICodingPlan.configure({ apiKey: "fixture" }).chat("glm-5.3-flash"), messages: [ Message.user("Read the report."), @@ -230,7 +230,7 @@ it.effect("ZAI rejects invalid typed thinking options before execution", () => .chat("glm-5.3") .route.with({ providerOptions: { thinking: { clear_thinking: "false" } } }) .model({ id: "glm-5.3" }) - const error = yield* compileRequest(LLM.request({ model })).pipe(Effect.flip) + const error = yield* compileRequest(ai.request({ model })).pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") expect(error.message).toContain("clear_thinking") }), @@ -239,7 +239,7 @@ it.effect("ZAI rejects invalid typed thinking options before execution", () => it.effect("ZAI preserves unsigned and signed Messages reasoning", () => Effect.gen(function* () { const result = yield* compileRequest( - LLM.request({ + ai.request({ model: ZAICodingPlan.configure({ apiKey: "fixture" }).messages("glm-5.3"), messages: [ Message.user("Hello"), @@ -270,7 +270,7 @@ it.effect("ZAI preserves unsigned and signed Messages reasoning", () => it.effect("ZAI applies configured options, request options, and final body overlays in order", () => LLMClient.generate( - LLM.request({ + ai.request({ model: ZAI.configure({ apiKey: "fixture", baseURL: "https://gateway.example/custom", @@ -303,7 +303,7 @@ it.effect("ZAI applies configured options, request options, and final body overl it.effect("ZAI Coding Responses uses HTTP with a supplied WebSocket executor", () => LLMClient.generate( - LLM.request({ model: ZAICodingPlan.configure({ apiKey: "fixture" }).responses("glm-5.3"), prompt: "Hello" }), + ai.request({ model: ZAICodingPlan.configure({ apiKey: "fixture" }).responses("glm-5.3"), prompt: "Hello" }), { webSocket: { execute: () => Effect.die("Unexpected WebSocket") }, }, @@ -323,7 +323,7 @@ it.effect("ZAI Coding Responses uses HTTP with a supplied WebSocket executor", ( it.effect("ZAI Coding Messages serializes enabled thinking without a token budget", () => LLMClient.generate( - LLM.request({ + ai.request({ model: ZAICodingPlan.configure({ apiKey: "fixture", baseURL: "https://gateway.example/messages/v1", diff --git a/packages/ai/test/raw-events.test.ts b/packages/ai/test/raw-events.test.ts index 5748eb7d3a24..66c3c91f21f2 100644 --- a/packages/ai/test/raw-events.test.ts +++ b/packages/ai/test/raw-events.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Stream } from "effect" -import { LLM } from "../src/effect.js" +import { ai } from "../src/effect.js" import { Auth, LLMClient } from "../src/route.js" import { AnthropicMessages } from "../src/protocols/anthropic-messages.js" import { it } from "./lib/effect.js" @@ -28,13 +28,13 @@ describe("raw provider events", () => { it.effect("stream emits every frame before its own events only when requested", () => Effect.gen(function* () { const raw = Array.from( - yield* LLMClient.stream(LLM.request({ model, prompt: "Hi", includeRaw: true })).pipe( + yield* LLMClient.stream(ai.request({ model, prompt: "Hi", includeRaw: true })).pipe( Stream.runCollect, Effect.provide(layer), ), ) const plain = Array.from( - yield* LLMClient.stream(LLM.request({ model, prompt: "Hi" })).pipe(Stream.runCollect, Effect.provide(layer)), + yield* LLMClient.stream(ai.request({ model, prompt: "Hi" })).pipe(Stream.runCollect, Effect.provide(layer)), ) expect(raw.filter((event) => event.type === "raw").map((event) => event.data)).toEqual(frames) @@ -63,10 +63,10 @@ describe("raw provider events", () => { it.effect("generate ignores raw events", () => Effect.gen(function* () { - const withRaw = yield* LLMClient.generate(LLM.request({ model, prompt: "Hi", includeRaw: true })).pipe( + const withRaw = yield* LLMClient.generate(ai.request({ model, prompt: "Hi", includeRaw: true })).pipe( Effect.provide(layer), ) - const plain = yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(Effect.provide(layer)) + const plain = yield* LLMClient.generate(ai.request({ model, prompt: "Hi" })).pipe(Effect.provide(layer)) expect(withRaw).toEqual(plain) expect(withRaw.text).toBe("Hello") diff --git a/packages/ai/test/recorded-scenarios.ts b/packages/ai/test/recorded-scenarios.ts index fa4bc612e45f..2b0f8b851be8 100644 --- a/packages/ai/test/recorded-scenarios.ts +++ b/packages/ai/test/recorded-scenarios.ts @@ -1,22 +1,23 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" import { - Media, - LLM, + ai, + type ContentPart, + type FinishReason, + type LanguageModel, + type LLM, LLMEvent, LLMRequest, LLMResponse, + Media, Message, - ToolRuntime, + toDefinitions, ToolChoice, ToolDefinition, - toDefinitions, - type ContentPart, - type FinishReason, - type LanguageModel, + ToolRuntime, } from "../src/effect.js" import { LLMClient } from "../src/route.js" -import { Tool } from "../src/tool.js" +import { tool } from "../src/tool.js" export const weatherToolName = "get_weather" @@ -43,7 +44,7 @@ export const weatherTool = ToolDefinition.make({ }, }) -export const weatherRuntimeTool = Tool.make({ +export const weatherRuntimeTool = tool({ description: weatherTool.description, parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), @@ -60,7 +61,7 @@ export const weatherToolLoopRequest = (input: { readonly maxTokens?: number readonly temperature?: number | false }) => - LLM.request({ + ai.request({ id: input.id, model: input.model, system: input.system ?? "Use the get_weather tool, then answer in one short sentence.", @@ -273,7 +274,7 @@ const runGeneratedConversation = (context: GoldenScenarioContext, steps: Readonl generated += 1 const response = yield* generate( - LLM.request({ + ai.request({ id: step.id ? `${context.id}_${step.id}` : `${context.id}_${generated}`, model: context.model, system: step.system, @@ -344,7 +345,7 @@ const runImageToolResultScenario = (context: GoldenScenarioContext) => Effect.gen(function* () { const image = yield* restroomImage() const response = yield* generate( - LLM.request({ + ai.request({ id: `${context.id}_image_tool_result`, model: context.model, system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.", diff --git a/packages/ai/test/request-encoding.test.ts b/packages/ai/test/request-encoding.test.ts index 877a7fe5b806..ff1ae724ab9f 100644 --- a/packages/ai/test/request-encoding.test.ts +++ b/packages/ai/test/request-encoding.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import { gunzipSync, zstdDecompressSync } from "node:zlib" import { Effect } from "effect" import { HttpClientRequest } from "effect/http" -import { LLM, type RequestEncoding } from "../src/effect.js" +import { ai, type RequestEncoding } from "../src/effect.js" import { LLMClient } from "../src/route.js" import { configure } from "../src/providers/openai.js" import { dynamicResponse } from "./lib/http.js" @@ -27,7 +27,7 @@ const send = (prompt: string, encoding?: RequestEncoding) => }) }), ) - yield* LLMClient.generate(LLM.request({ model, prompt, http: encoding ? { encoding } : undefined })).pipe( + yield* LLMClient.generate(ai.request({ model, prompt, http: encoding ? { encoding } : undefined })).pipe( Effect.provide(server), ) return seen[0] diff --git a/packages/ai/test/route.test.ts b/packages/ai/test/route.test.ts index 31596582a925..537dbe67eec5 100644 --- a/packages/ai/test/route.test.ts +++ b/packages/ai/test/route.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, Stream } from "effect" import * as OpenAIChat from "../src/protocols/openai-chat.js" -import { AIError, HttpContext, InvalidProviderOutputError, LLM } from "../src/effect.js" +import { ai, AIError, HttpContext, InvalidProviderOutputError } from "../src/effect.js" import { Anthropic } from "../src/providers.js" import { Auth, Framing, HttpTransport, LLMClient, Route } from "../src/route.js" import { fixedResponse, truncatedStream } from "./lib/http.js" @@ -59,7 +59,7 @@ describe("Route.with", () => { describe("Route diagnostics", () => { const route = OpenAIChat.route.with({ endpoint: { baseURL: "https://provider.test/v1" } }) - const request = LLM.request({ model: route.model({ id: "test" }), prompt: "Hello" }) + const request = ai.request({ model: route.model({ id: "test" }), prompt: "Hello" }) const headers = { "content-type": "text/event-stream", "x-request-id": "req_stream" } it.effect("retains an entire invalid event and its validation cause", () => @@ -130,7 +130,7 @@ describe("Route diagnostics", () => { }, }) const error = yield* LLMClient.generate( - LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }), + ai.request({ model: failing.model({ id: "test" }), prompt: "Hello" }), ).pipe(Effect.provide(fixedResponse(sseEvents(frame), { headers })), Effect.flip) expect(error.message).toBe("Parser failed") @@ -145,7 +145,7 @@ describe("Route diagnostics", () => { Effect.gen(function* () { const body = '{"query": BROKEN}' const error = yield* LLMClient.generate( - LLM.request({ + ai.request({ model: Anthropic.configure({ apiKey: "test", baseURL: "https://provider.test" }).model("claude"), prompt: "Hello", }), @@ -195,7 +195,7 @@ describe("Route diagnostics", () => { transport: HttpTransport.httpJson({ framing: { id: "failure", frame: () => Stream.fail(failure) } }), }) const error = yield* LLMClient.generate( - LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }), + ai.request({ model: failing.model({ id: "test" }), prompt: "Hello" }), ).pipe(Effect.provide(fixedResponse("wire bytes", { headers })), Effect.flip) expect(error.reason.body).toBe(failure.reason.body) diff --git a/packages/ai/test/sanitize.test.ts b/packages/ai/test/sanitize.test.ts index e0e843c1997a..622fb93b8973 100644 --- a/packages/ai/test/sanitize.test.ts +++ b/packages/ai/test/sanitize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../src/effect.js" +import { ai, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../src/effect.js" import { OpenAIChat } from "../src/protocols.js" import { Auth } from "../src/route.js" import { sanitizeSurrogates } from "../src/utils/sanitize.js" @@ -20,7 +20,7 @@ const sanitizeRequest = (request: LLMRequest) => describe("sanitizeSurrogates", () => { test("replaces lone surrogates in nested message text, tool input, and tool output", () => { const sanitized = sanitizeRequest( - LLM.request({ + ai.request({ model, system: "system \uD800", tools: [tool], @@ -46,7 +46,7 @@ describe("sanitizeSurrogates", () => { test("keeps clean requests equal and shares unchanged nested values", () => { const messages = [Message.user("hello \u{1F600}"), Message.assistant("hi")] - const request = LLM.request({ + const request = ai.request({ model, system: "system", tools: [tool], @@ -72,7 +72,7 @@ describe("sanitizeSurrogates", () => { expect(output.metadata).toBe(input.metadata) expect(output.messages).not.toBe(input.messages) - const clean = LLM.request({ model, system: "system", tools: [tool], messages }) + const clean = ai.request({ model, system: "system", tools: [tool], messages }) expect(sanitizeRequest(clean)).toEqual(clean) expect(sanitizeSurrogates(LLMRequest.input(clean)).messages).toBe(clean.messages) }) diff --git a/packages/ai/test/speech.test.ts b/packages/ai/test/speech.test.ts index 139a3134a8b2..a30b39f1942b 100644 --- a/packages/ai/test/speech.test.ts +++ b/packages/ai/test/speech.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Stream } from "effect" import { Base64 } from "effect/encoding" -import { Speech, SpeechClient, SpeechEvent } from "../src/effect.js" +import { Speech, SpeechClient, SpeechEvent } from "../src/experimental.js" import { Cartesia, Deepgram, ElevenLabs, Google, OpenAI } from "../src/providers.js" import { it } from "./lib/effect.js" import { dynamicResponse } from "./lib/http.js" diff --git a/packages/ai/test/speech.types.ts b/packages/ai/test/speech.types.ts index 0a4f56841616..1b5793cb1024 100644 --- a/packages/ai/test/speech.types.ts +++ b/packages/ai/test/speech.types.ts @@ -1,5 +1,5 @@ import type { Stream } from "effect" -import { Speech, SpeechModel, type SpeechEvent, type SpeechOptions } from "../src/effect.js" +import { Speech, SpeechModel, type SpeechEvent, type SpeechOptions } from "../src/experimental.js" import { ElevenLabs, OpenAI, Runway } from "../src/providers.js" type StreamItem = T extends Stream.Stream ? A : never diff --git a/packages/ai/test/testing-media.test.ts b/packages/ai/test/testing-media.test.ts index 79f18eeef0c5..29be0ce84d94 100644 --- a/packages/ai/test/testing-media.test.ts +++ b/packages/ai/test/testing-media.test.ts @@ -1,23 +1,18 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, Stream } from "effect" +import { ai, AIError, DecisionResponse, ImageResponse, Media, RateLimitError } from "../src/effect.js" import { - AIError, - Decision, - DecisionResponse, - Image, - ImageResponse, - Media, - RateLimitError, Speech, SpeechResponse, Transcription, TranscriptionResponse, Video, VideoResponse, -} from "../src/effect.js" +} from "../src/experimental.js" import { AI } from "../src/index.js" import { OpenAI, Runway } from "../src/providers.js" -import { TestDecision, TestImage, TestSpeech, TestTranscription, TestVideo } from "../src/testing.js" +import { TestDecision, TestImage } from "../src/testing.js" +import { TestSpeech, TestTranscription, TestVideo } from "../src/experimental/testing.js" import { testEffect } from "./lib/effect.js" const openai = OpenAI.configure({ apiKey: "test" }) @@ -46,11 +41,11 @@ describe("media test clients", () => { yield* test.push(new ImageResponse({ images: [png] }), failure) yield* test.always(new ImageResponse({ images: [png, png] })) - expect((yield* Image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })).image).toBe(png) - expect(yield* Image.generate({ model: openai.image("gpt-image-2"), prompt: "A kite" }).pipe(Effect.flip)).toBe( + expect((yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })).image).toBe(png) + expect(yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A kite" }).pipe(Effect.flip)).toBe( failure, ) - const events = yield* Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt: "A boat" })) + const events = yield* Stream.runCollect(ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A boat" })) expect(Array.from(events, (event) => event.type)).toEqual(["image", "image", "finish"]) expect((yield* test.requests()).map((request) => request.prompt)).toEqual(["A lighthouse", "A kite", "A boat"]) }), @@ -85,7 +80,7 @@ describe("media test clients", () => { expect( (yield* Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio })).text, ).toBe("Hello there.") - const response = yield* Decision.generate({ + const response = yield* ai.decision.generate({ model: openai.decision("decision-model"), input: [{ type: "text", text: "A red apple" }], questions: [question], @@ -101,8 +96,8 @@ describe("media test clients", () => { ) test("replace the Promise client's defaults and expose their controls to run", async () => { - const ai = AI.make({ clients: doubles() }) - await ai.run( + const client = AI.make({ clients: doubles() }) + await client.run( Effect.gen(function* () { yield* (yield* TestImage.Test).push(new ImageResponse({ images: [png] })) yield* (yield* TestVideo.Test).push(new VideoResponse({ videos: [mp4] })) @@ -112,23 +107,29 @@ describe("media test clients", () => { }), ) - expect((await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })).image).toBe(png) - const generation = await ai.video.start({ model: runway, prompt: "A kite" }) - expect((await ai.video.resume(runway, generation.token).then((resumed) => resumed.await())).video).toBe(mp4) + expect((await client.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })).image).toBe( + png, + ) + const generation = await client.experimental.video.start({ model: runway, prompt: "A kite" }) + expect( + (await client.experimental.video.resume(runway, generation.token).then((resumed) => resumed.await())).video, + ).toBe(mp4) expect( - await ai.bytes((await ai.speech.generate({ model: openai.speech("gpt-4o-mini-tts"), text: "Hi" })).audio), + await client.bytes( + (await client.experimental.speech.generate({ model: openai.speech("gpt-4o-mini-tts"), text: "Hi" })).audio, + ), ).toEqual(Uint8Array.from([4, 5, 6])) const deltas = await Array.fromAsync( - ai.transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }), + client.experimental.transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }), ) expect(deltas.map((event) => event.type)).toEqual(["finish"]) - const decided = await ai.decision.generate({ + const decided = await client.decision.generate({ model: openai.decision("decision-model"), input: [{ type: "text", text: "A red apple" }], questions: [question], }) expect(decided.answers).toEqual([{ name: "red", type: "predicate", probability: 0.9 }]) - expect((await ai.run(TestImage.Test.use((test) => test.requests())))[0].prompt).toBe("A lighthouse") - await ai.dispose() + expect((await client.run(TestImage.Test.use((test) => test.requests())))[0].prompt).toBe("A lighthouse") + await client.dispose() }) }) diff --git a/packages/ai/test/testing.test.ts b/packages/ai/test/testing.test.ts index 0beec864cc2a..f34749e9aa0c 100644 --- a/packages/ai/test/testing.test.ts +++ b/packages/ai/test/testing.test.ts @@ -1,11 +1,11 @@ import { describe, expect } from "bun:test" import { + ai, AIError, + CompactionCheckpointResponse, CompactionPart, CompactionResponse, - CompactionCheckpointResponse, LanguageModel, - LLM, LLMClient, LLMEvent, LLMRequest, @@ -19,7 +19,7 @@ import { TestLLM } from "../src/testing.js" import { Effect, Fiber, Latch, Stream } from "effect" import { testEffect } from "./lib/effect.js" -const request = LLM.request({ +const request = ai.request({ model: LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }), prompt: "Say hello", }) @@ -29,14 +29,14 @@ describe("TestLLM first-class client", () => { it.effect("returns scripted checkpoints only for trigger compaction", () => Effect.gen(function* () { const client = yield* TestLLM.Test - const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" }) + const request = ai.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" }) const checkpoint = new CompactionCheckpointResponse({ checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" }, responseID: "resp_fixture", }) const endpoint = new CompactionResponse({ replacement: [] }) yield* client.push(checkpoint, endpoint, checkpoint, checkpoint) - const operation = LLMClient.compact(request, { mechanism: "trigger" }) + const operation = ai.compact(request, { mechanism: "trigger" }) expect(yield* client.requests()).toEqual([]) expect(yield* operation).toBe(checkpoint) expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe( @@ -54,7 +54,10 @@ describe("TestLLM first-class client", () => { it.effect("rejects response fixtures for the wrong operation", () => Effect.gen(function* () { const client = yield* TestLLM.Test - const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }) + const request = ai.request({ + model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), + prompt: "hello", + }) yield* client.push(TestLLM.stop(), new CompactionResponse({ replacement: [] })) expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe( "TestLLM compaction requires a CompactionResponse", @@ -68,7 +71,10 @@ describe("TestLLM first-class client", () => { it.effect("scripts replacement windows with the same lazy recording, gates, and fallback controls", () => Effect.gen(function* () { const client = yield* TestLLM.Test - const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }) + const request = ai.request({ + model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), + prompt: "hello", + }) const compacted = new CompactionResponse({ replacement: [ Message.user("retained input"), @@ -77,7 +83,7 @@ describe("TestLLM first-class client", () => { ], }) yield* client.push(compacted, TestLLM.text("continued", "answer")) - const operation = LLMClient.compact(request) + const operation = ai.compact(request) expect(yield* client.requests()).toEqual([]) const gate = yield* client.gate() const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true })) @@ -92,9 +98,9 @@ describe("TestLLM first-class client", () => { expect(observed).toBe(next) return compacted }) - expect(yield* LLMClient.compact(next)).toBe(compacted) + expect(yield* ai.compact(next)).toBe(compacted) yield* client.always(compacted) - expect(yield* LLMClient.compact(next)).toBe(compacted) + expect(yield* ai.compact(next)).toBe(compacted) expect(yield* client.requests()).toEqual([request, next, next, next]) }), ) @@ -337,7 +343,9 @@ describe("TestLLM first-class client", () => { ), ).toBe(failure) expect(observed).toEqual(events) - expect(yield* llm.generate(request).pipe(Effect.flip)).toBe(failure) + const generated = yield* llm.generate(request).pipe(Effect.flip) + expect(generated.reason).toBe(failure.reason) + expect(generated.partial?.text).toBe("partial") const defect = new Error("Broken fixture responder") yield* llm.serve(() => { diff --git a/packages/ai/test/tool-runtime.test.ts b/packages/ai/test/tool-runtime.test.ts index cf038f6fa3c8..8a811ab0771e 100644 --- a/packages/ai/test/tool-runtime.test.ts +++ b/packages/ai/test/tool-runtime.test.ts @@ -2,21 +2,21 @@ import { describe, expect, test } from "bun:test" import { Content } from "@opencode/schema/tool" import { Effect, Schema, Stream } from "effect" import { + ai, GenerationOptions, - LLM, LLMEvent, LLMRequest, LLMResponse, + toDefinitions, ToolCallPart, ToolChoice, ToolOutput, - toDefinitions, } from "../src/effect.js" import { Auth, LLMClient } from "../src/route.js" import * as AnthropicMessages from "../src/protocols/anthropic-messages.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIResponses from "../src/protocols/openai-responses.js" -import { Tool, ToolFailure, type ToolExecuteContext } from "../src/tool.js" +import { ToolFailure, tool, type ToolExecuteContext } from "../src/tool.js" import { ToolRuntime } from "../src/tool-runtime.js" import { it } from "./lib/effect.js" import * as TestToolRuntime from "./lib/tool-runtime.js" @@ -30,7 +30,7 @@ const model = OpenAIChat.route const Json = Schema.fromJsonString(Schema.Unknown) const decodeJson = Schema.decodeUnknownSync(Json) -const baseRequest = LLM.request({ +const baseRequest = ai.request({ id: "req_1", model, prompt: "Use the tool.", @@ -39,7 +39,7 @@ const weatherFailureCause = new Error("weather lookup denied") test("dispatches namespaced calls by qualified identity", async () => { let context: ToolExecuteContext | undefined - const lookup = Tool.make({ + const lookup = tool({ description: "Look up a customer.", parameters: Schema.Struct({}), success: Schema.String, @@ -58,7 +58,7 @@ test("dispatches namespaced calls by qualified identity", async () => { expect(result.events).toEqual([expect.objectContaining({ type: "tool-result", namespace: "crm", name: "lookup" })]) }) -const get_weather = Tool.make({ +const get_weather = tool({ description: "Get current weather for a city.", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), @@ -70,7 +70,7 @@ const get_weather = Tool.make({ }), }) -const schema_only_weather = Tool.make({ +const schema_only_weather = tool({ description: "Get current weather for a city.", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), @@ -175,7 +175,7 @@ describe("LLMClient tools", () => { it.effect("projects encoded typed tool success into canonical model content", () => Effect.gen(function* () { const calls: unknown[] = [] - const projected = Tool.make({ + const projected = tool({ description: "Project an encoded success.", parameters: Schema.Struct({ prefix: Schema.String }), success: Schema.Struct({ count: Schema.NumberFromString }), @@ -207,7 +207,7 @@ describe("LLMClient tools", () => { it.effect("preserves provider metadata on dispatched tool results", () => Effect.gen(function* () { - const tool = Tool.make({ + const text = tool({ description: "Return text.", parameters: Schema.Struct({}), success: Schema.String, @@ -215,14 +215,14 @@ describe("LLMClient tools", () => { }) const providerMetadata = { google: { thoughtSignature: "provider_sig" } } const dispatched = yield* ToolRuntime.dispatch( - { tool }, - LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }), + { text }, + LLMEvent.toolCall({ id: "call_1", name: "text", input: {}, providerMetadata }), ) expect(dispatched.events).toEqual([ LLMEvent.toolResult({ id: "call_1", - name: "tool", + name: "text", result: { type: "text", value: "hello" }, output: { structured: "hello", content: [{ type: "text", text: "hello" }] }, providerMetadata, @@ -255,13 +255,13 @@ describe("LLMClient tools", () => { it.effect("uses the narrow default projection for encoded typed success", () => Effect.gen(function* () { - const text = Tool.make({ + const text = tool({ description: "Return text.", parameters: Schema.Struct({}), success: Schema.String, execute: () => Effect.succeed("hello"), }) - const json = Tool.make({ + const json = tool({ description: "Return JSON.", parameters: Schema.Struct({}), success: Schema.Struct({ ok: Schema.Boolean }), @@ -279,7 +279,7 @@ describe("LLMClient tools", () => { it.effect("can retain model media while redacting duplicated structured payloads", () => Effect.gen(function* () { - const image = Tool.make({ + const image = tool({ description: "Return an image.", parameters: Schema.Struct({}), success: Schema.Struct({ mime: Schema.String, data: Schema.String }), @@ -360,7 +360,7 @@ describe("LLMClient tools", () => { it.effect("settles projected URL files as canonical tool results", () => Effect.gen(function* () { - const remote = Tool.make({ + const remote = tool({ description: "Return a remote file.", parameters: Schema.Struct({}), success: Schema.Struct({ ok: Schema.Boolean }), @@ -389,7 +389,7 @@ describe("LLMClient tools", () => { const [typed] = toDefinitions({ get_weather }) const schema = { type: "object", properties: { result: { type: "string" } } } as const const [dynamic] = toDefinitions({ - dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }), + dynamic: tool({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }), }) expect(typed?.outputSchema).toMatchObject({ @@ -406,7 +406,7 @@ describe("LLMClient tools", () => { it.effect("preserves content tool results from dynamic tools", () => Effect.gen(function* () { - const screenshot = Tool.make({ + const screenshot = tool({ description: "Capture a screenshot.", jsonSchema: { type: "object", properties: {} }, execute: () => @@ -446,7 +446,7 @@ describe("LLMClient tools", () => { it.effect("does not mistake dynamic tool output fields for dispatcher state", () => Effect.gen(function* () { const callerOwned = { type: "json" as const, value: { ok: true }, events: ["caller-owned"] } - const eventful = Tool.make({ + const eventful = tool({ description: "Return an events field.", jsonSchema: { type: "object", properties: {} }, execute: () => Effect.succeed(callerOwned), @@ -472,7 +472,7 @@ describe("LLMClient tools", () => { it.effect("projects malformed tagged dynamic output as opaque JSON", () => Effect.gen(function* () { const malformed = { type: "content", value: [{ type: "text" }] } - const dynamic = Tool.make({ + const dynamic = tool({ description: "Return caller-defined JSON.", jsonSchema: { type: "object", properties: {} }, execute: () => Effect.succeed(malformed), @@ -518,7 +518,7 @@ describe("LLMClient tools", () => { it.effect("passes tool call context to execute", () => Effect.gen(function* () { let context: ToolExecuteContext | undefined - const contextual = Tool.make({ + const contextual = tool({ description: "Capture tool context.", parameters: Schema.Struct({ value: Schema.String }), success: Schema.Struct({ ok: Schema.Boolean }), @@ -679,7 +679,7 @@ describe("LLMClient tools", () => { ) yield* TestToolRuntime.runTools({ - request: LLM.request({ + request: ai.request({ model: OpenAIResponses.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-5.5" }), diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index 018680d98d53..30e842178e4e 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" -import { LLM } from "../src/effect.js" +import { ai } from "../src/effect.js" import { AnthropicMessages, Gemini, OpenAIChat, OpenAIResponses } from "../src/protocols.js" import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js" -import { Tool, toDefinitions } from "../src/tool.js" +import { toDefinitions, tool } from "../src/tool.js" import { Auth } from "../src/route.js" import { compileRequest } from "../src/route/client.js" import { it } from "./lib/effect.js" @@ -32,14 +32,14 @@ const reversed = Token.pipe( describe("tool schema projections", () => { test("only normalizes typed empty input structs, preserving raw schemas and output schemas", () => { const schema = { not: { type: "null" } } - const definitions = Tool.toDefinitions({ - typed: Tool.make({ + const definitions = toDefinitions({ + typed: tool({ description: "Typed", parameters: Schema.Struct({}), success: Schema.Struct({}), execute: () => Effect.succeed({}), }), - raw: Tool.make({ description: "Raw", jsonSchema: schema, execute: () => Effect.succeed({}) }), + raw: tool({ description: "Raw", jsonSchema: schema, execute: () => Effect.succeed({}) }), }) expect(definitions[0]?.inputSchema).toEqual({ type: "object", properties: {}, additionalProperties: false }) expect(definitions[0]?.outputSchema).toEqual(schema) @@ -49,9 +49,9 @@ describe("tool schema projections", () => { test("retains empty input descriptions and preserves named outputs and explicit raw schemas", () => { const parameters = Schema.Struct({}).annotate({ identifier: "Ping", description: "No arguments" }) const raw = { $ref: "#/$defs/Raw", $defs: { Raw: { not: { type: "null" }, description: "Raw schema" } } } - const definitions = Tool.toDefinitions({ - typed: Tool.make({ description: "Ping", parameters, success: parameters, execute: () => Effect.succeed({}) }), - raw: Tool.make({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed({}) }), + const definitions = toDefinitions({ + typed: tool({ description: "Ping", parameters, success: parameters, execute: () => Effect.succeed({}) }), + raw: tool({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed({}) }), }) expect(definitions[0]?.inputSchema).toEqual({ type: "object", @@ -67,33 +67,33 @@ describe("tool schema projections", () => { }) test("normalizes inputs whose encoded side is an empty struct", () => { - const definitions = Tool.toDefinitions({ - defaulted: Tool.make({ description: "Defaulted", parameters: defaulted, success: defaulted }), - named: Tool.make({ + const definitions = toDefinitions({ + defaulted: tool({ description: "Defaulted", parameters: defaulted, success: defaulted }), + named: tool({ description: "Named", parameters: defaulted.annotate({ identifier: "Defaulted" }), success: Schema.String, }), - encodedMetadata: Tool.make({ + encodedMetadata: tool({ description: "Encoded metadata", parameters: Schema.Struct({}) .annotate({ identifier: "Ping", title: "Ping", description: "No arguments" }) .pipe(Schema.decodeTo(Token, fromEmpty)), success: Schema.String, }), - decodedCheck: Tool.make({ + decodedCheck: tool({ description: "Decoded check", parameters: defaulted.check(Schema.makeFilter(() => true, { toJsonSchema: () => ({ minProperties: 1 }) })), success: Schema.String, }), - reversed: Tool.make({ description: "Reversed", parameters: reversed, success: Schema.String }), - encodedCheck: Tool.make({ + reversed: tool({ description: "Reversed", parameters: reversed, success: Schema.String }), + encodedCheck: tool({ description: "Encoded check", parameters: Schema.Struct({}).check(Schema.isMinProperties(1)), success: Schema.String, }), // A filter without a JSON Schema form emits nothing, so the input still describes an empty object. - encodedFilter: Tool.make({ + encodedFilter: tool({ description: "Encoded filter", parameters: Schema.Struct({}).check(Schema.makeFilter(() => true)), success: Schema.String, @@ -158,30 +158,30 @@ describe("tool schema projections", () => { it.effect(`${scenario.route.id} prepares empty Effect Struct tools alongside nonempty and raw schemas`, () => Effect.gen(function* () { const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: scenario.route.with({ auth: Auth.bearer("test") }).model({ id: "test-model" }), prompt: "Use a tool.", - tools: Tool.toDefinitions({ - ping: Tool.make({ + tools: toDefinitions({ + ping: tool({ description: "Ping", parameters: Schema.Struct({}), success: Schema.String, execute: () => Effect.succeed("pong"), }), - lookup: Tool.make({ + lookup: tool({ description: "Lookup", parameters: Schema.Struct({ query: Schema.String }), success: Schema.String, execute: (input) => Effect.succeed(input.query), }), - raw: Tool.make({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed("raw") }), - reversed: Tool.make({ + raw: tool({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed("raw") }), + reversed: tool({ description: "Reversed", parameters: reversed, success: Schema.String, execute: () => Effect.succeed("reversed"), }), - checked: Tool.make({ + checked: tool({ description: "Checked", parameters: Schema.Struct({}).check(Schema.isMinProperties(1)), success: Schema.String, @@ -282,14 +282,14 @@ describe("tool schema projections", () => { }) const parameters = (inputSchema: Record, model = route.model({ id: "gpt-6-luna" })) => compileRequest( - LLM.request({ + ai.request({ model, prompt: "Use the tool.", tools: [{ name: "lookup", description: "Lookup data.", inputSchema }], }), ).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters)) const parameterless = toDefinitions({ - lookup: Tool.make({ + lookup: tool({ description: "Lookup data.", parameters: Schema.Struct({}).annotate({ description: "No input." }), success: Schema.String, @@ -342,7 +342,7 @@ describe("tool schema projections", () => { } const parameters = (model: ReturnType) => compileRequest( - LLM.request({ + ai.request({ model, prompt: "Use the tool.", tools: [{ name: "lookup", description: "Lookup data.", inputSchema: original }], @@ -375,7 +375,7 @@ describe("tool schema projections", () => { .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "kimi-k2", compatibility: { sanitizer: "moonshot" } }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model, prompt: "Use the tool.", tools: [ diff --git a/packages/ai/test/tool.types.ts b/packages/ai/test/tool.types.ts index bc5dcfd9c44e..f9bb73d8ad11 100644 --- a/packages/ai/test/tool.types.ts +++ b/packages/ai/test/tool.types.ts @@ -1,29 +1,28 @@ import { Effect, Schema } from "effect" -import { LLM, LLMRequest, ToolRuntime, toDefinitions } from "../src/effect.js" +import { ai, type ExecutableTool, LLMRequest, toDefinitions, tool, ToolRuntime } from "../src/effect.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import { Auth } from "../src/route.js" -import { Tool } from "../src/tool.js" import { AI } from "../src/index.js" -const request = LLM.request({ +const request = ai.request({ model: OpenAIChat.route.with({ auth: Auth.bearer("fixture") }).model({ id: "gpt-4o-mini" }), prompt: "Use the tool.", }) -const executable = Tool.make({ +const executable = tool({ description: "Get weather.", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ forecast: Schema.String }), execute: (input) => Effect.succeed({ forecast: input.city }), }) -const schemaOnly = Tool.make({ +const schemaOnly = tool({ description: "Get weather.", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ forecast: Schema.String }), }) -Tool.make({ +tool({ description: "Encode success before projection.", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ forecast: Schema.NumberFromString }), @@ -33,16 +32,16 @@ Tool.make({ ], }) -LLM.stream(request) -LLM.generate(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) })) +ai.stream(request) +ai.generate(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) })) ToolRuntime.dispatch({ executable }, { type: "tool-call", id: "call_1", name: "executable", input: { city: "Paris" } }) // @ts-expect-error High-level tool orchestration overloads are intentionally not supported. -LLM.stream({ request, tools: { schemaOnly } }) +ai.stream({ request, tools: { schemaOnly } }) const code = Schema.Struct({ code: Schema.String }) -Tool.make({ +tool({ description: "Run JavaScript.", parameters: code, success: Schema.String, @@ -57,11 +56,11 @@ AI.tool({ execute: (input) => input.code, }) -Tool.make({ +tool({ description: "Run JavaScript.", - // @ts-expect-error A freeform tool's only parameter must be a string. parameters: Schema.Struct({ code: Schema.Number }), success: Schema.String, + // @ts-expect-error A freeform tool's only parameter must be a string. freeform: { type: "text" }, }) @@ -80,3 +79,19 @@ AI.tool({ freeform: { type: "text" }, execute: () => undefined, }) + +const sum = { + description: "Add two numbers.", + parameters: Schema.Struct({ a: Schema.Number, b: Schema.Number }), + success: Schema.Number, + toModelOutput: ({ output }: { readonly output: number }) => [{ type: "text" as const, text: String(output) }], +} +const declared: AI.Tool = AI.tool(sum) +const effectSum: ExecutableTool = tool({ ...sum, execute: ({ a, b }) => Effect.succeed(a + b) }) +const promiseSum: AI.ExecutableTool = AI.tool({ ...sum, execute: async ({ a, b }) => a + b }) +toDefinitions({ declared, effectSum, promiseSum, dynamic: AI.tool({ description: "Look up.", jsonSchema: {} }) }) + +// @ts-expect-error The Effect tool's execute returns an Effect. +tool({ ...sum, execute: ({ a, b }) => a + b }) +// @ts-expect-error The execute result must match `success`. +AI.tool({ ...sum, execute: async () => "3" }) diff --git a/packages/ai/test/transcription.test.ts b/packages/ai/test/transcription.test.ts index 05c4f6a42299..74030febf12d 100644 --- a/packages/ai/test/transcription.test.ts +++ b/packages/ai/test/transcription.test.ts @@ -2,7 +2,8 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" import { HttpClientRequest } from "effect/http" -import { Media, Transcription, TranscriptionClient, type TranscriptionEvent } from "../src/effect.js" +import { Media } from "../src/effect.js" +import { Transcription, TranscriptionClient, type TranscriptionEvent } from "../src/experimental.js" import { AssemblyAI, Deepgram, ElevenLabs, Google, OpenAI } from "../src/providers.js" import { it } from "./lib/effect.js" import { dynamicResponse, json, observe, type Call } from "./lib/http.js" diff --git a/packages/ai/test/transcription.types.ts b/packages/ai/test/transcription.types.ts index c9522953853c..903e0b8f8e07 100644 --- a/packages/ai/test/transcription.types.ts +++ b/packages/ai/test/transcription.types.ts @@ -1,11 +1,11 @@ import type { Stream } from "effect" +import { Media } from "../src/effect.js" import { - Media, Transcription, TranscriptionModel, type TranscriptionEvent, type TranscriptionOptions, -} from "../src/effect.js" +} from "../src/experimental.js" import { AssemblyAI, Deepgram, OpenAI } from "../src/providers.js" type StreamItem = T extends Stream.Stream ? A : never diff --git a/packages/ai/test/video.test.ts b/packages/ai/test/video.test.ts index 3aef30e0582d..3fe4d4ef77e8 100644 --- a/packages/ai/test/video.test.ts +++ b/packages/ai/test/video.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" -import { Media, Video, VideoClient, type GenerationEvent, type VideoEvent } from "../src/effect.js" +import { Media, type GenerationEvent } from "../src/effect.js" +import { Video, VideoClient, type VideoEvent } from "../src/experimental.js" import { Google, Runway, XAI } from "../src/providers.js" import { it } from "./lib/effect.js" import { dynamicResponse, json, observe, settle, type Call, type HandlerInput } from "./lib/http.js" @@ -1043,15 +1044,33 @@ describe("Video / queued result", () => { ) } - it.effect("rejects a status that only matches an inherited property", () => + it.effect("keeps polling through unknown statuses, including inherited property names", () => Effect.gen(function* () { - const error = yield* Video.resume( - XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"), - { requestID: "req_1" }, - ).pipe(Effect.flip) - expect(error.reason._tag).toBe("InvalidProviderOutput") - expect(error.message).toBe('Unknown generation status "constructor"') - expect(error.reason.body).toBe(JSON.stringify({ status: "constructor" })) - }).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { status: "constructor" }))))), + const calls: Array = [] + const response = yield* settle( + Video.generate( + { + model: Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5"), + prompt: "x", + }, + { poll: { interval: "1 second" } }, + ), + 3, + ).pipe( + Effect.provide( + layer((input) => + Effect.gen(function* () { + const { call, nth } = yield* observe(calls, input) + if (call.method === "POST") return json(input, { id: "task_1" }) + if (nth === 1) return json(input, { id: "task_1", status: "WARMING_UP" }) + if (nth === 2) return json(input, { id: "task_1", status: "constructor" }) + return json(input, { id: "task_1", status: "SUCCEEDED", output: ["https://runway.test/out.mp4"] }) + }), + ), + ), + ) + expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" }) + expect(calls.map((call) => call.method)).toEqual(["POST", "GET", "GET", "GET", "GET"]) + }), ) }) diff --git a/packages/ai/test/video.types.ts b/packages/ai/test/video.types.ts index f7ac2b81129d..9e854672b1c7 100644 --- a/packages/ai/test/video.types.ts +++ b/packages/ai/test/video.types.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" +import { Generation, Media } from "../src/effect.js" import { - Generation, - Media, Video, VideoClient, VideoModel, @@ -9,7 +8,7 @@ import { type VideoModelOptions, type VideoOptions, type VideoRequestFor, -} from "../src/effect.js" +} from "../src/experimental.js" import type { Service } from "../src/video-client.js" import { Anthropic, Google, OpenAI, Runway, XAI } from "../src/providers.js" diff --git a/packages/ai/tsconfig.types.json b/packages/ai/tsconfig.types.json index 911f47029d01..fd12c46e32a2 100644 --- a/packages/ai/tsconfig.types.json +++ b/packages/ai/tsconfig.types.json @@ -8,6 +8,8 @@ "include": [ "example/**/*.ts", "test/**/*.types.ts", + "test/loop.test.ts", + "test/promise.test.ts", "test/testing.test.ts", "test/testing-media.test.ts", "test/provider/checkpoint.test.ts" diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 5918a0f328a8..928eccf179e8 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -15,6 +15,13 @@ - App (from `packages/app`): `bun dev -- --port 4444` - Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`). +## Insecure Origins (HTTP LAN) + +The Web UI is served over plain HTTP on non-localhost LAN and Tailscale origins (`isSecureContext === false`). This rule applies across `app`, `ui`, `session-ui` and `gui-plugins` window code. + +- Never call `crypto.randomUUID()` directly in renderer or browser code; it is `[SecureContext]`-only and throws `TypeError: crypto.randomUUID is not a function` on plain HTTP origins. Always use `uuid()` from `@opencode/util/uuid`. +- Guard any other `[SecureContext]`-only Web API (`crypto.subtle`, `navigator.clipboard`, `navigator.serviceWorker`) with a non-secure fallback. + ## SolidJS - Always prefer `createStore` over multiple `createSignal` calls diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index cb2a0c7db6d2..ef5fc0f6ba60 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -463,6 +463,40 @@ story("keeps toolbar focus rings whole and keyboard-only", async ({ mount, page await expect(variant).toHaveCSS("outline-style", "none") }) +story("keeps command selection on the keyboard until the pointer moves", async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--slash-suggestions") + + const input = component.getByRole("textbox", { name: "Prompt", exact: true }) + + const menu = component.locator('[data-component="composer-suggestions"]') + + const test = menu.getByRole("button", { name: "/test", exact: true }) + + const review = menu.getByRole("button", { name: "/review", exact: true }) + + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await expect(test).toHaveAttribute("data-active", "") + + const box = await review.boundingBox() + + if (!box) throw new Error("Missing review command") + + await input.press("Escape") + await expect(menu).toHaveCount(0) + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await input.fill("/") + await expect(test).toHaveAttribute("data-active", "") + await expect(review).toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + + // Layout changes can deliver pointer events without any actual mouse movement. + await review.dispatchEvent("pointermove", { movementX: 0, movementY: 0 }) + await expect(test).toHaveAttribute("data-active", "") + + await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2) + await expect(review).toHaveAttribute("data-active", "") + await expect(test).not.toHaveAttribute("data-active") +}) + story("grows suggestions while preserving visible timeline context", async ({ mount }) => { const component = await mount("opencode-composer-flow--constrained-command-suggestions") const boundary = component.locator('[data-slot="composer-suggestion-boundary-story"]') diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts index c7d07611b7a2..a9bd0538125d 100644 --- a/packages/app/component-tests/session-question-dock.spec.ts +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -2,6 +2,70 @@ import type { Locator } from "@playwright/test" import type { MotionKnob } from "@opencode/ui/motion-tuning" import { expect, sourceURL, story } from "../../stories/playwright/story" +for (const variant of ["classic", "experimental"] as const) { + story(`${variant} answer cards allow text selection without changing answers`, async ({ mount, page }) => { + await page.addInitScript( + (enabled) => localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: enabled } })), + variant === "experimental", + ) + + const component = await mount("app-current-session-surface--question-request") + + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + + const wide = question.getByRole("radio", { name: /Wide/ }) + + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + + const drag = async (text: Locator) => { + await text.scrollIntoViewIfNeeded() + + const box = await text.boundingBox() + + expect(box).not.toBeNull() + await page.mouse.move(box!.x + 1, box!.y + box!.height / 2) + await page.mouse.down() + await page.mouse.move(box!.x + box!.width - 1, box!.y + box!.height / 2, { steps: 12 }) + await page.mouse.up() + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(await text.innerText()) + } + + for (const slot of ["option-label", "option-description"]) { + await drag(wide.locator(`[data-slot="${slot}"] bdi`)) + await expect(wide).not.toBeChecked() + } + + await wide.locator('[data-slot="option-description"] bdi').click({ modifiers: ["Shift"] }) + await expect(wide).not.toBeChecked() + await wide.click({ position: { x: 4, y: 4 } }) + await expect(wide).toBeChecked() + await question.getByRole("radio", { name: /Focused/ }).click() + await drag(wide.locator('[data-slot="option-description"] bdi')) + await expect(wide).not.toBeChecked() + + const mark = await wide.locator('[data-slot="question-option-check"]').boundingBox() + + expect(mark).not.toBeNull() + await page.mouse.move(mark!.x + mark!.width / 2, mark!.y + mark!.height / 2) + await page.mouse.down() + await page.mouse.move(mark!.x + mark!.width / 2 + 1, mark!.y + mark!.height / 2) + await page.mouse.up() + await expect(wide).toBeChecked() + await question.getByRole("button", { name: "Next", exact: true }).click() + + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + + await expect(files).toBeFocused() + await files.click() + await expect(files).toBeChecked() + await drag(files.locator('[data-slot="option-label"] bdi')) + await expect(files).toBeChecked() + await files.click({ position: { x: 4, y: 4 } }) + await expect(files).not.toBeChecked() + }) +} + story( "uses the original question cards by default and submits selected and custom answers", async ({ mount, page }) => { @@ -32,7 +96,9 @@ story( const submit = question.getByRole("button", { name: "Submit", exact: true }) expect(await submit.evaluate((button) => button.getBoundingClientRect().width)).toBe(width) await expect(shortcuts).toHaveText(["1", "2", "3", "4"]) - await files.click() + await page.keyboard.down(" ") + await page.keyboard.down(" ") + await page.keyboard.up(" ") await terminal.click() await expect(files).toBeChecked() await expect(terminal).toBeChecked() @@ -80,6 +146,90 @@ story("keeps classic long answer lists inside the panel and scrolls them", async await expect(question.getByRole("radio", { name: "One pull request", exact: true })).toBeFocused() }) +story.describe("on a 393x852 mobile viewport", () => { + story.use({ hasTouch: true, isMobile: true, viewport: { width: 393, height: 852 } }) + + for (const experimental of [false, true]) { + story( + `keeps navigation visible and scrolls long prompts and 12 options (${experimental ? "experimental" : "classic"})`, + async ({ mount, page }) => { + if (experimental) { + await page.addInitScript(() => + localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: true } })), + ) + } + + const component = await mount("session-question-switch--long-prompt") + expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true) + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const dismiss = question.getByRole("button", { name: "Dismiss", exact: true }) + const next = question.getByRole("button", { name: "Next", exact: true }) + await expect(dismiss).toBeInViewport({ ratio: 1 }) + await expect(next).toBeInViewport({ ratio: 1 }) + + const twelfth = question.getByRole("radio", { name: /12\. Clean slate rebuild/ }) + await twelfth.scrollIntoViewIfNeeded() + await expect(twelfth).toBeInViewport({ ratio: 1 }) + await twelfth.tap() + await expect(twelfth).toBeChecked() + await expect(next).toBeInViewport({ ratio: 1 }) + + await next.tap() + + const back = question.getByRole("button", { name: "Back", exact: true }) + const submit = question.getByRole("button", { name: "Submit", exact: true }) + await expect(back).toBeInViewport({ ratio: 1 }) + await expect(submit).toBeInViewport({ ratio: 1 }) + + const detailsSlot = experimental ? '[data-slot="question-details-frame"]' : '[data-slot="question-details"]' + + const layout = await question.evaluate((root, selector) => { + const details = root.querySelector(selector)! + const options = root.querySelector('[data-slot="question-options"]')! + const footer = root.querySelector('[data-slot="question-footer"]')! + const detailsRect = details.getBoundingClientRect() + const optionsRect = options.getBoundingClientRect() + const footerRect = footer.getBoundingClientRect() + + details.scrollTop = details.scrollHeight + const scrolledPrompt = details.scrollTop > 0 + + return { + promptScrollHeight: details.scrollHeight, + promptClientHeight: details.clientHeight, + scrolledPrompt, + optionsHeight: optionsRect.height, + optionsBelowPrompt: optionsRect.top >= detailsRect.bottom - 0.5, + footerBelowOptions: footerRect.top >= optionsRect.bottom - 9.5, + dockFitsViewport: root.getBoundingClientRect().bottom <= innerHeight + 0.5, + } + }, detailsSlot) + + expect(layout.promptScrollHeight).toBeGreaterThan(852) + expect(layout.promptClientHeight).toBeGreaterThan(48) + expect(layout.promptScrollHeight).toBeGreaterThan(layout.promptClientHeight) + expect(layout.scrolledPrompt).toBe(true) + expect(layout.optionsHeight).toBeGreaterThan(80) + expect(layout.optionsBelowPrompt).toBe(true) + expect(layout.footerBelowOptions).toBe(true) + expect(layout.dockFitsViewport).toBe(true) + + const lastOption = question.getByRole("radio", { name: /Export a diagnostic bundle/ }) + await lastOption.scrollIntoViewIfNeeded() + await expect(lastOption).toBeInViewport({ ratio: 1 }) + await lastOption.tap() + await expect(lastOption).toBeChecked() + await expect(submit).toBeInViewport({ ratio: 1 }) + + await submit.tap() + await expect(question).toBeHidden() + }, + ) + } +}) + story( "minimizes and restores classic questions immediately for reduced motion and keyboard actions", async ({ mount, page }) => { diff --git a/packages/app/component-tests/terminal-focus.spec.ts b/packages/app/component-tests/terminal-focus.spec.ts deleted file mode 100644 index e2962676095c..000000000000 --- a/packages/app/component-tests/terminal-focus.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Page } from "@playwright/test" -import { expect, sourceURL, story } from "../../stories/playwright/story" - -const fixture = sourceURL(new URL("../../gui-plugins/src/terminal/terminal.fixture.tsx", import.meta.url)) - -const mountTerminal = async (page: Page, autoFocus: boolean) => { - await page.evaluate( - async (input) => { - const { mountTerminalFocus } = await import(input.fixture) - mountTerminalFocus({ autoFocus: input.autoFocus }) - }, - { fixture, autoFocus }, - ) - const root = page.getByTestId("terminal-focus-fixture") - await expect(root.locator('[data-component="terminal"] textarea')).toBeAttached() - // The terminal's deferred focus and restore run on timers queued when it opened; let them run first. - await page.evaluate( - () => new Promise((resolve) => setTimeout(() => setTimeout(() => requestAnimationFrame(() => resolve()), 0), 0)), - ) - - return root -} - -story.beforeEach(async ({ mount }) => { - // Any story loads the app styles; the fixture mounts the real terminal beside it. - await mount("ui-line-comment--editor") -}) - -story("a terminal opened with a focus request keeps focus after the request is consumed", async ({ page }) => { - const root = await mountTerminal(page, true) - - await expect(root.locator('[data-component="terminal"] textarea')).toBeFocused() -}) - -story("a terminal opened without a focus request leaves focus in the composer", async ({ page }) => { - const root = await mountTerminal(page, false) - - await expect(root.getByRole("textbox", { name: "Composer", exact: true })).toBeFocused() -}) - -story("disposing a terminal invalidates its retained quote anchor", async ({ page }) => { - await page.evaluate(async (fixture) => { - const { mountTerminalQuote } = await import(fixture) - await mountTerminalQuote() - }, fixture) - const root = page.getByTestId("terminal-quote-fixture") - await root.getByRole("button", { name: "Select output", exact: true }).click() - await page.getByRole("toolbar", { name: "quote.actions", exact: true }).getByRole("button").click() - await expect(root.getByLabel("Quoted output", { exact: true })).toHaveText("Retained terminal output") - await expect(root.getByLabel("Quote placement", { exact: true })).toHaveText("Source") - await root.getByRole("button", { name: "Dispose source", exact: true }).click() - - await expect(root.getByTestId("terminal-quote-source")).toHaveCount(0) - await expect(root.getByLabel("Quote placement", { exact: true })).toHaveText("Tile") - await expect(root.getByLabel("Quoted output", { exact: true })).toHaveText("Retained terminal output") -}) diff --git a/packages/app/component-tests/terminal.spec.ts b/packages/app/component-tests/terminal.spec.ts new file mode 100644 index 000000000000..d97e93bdf809 --- /dev/null +++ b/packages/app/component-tests/terminal.spec.ts @@ -0,0 +1,138 @@ +import type { Locator, Page, WebSocketRoute } from "@playwright/test" +import { expect, sourceURL, story } from "../../stories/playwright/story" + +const fixture = sourceURL(new URL("../../gui-plugins/src/terminal/terminal.fixture.tsx", import.meta.url)) + +// No page loads anything from this origin; the routes below answer its terminal API and socket. +const SERVER = "http://127.0.0.1:4599" + +const SIZE = { cols: 80, rows: 24 } + +type Frame = { type: number; data: string } + +/** + * A server with one terminal that already wrote `output`: it answers the snapshot and connect ticket, then speaks the + * persistent PTY socket protocol at `inputProtocol`. A missing `output` is a terminal the server no longer has. + */ +async function serveTerminal(page: Page, input: { ptyID: string; output?: string; inputProtocol?: 0 | 1 }) { + const frames: Frame[] = [] + const sockets: WebSocketRoute[] = [] + const tail = Buffer.byteLength(input.output ?? "") + + const info = { + id: input.ptyID, + title: "Terminal 1", + userTitle: null, + command: "bash", + args: [], + cwd: "C:/OpenCode/Terminal", + status: "running", + pid: 1, + sessionID: "ses_terminal", + foregroundProcess: null, + size: SIZE, + output: { head: 0, tail }, + } + + await page.route(`${SERVER}/api/experimental/persistent-pty/**`, (route) => { + const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "*" } + + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) + + if (input.output === undefined) { + return route.fulfill({ + status: 404, + headers, + // SAFETY: the server's wire body for a terminal it does not have. + // oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above + json: { _tag: "PtyNotFoundError", ptyID: input.ptyID, message: `PTY session not found: ${input.ptyID}` }, + }) + } + + if (route.request().url().endsWith("/snapshot")) { + const checkpoint = Buffer.from(input.output).toString("base64") + + return route.fulfill({ + headers, + json: { data: { info, text: input.output, checkpoint, cursor: { x: 0, y: 0 } } }, + }) + } + + return route.fulfill({ headers, json: { data: { ticket: "component-ticket", expires_in: 60 } } }) + }) + + // The socket mock patches the page's WebSocket when it loads, so this runs before the story mounts. + await page.routeWebSocket(new RegExp(`/api/experimental/persistent-pty/${input.ptyID}/connect`), (ws) => { + const url = new URL(ws.url()) + const attachmentID = url.searchParams.get("attachment_id") ?? "" + sockets.push(ws) + ws.onMessage((message) => { + const data = Buffer.from(message) + + frames.push({ type: data[0] ?? -1, data: data.subarray(5).toString() }) + }) + ws.send( + JSON.stringify({ + type: "attached", + attachmentID, + inputProtocol: input.inputProtocol ?? 1, + info, + role: "controller", + generation: 1, + replay: { requestedOffset: tail, availableOffset: 0, endOffset: tail, truncated: false }, + }), + ) + ws.send(JSON.stringify({ type: "replay_complete", endOffset: tail })) + }) + + return { frames, sockets } +} + +async function mountTerminal(page: Page, mount: (id: string) => Promise, ptyID: string) { + // Any story loads the app styles; the fixture mounts the pane beside it. + await mount("ui-line-comment--editor") + await page.evaluate( + async (input) => { + const { mountTerminal } = await import(input.fixture) + mountTerminal({ url: input.server, ptyID: input.ptyID }) + }, + { fixture, server: SERVER, ptyID }, + ) + + return page.getByTestId("terminal-fixture") +} + +story("does not send the emulator's own answers to terminal queries as input", async ({ page, mount }) => { + // Device status and attribute queries, in the restored screen and in live output. The daemon answers them. + const server = await serveTerminal(page, { ptyID: "pty_queries", output: "restored\x1b[6n\x1b[c\r\n" }) + const root = await mountTerminal(page, mount, "pty_queries") + + await expect.poll(() => server.sockets.length).toBe(1) + await expect.poll(() => server.frames.some((frame) => frame.type === 0)).toBe(true) + server.sockets[0]!.send(Buffer.from("live\x1b[6n\x1b[5n\x1b[c\r\n")) + + // Typed input lands after the output was written, so any answer would already have been sent. + await expect(root.locator("textarea")).toBeFocused() + await page.keyboard.type("x") + await expect.poll(() => server.frames.filter((frame) => frame.type === 1).length).toBeGreaterThan(0) + expect(server.frames.filter((frame) => frame.type === 1).map((frame) => frame.data)).toEqual(["x"]) +}) + +story("tells the user a server that predates the input protocol is too old", async ({ page, mount }) => { + await serveTerminal(page, { ptyID: "pty_old", output: "old server\r\n", inputProtocol: 0 }) + const root = await mountTerminal(page, mount, "pty_old") + + await expect( + root.getByText("This server is too old for terminals; update OpenCode on it", { exact: true }), + ).toBeVisible() + await expect(root.locator("[data-failures]")).toHaveAttribute("data-failures", "1") +}) + +story("says the connection failed when the terminal cannot be restored", async ({ page, mount }) => { + const server = await serveTerminal(page, { ptyID: "pty_missing" }) + const root = await mountTerminal(page, mount, "pty_missing") + + await expect(root.getByText("Terminal connection failed", { exact: true })).toBeVisible() + await expect(root.locator("[data-failures]")).toHaveAttribute("data-failures", "1") + expect(server.sockets).toEqual([]) +}) diff --git a/packages/app/component-tests/timeline-virtualizer.fixture.tsx b/packages/app/component-tests/timeline-virtualizer.fixture.tsx index d7b667667fa7..c25e0d34a74e 100644 --- a/packages/app/component-tests/timeline-virtualizer.fixture.tsx +++ b/packages/app/component-tests/timeline-virtualizer.fixture.tsx @@ -19,7 +19,7 @@ export function mountTimelineVirtualizer(input: { document.body.appendChild(host) function Fixture() { - const [state, setState] = createStore({ pinned: true, ready: false }) + const [state, setState] = createStore({ pinned: true, ready: false, grown: false }) const rows = Array.from( { length: input.count }, @@ -31,6 +31,7 @@ export function mountTimelineVirtualizer(input: { let viewport!: HTMLDivElement let content!: HTMLDivElement let container!: HTMLDivElement + let scrollToEnd = () => {} const timeline = createTimelineVirtualizer({ sessionKey: () => "cold-reveal-fixture", @@ -50,9 +51,10 @@ export function mountTimelineVirtualizer(input: { viewport = element resize.observe(element, { box: "border-box" }) }, - setContentRef: (element) => { - content = element - reveal.observe(element, { attributes: true, attributeFilter: ["style"] }) + onContentResize: () => { + if (content) return + content = host.querySelector("[data-timeline-virtual-content]")! + reveal.observe(content, { attributes: true, attributeFilter: ["style"] }) }, onPin: () => setState("pinned", true), onUnpin: () => setState("pinned", false), @@ -65,6 +67,9 @@ export function mountTimelineVirtualizer(input: { onUserScroll: () => {}, onHistoryScroll: () => {}, canRenderImmediately: () => input.immediate ?? false, + setScrollToEnd: (fn) => { + scrollToEnd = fn + }, }) const resize = new ResizeObserver((entries) => { @@ -117,6 +122,12 @@ export function mountTimelineVirtualizer(input: { > Reconnect ready rows + +

diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts index 994570d69467..bece9cd84f0c 100644 --- a/packages/app/component-tests/timeline-virtualizer.spec.ts +++ b/packages/app/component-tests/timeline-virtualizer.spec.ts @@ -89,6 +89,45 @@ story("treats the reachable end as the end under page zoom", async ({ page }) => await expect(controls).toHaveAttribute("data-pinned", "true") }) +story("leaves a reader who scrolled up in place after a scroll to the zoomed end", async ({ page }) => { + // At 110% the end the browser reaches sits more than a pixel short of scrollHeight - clientHeight. TanStack kept + // scrolling toward that max on every frame for five seconds, so a row that grew meanwhile pulled the reader back down. + await page.evaluate(async (fixture) => { + document.documentElement.style.zoom = "1.1" + const { mountTimelineVirtualizer } = await import(fixture) + mountTimelineVirtualizer({ count: 40, rowHeight: 59.3, immediate: true, viewportHeight: 200.5 }) + }, fixture) + const root = page.getByTestId("timeline-virtualizer-fixture") + await root.getByRole("button", { name: "Complete Markdown", exact: true }).click() + await expect(root.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible") + const viewport = root.locator("[data-scrollable]") + const distance = () => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop) + expect(await distance()).toBeGreaterThan(1.01) + + // A scroll command settles or scrolls again on its next frame. + const frames = () => + page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + await root.getByRole("button", { name: "Scroll to end", exact: true }).click() + await frames() + // The wheel and the scroll it causes arrive together. A wheel alone would leave an earlier arrival at the end free to + // pin again once that scroll settles. + await viewport.evaluate( + (element) => + new Promise((resolve) => { + element.addEventListener("scroll", resolve, { once: true }) + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -1, bubbles: true })) + element.scrollTop -= 40 + }), + ) + await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false") + const height = await viewport.evaluate((element) => element.scrollHeight) + await root.getByRole("button", { name: "Grow last row", exact: true }).click() + await expect.poll(() => viewport.evaluate((element) => element.scrollHeight)).toBeGreaterThan(height + 30) + await frames() + expect(await distance()).toBeGreaterThan(70) +}) + for (const input of [ { name: "offset-only", count: 1, rowHeight: 600, reveal: { scrollTop: 0 } }, { name: "zero-height", count: 4, rowHeight: 60, reveal: {} }, diff --git a/packages/app/component-tests/toast.spec.ts b/packages/app/component-tests/toast.spec.ts new file mode 100644 index 000000000000..e4681e2ac8c1 --- /dev/null +++ b/packages/app/component-tests/toast.spec.ts @@ -0,0 +1,23 @@ +import { expect, sourceURL, story } from "../../stories/playwright/story" + +const toastModule = sourceURL(new URL("../../ui/src/feedback/toast/toast.tsx", import.meta.url)) + +story.beforeEach(async ({ mount }) => { + await mount("ui-toast--all-examples") +}) + +story("keeps toasts hidden behind the stack out of reach of the keyboard", async ({ page }) => { + await page.evaluate(async (toastModule) => { + const { showToast } = await import(toastModule) + const titles = ["First", "Second", "Third", "Fourth"] + + titles.forEach((title) => showToast({ title, persistent: true })) + }, toastModule) + await expect(page.locator(".toast-v2-region .toast-v2")).toHaveCount(4) + + const hidden = page.locator('.toast-v2-region .toast-v2[data-visible="false"]') + await expect(hidden).toHaveCount(1) + await expect(hidden).toHaveJSProperty("inert", true) + await expect(hidden).toHaveAttribute("tabindex", "-1") + await expect(page.locator('.toast-v2-region .toast-v2[data-visible="true"][tabindex="0"]')).toHaveCount(3) +}) diff --git a/packages/app/e2e/performance/terminals/README.md b/packages/app/e2e/performance/terminals/README.md deleted file mode 100644 index 4b9fd6a05f9d..000000000000 --- a/packages/app/e2e/performance/terminals/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Native Terminal Benchmark - -Manual Windows benchmark. Run only in an isolated development worktree. It does -not connect to an OpenCode service, user profile, or database. - -Build from `packages/app` with -`bun x vite build --config e2e/performance/terminals/vite.config.ts`, then freeze -`dist` outside the repository. Set `PLAYWRIGHT_BUILD=1`, `PLAYWRIGHT_BASE_URL` to -an unused loopback URL, `TERMINAL_BUILD` to the frozen build, -`TERMINAL_ARTIFACTS` to an existing external directory, and `TERMINAL_RESULTS` -to an external result directory. Run: - -```sh -bun x playwright test --config e2e/performance/terminals/playwright.config.ts --repeat-each=20 -``` - -The runner owns its preview server and each test owns a PowerShell ConPTY process. -Session metadata is deterministic. Native output is forwarded through Playwright's -WebSocket fixture into the real production `Terminal`, writer, Ghostty WASM/canvas, -and serializer. No output is dropped, paused, or delayed. This is native terminal -plus production renderer evidence, not the production PTY backend or Electron IPC. - -The workload is 12,000 colored build/test log lines with file paths, durations, and -result descriptions. Cases separate visible output, the same output while hidden, -and closing the session tab after filling the configured scrollback. Ghostty -converts the app's 10,000-line setting to bytes at its initial 80-column width; -resizing reduces the effective row capacity. The report records actual retained -rows and the first retained fixture record rather than assuming 10,000 rows. Completion -requires the final marker in Ghostty and completion of its write callbacks, not -just WebSocket delivery. Teardown requires Home readiness and the final serialized -snapshot. Input, focus, resizing, and native process survival are checked. - -`probe.ts` is included only by this benchmark build. It observes actual writes, -renderer calls, and serialization. Chrome `TaskDuration` measures renderer task -time, not total process CPU or RAM. For attribution, set -`OPENCODE_PERFORMANCE_TRACE_DIR`; keep traced runs separate from clean timing. -`TERMINAL_DRAW_PROBE=1` separately counts actual canvas draws to verify hidden -rendering; do not mix these instrumented samples with clean timing. -Use `TERMINAL_REVISION` and `TERMINAL_BUNDLE` to identify frozen artifacts. -`TERMINAL_SCREENSHOTS` captures the visible result after timing. - -The benchmark has no machine-dependent performance thresholds. Keep raw logs, -snapshots, traces, and screenshots outside Git. Run heavy work through the -coordinator's exclusive gate when participating in a shared performance wave. diff --git a/packages/app/e2e/performance/terminals/playwright.config.ts b/packages/app/e2e/performance/terminals/playwright.config.ts deleted file mode 100644 index 46de831b0a72..000000000000 --- a/packages/app/e2e/performance/terminals/playwright.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from "@playwright/test" -import config from "../../../playwright.config" - -export default defineConfig({ - ...config, - testDir: ".", - testIgnore: [], - testMatch: "terminal-benchmark.spec.ts", - workers: 1, - retries: 0, - timeout: 120_000, - outputDir: process.env.TERMINAL_RESULTS, - reporter: [["line"]], - webServer: { - command: `bun x vite preview --host 127.0.0.1 --port ${new URL(process.env.PLAYWRIGHT_BASE_URL!).port} --strictPort --outDir ${process.env.TERMINAL_BUILD}`, - url: process.env.PLAYWRIGHT_BASE_URL, - reuseExistingServer: false, - }, - use: { ...config.use, viewport: { width: 1440, height: 900 }, trace: "off", video: "off", serviceWorkers: "block" }, -}) diff --git a/packages/app/e2e/performance/terminals/probe.ts b/packages/app/e2e/performance/terminals/probe.ts deleted file mode 100644 index 5547a40d3565..000000000000 --- a/packages/app/e2e/performance/terminals/probe.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Terminal } from "ghostty-web" -import { SerializeAddon } from "../../../../gui-plugins/src/terminal/serialize" - -export type TerminalProbe = { - term?: Terminal - writes: number - pending: number - bytes: number - renders: number - hiddenRenders: number - draws: number - hiddenDraws: number - serialized: { ms: number; bytes: number; value: string }[] -} - -declare global { - interface Window { - terminalProbe: TerminalProbe - } -} - -const probe: TerminalProbe = { - writes: 0, - pending: 0, - bytes: 0, - renders: 0, - hiddenRenders: 0, - draws: 0, - hiddenDraws: 0, - serialized: [], -} - -window.terminalProbe = probe - -const open = Terminal.prototype.open - -Terminal.prototype.open = function (element) { - probe.term = this - open.call(this, element) - // Ghostty does not expose render events. This benchmark-only wrapper observes its - // actual renderer; it does not alter scheduling, parsing, or drawing. - // SAFETY: ghostty-web keeps its renderer on this undeclared field; the probe only wraps its render method. - const renderer = (this as Terminal & { renderer: { render: (...args: unknown[]) => void } }).renderer - const render = renderer.render - let hidden = false - renderer.render = function (...args) { - probe.renders++ - hidden = !element.checkVisibility() - - if (hidden) probe.hiddenRenders++ - - return render.apply(this, args) - } - - if (new URL(location.href).searchParams.has("terminalDrawProbe")) { - const context = element.querySelector("canvas")!.getContext("2d")! - const draw = context.drawImage - context.drawImage = function (...args: unknown[]) { - probe.draws++ - - if (hidden) probe.hiddenDraws++ - draw.apply(this, args) - } - } -} - -const write = Terminal.prototype.write - -Terminal.prototype.write = function (data, done) { - probe.writes++ - probe.pending++ - probe.bytes += data instanceof Uint8Array ? data.byteLength : new TextEncoder().encode(data).byteLength - - return write.call(this, data, () => { - probe.pending-- - done?.() - }) -} - -const serialize = SerializeAddon.prototype.serialize - -SerializeAddon.prototype.serialize = function (options) { - const start = performance.now() - const value = serialize.call(this, options) - probe.serialized.push({ ms: performance.now() - start, bytes: new TextEncoder().encode(value).byteLength, value }) - - return value -} diff --git a/packages/app/e2e/performance/terminals/shell.ps1 b/packages/app/e2e/performance/terminals/shell.ps1 deleted file mode 100644 index b8457d0c654c..000000000000 --- a/packages/app/e2e/performance/terminals/shell.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -param([Parameter(Mandatory = $true)][string]$Fixture) -$ErrorActionPreference = 'Stop' -[Console]::WriteLine('TERMINAL_FIXTURE_READY') -while ($null -ne ($command = [Console]::ReadLine())) { - if ($command -eq 'exit') { exit 0 } - if ($command -eq 'run') { - foreach ($line in [System.IO.File]::ReadLines($Fixture)) { - [Console]::WriteLine($line) - } - [Console]::WriteLine('TERMINAL_WORKLOAD_DONE') - } - if ($command -eq 'ping') { [Console]::WriteLine('TERMINAL_PROCESS_ALIVE') } -} diff --git a/packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts b/packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts deleted file mode 100644 index 6c148b26e5fe..000000000000 --- a/packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { createRequire } from "node:module" -import { mkdtemp, writeFile, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import path from "node:path" -import { fileURLToPath } from "node:url" -import type { Page } from "@playwright/test" -import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" -import { mockOpenCodeServer } from "../../utils/mock-server" -import { expectSessionTitle } from "../../utils/waits" -import type {} from "./probe" - -// Use the same installed native PTY package as Core, with a fixture-owned process. -const native = createRequire(new URL("../../../../core/package.json", import.meta.url))("@lydell/node-pty") as { - spawn: ( - file: string, - args: string[], - options: { cols: number; rows: number; cwd: string }, - ) => { - pid: number - write: (data: string) => void - resize: (cols: number, rows: number) => void - kill: () => void - onData: (handler: (data: string) => void) => { dispose: () => void } - onExit: (handler: () => void) => { dispose: () => void } - } -} - -const sessionID = "ses_terminal_benchmark" - -const ptyID = "pty_terminal_benchmark" - -const title = "Terminal build output" - -const server = process.env.PLAYWRIGHT_BASE_URL! - -const href = `/server/${Buffer.from(server).toString("base64url")}/session/${sessionID}` - -const lines = Array.from({ length: 12_000 }, (_, i) => { - const unit = ["session/history", "session/runner", "project/discovery", "tool/shell", "provider/stream"][i % 5] - - return `\x1b[32mPASS\x1b[0m packages/core/test/${unit}-${String(i).padStart(5, "0")}.test.ts \x1b[2m[${10 + (i % 237)}ms]\x1b[0m validates ordered output and durable recovery` -}).join("\r\n") - -benchmark.use({ traceScope: "interaction", viewport: { width: 1440, height: 900 } }) - -for (const scenario of ["visible-output", "hidden-output", "full-scrollback-teardown"] as const) { - benchmark(scenario, async ({ page, report }, info) => { - const dir = await mkdtemp(path.join(process.env.TERMINAL_ARTIFACTS ?? tmpdir(), "terminal-fixture-")) - await writeFile(path.join(dir, "build.log"), lines) - - const pty = native.spawn( - "pwsh.exe", - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-File", - fileURLToPath(new URL("./shell.ps1", import.meta.url)), - "-Fixture", - path.join(dir, "build.log"), - ], - { cols: 120, rows: 24, cwd: dir }, - ) - - const exited = new Promise((resolve) => pty.onExit(resolve)) - let output = "" - let connected = 0 - let closed = 0 - let send: ((data: string) => void) | undefined - - const listener = pty.onData((data) => { - output += data - send?.(data) - }) - - const sizes: { cols: number; rows: number }[] = [] - const removals: string[] = [] - - try { - if (process.env.TERMINAL_DRAW_PROBE) { - await page.addInitScript(() => { - const fill = CanvasRenderingContext2D.prototype.fillText - CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters) { - if (this.canvas instanceof HTMLCanvasElement && this.canvas.closest('[data-component="terminal"]')) { - window.terminalProbe.draws++ - - if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++ - } - - fill.apply(this, args) - } - }) - } - - const location = { directory: dir, project: { id: "proj_terminal_benchmark", directory: dir } } - - const data = { - id: ptyID, - title: "Terminal 1", - command: "pwsh.exe", - args: [], - cwd: dir, - status: "running", - pid: pty.pid, - } - - await mockOpenCodeServer(page, { - directory: dir, - project: { - id: location.project.id, - worktree: dir, - vcs: "git", - name: "terminal-benchmark", - time: { created: 1, updated: 1 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID: location.project.id, - directory: dir, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/api/pty**", async (route) => { - if (route.request().method() === "DELETE") removals.push(route.request().url()) - const body = route.request().postDataJSON() - - if (body?.size) { - sizes.push(body.size) - pty.resize(body.size.cols, body.size.rows) - } - - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location, - data: route.request().url().includes("connect-token") ? { ticket: "fixture", expires_in: 60 } : data, - }), - }) - }) - await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (socket) => { - connected++ - send = (data) => socket.send(data) - socket.send(output.slice(Number(new URL(socket.url()).searchParams.get("cursor") ?? 0))) - socket.onMessage((data) => pty.write(String(data))) - socket.onClose(() => { - closed++ - send = undefined - }) - }) - await page.addInitScript( - ({ server, sessionID }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { server, sessionID }, - ) - await page.goto( - `${href}${process.env.TERMINAL_DRAW_PROBE && scenario !== "full-scrollback-teardown" ? "?terminalDrawProbe" : ""}`, - ) - await expectSessionTitle(page, title) - await page.keyboard.press("Control+Backquote") - await waitForText(page, "TERMINAL_FIXTURE_READY") - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal).toBeVisible() - await page.evaluate(() => document.fonts.ready.then(() => undefined)) - await expect - .poll(async () => { - const size = await page.evaluate(() => ({ - cols: window.terminalProbe.term!.cols, - rows: window.terminalProbe.term!.rows, - })) - - return sizes.at(-1)?.cols === size.cols && sizes.at(-1)?.rows === size.rows - }) - .toBe(true) - - if (scenario === "hidden-output") { - await page.keyboard.press("Control+Backquote") - await expect(terminal).toBeHidden() - } - - const cdp = await page.context().newCDPSession(page) - await cdp.send("Performance.enable") - const before = await cdp.send("Performance.getMetrics") - - const start = await page.evaluate(() => { - window.terminalProbe.renders = 0 - window.terminalProbe.hiddenRenders = 0 - window.terminalProbe.draws = 0 - window.terminalProbe.hiddenDraws = 0 - - return performance.now() - }) - - await benchmarkDiagnostics(page).startTrace() - // The producer is not throttled. The visible and hidden cases receive the same bytes. - pty.write("run\r") - await waitForText(page, "TERMINAL_WORKLOAD_DONE") - - const produced = await page.evaluate( - (start) => ({ - ms: performance.now() - start, - renders: window.terminalProbe.renders, - hiddenRenders: window.terminalProbe.hiddenRenders, - draws: window.terminalProbe.draws, - hiddenDraws: window.terminalProbe.hiddenDraws, - bytes: window.terminalProbe.bytes, - scrollback: window.terminalProbe.term!.getScrollbackLength(), - cols: window.terminalProbe.term!.cols, - rows: window.terminalProbe.term!.rows, - firstRecord: Number( - window.terminalProbe - .term!.buffer.normal.getLine(0) - ?.translateToString(true) - .match(/-(\d{5})\.test\.ts/)?.[1], - ), - }), - start, - ) - - const after = await cdp.send("Performance.getMetrics") - - const cpuMs = - (after.metrics.find((x) => x.name === "TaskDuration")!.value - - before.metrics.find((x) => x.name === "TaskDuration")!.value) * - 1000 - - let interaction: Record = {} - - if (scenario === "hidden-output") { - const start = await page.evaluate(() => performance.now()) - await page.keyboard.press("Control+Backquote") - await expect(terminal).toBeVisible() - await waitForText(page, "TERMINAL_WORKLOAD_DONE") - interaction = { returnMs: await page.evaluate((start) => performance.now() - start, start) } - } - - if (scenario === "full-scrollback-teardown") { - // Ghostty converts the configured line limit to bytes at the initial - // 80-column size. Resizing changes the effective retained row count. - expect(produced.scrollback).toBeGreaterThan(0) - expect(produced.firstRecord).toBeGreaterThan(0) - expect(produced.firstRecord).toBeLessThan(11_999) - const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`) - await expect(close).toBeVisible() - const cpuBefore = await cdp.send("Performance.getMetrics") - const start = await page.evaluate(() => performance.now()) - await close.click() - await expect(page).toHaveURL("/") - await expect(page.locator('[data-component="home-session-search"]')).toBeVisible() - await expect(page.locator('[data-component="home-session-search"] input')).toBeEditable() - await expect.poll(() => page.evaluate(() => window.terminalProbe.serialized.length)).toBe(1) - interaction = await page.evaluate( - (start) => ({ - homeReadyMs: performance.now() - start, - serializeMs: window.terminalProbe.serialized[0].ms, - serializedBytes: window.terminalProbe.serialized[0].bytes, - }), - start, - ) - const cpuAfter = await cdp.send("Performance.getMetrics") - interaction.teardownCpuMs = - (cpuAfter.metrics.find((x) => x.name === "TaskDuration")!.value - - cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) * - 1000 - const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value) - expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual( - Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index), - ) - expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE") - await writeFile( - path.join( - process.env.TERMINAL_ARTIFACTS ?? tmpdir(), - `${process.env.TERMINAL_BUNDLE}-${info.repeatEachIndex}.ansi`, - ), - snapshot, - ) - await expect(terminal).toHaveCount(0) - expect(closed).toBe(1) - // UI teardown must not terminate the native process. - pty.write("ping\r") - await expect.poll(() => output.includes("TERMINAL_PROCESS_ALIVE")).toBe(true) - } - - await benchmarkDiagnostics(page).stop() - expect(connected).toBe(1) - expect(removals).toEqual([]) - expect(sizes.length).toBeGreaterThan(0) - report( - { ...produced, cpuMs, ...interaction }, - { - revision: process.env.TERMINAL_REVISION, - bundle: process.env.TERMINAL_BUNDLE, - fixtureBytes: Buffer.byteLength(lines), - fixtureLines: 12_000, - transport: "Windows ConPTY -> Playwright WebSocket fixture -> production Terminal/writer/Ghostty", - scope: "Chromium renderer; not Electron total RAM or production backend IPC", - }, - ) - - if (scenario !== "full-scrollback-teardown") { - // Validate input, focus, and resize after both visible and hidden output. - await terminal.click() - await expect(terminal.locator("textarea")).toBeFocused() - await page.keyboard.type("ping") - await page.keyboard.press("Enter") - await waitForText(page, "TERMINAL_PROCESS_ALIVE") - const columns = await page.evaluate(() => window.terminalProbe.term!.cols) - await page.setViewportSize({ width: 1100, height: 800 }) - await expect.poll(() => page.evaluate(() => window.terminalProbe.term!.cols)).not.toBe(columns) - await expect - .poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols))) - .toBe(true) - expect(closed).toBe(0) - } - - if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") { - await page.screenshot({ - path: path.join(process.env.TERMINAL_SCREENSHOTS, `${scenario}-${info.repeatEachIndex}.png`), - }) - } - } finally { - listener.dispose() - - try { - await benchmarkDiagnostics(page).stop() - // Stop fixture request handlers before killing their native resource. The - // app debounces PTY resize requests independently of the canvas resize. - await page.unrouteAll({ behavior: "wait" }) - await page.close() - } finally { - pty.kill() - await exited - await writeFile( - path.join( - process.env.TERMINAL_ARTIFACTS ?? tmpdir(), - `${process.env.TERMINAL_BUNDLE}-${scenario}-${info.repeatEachIndex}.native.log`, - ), - output, - ) - await rm(dir, { recursive: true, force: true }) - } - } - }) -} - -async function waitForText(page: Page, text: string) { - await expect - .poll(() => - page.evaluate((text) => { - const probe = window.terminalProbe - const term = probe?.term - - if (!term || probe.pending !== 0) return false - const buffer = term.buffer.active - - return Array.from( - { length: term.rows }, - (_, i) => buffer.getLine(buffer.length - term.rows + i)?.translateToString(true) ?? "", - ) - .join("\n") - .includes(text) - }, text), - ) - .toBe(true) -} diff --git a/packages/app/e2e/performance/terminals/vite.config.ts b/packages/app/e2e/performance/terminals/vite.config.ts deleted file mode 100644 index 02f17d0ce544..000000000000 --- a/packages/app/e2e/performance/terminals/vite.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { mergeConfig } from "vite" -import config from "../../../vite.config" - -// The probe is included only in this manual benchmark build, never in the app build. -export default mergeConfig(config, { - plugins: [ - { - name: "terminal-benchmark-probe", - transformIndexHtml: { - order: "pre", - handler: () => [ - { tag: "script", attrs: { type: "module", src: "/e2e/performance/terminals/probe.ts" }, injectTo: "head" }, - ], - }, - }, - ], -}) diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index bfadd05f5711..0a7a1bdea5ac 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -6,6 +6,7 @@ import { openPaletteWorkspace, paletteSession, } from "../utils/command-palette" +import { mockWorkspace } from "../utils/workspace" test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] }) @@ -130,6 +131,29 @@ test("titlebar search lists recent sessions and opens the typed match", async ({ await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible() }) +test("a working session keeps its project avatar in Search sessions and shows its activity ring in place of its age", async ({ + page, +}) => { + const working = { id: "ses_palette_working", title: "Working session" } + const idle = { id: "ses_palette_idle", title: "Idle session" } + await mockWorkspace(page, { + name: "PaletteActivity", + sessions: [working, idle], + sessionStatus: { [working.id]: { type: "running" } }, + }) + await page.goto("/") + await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click() + const dialog = page.getByRole("dialog") + const workingRow = dialog.getByRole("option", { name: /Working session/ }) + const idleRow = dialog.getByRole("option", { name: /Idle session/ }) + await expect(workingRow.locator('[data-component="activity-ring"]')).toBeVisible() + await expect(workingRow.locator('[data-component="project-avatar-v2"]')).toBeVisible() + await expect(workingRow.locator(".command-palette-meta")).toHaveText("Working") + await expect(idleRow.locator('[data-component="activity-ring"]')).toHaveCount(0) + await expect(idleRow.locator('[data-component="project-avatar-v2"]')).toBeVisible() + await expect(idleRow.locator(".command-palette-meta")).not.toHaveText("Working") +}) + test("Search commands lists only commands and runs the chosen one", async ({ page }) => { const { dialog, input } = await openCommandPalette(page, true) await input.fill("search commands") @@ -325,7 +349,7 @@ test("appends search results without resetting the selected command", async ({ p await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0) }) -test("lists matching sessions above commands and jumps between sections with Alt and the arrows", async ({ page }) => { +test("lists commands above matching sessions and jumps between sections with Alt and the arrows", async ({ page }) => { const { dialog, input } = await openCommandPalette(page) await page.route("**/api/session?*", (route) => route.fulfill({ @@ -343,16 +367,17 @@ test("lists matching sessions above commands and jumps between sections with Alt ) await input.fill("copy") const session = dialog.getByRole("option", { name: /Copy fixture/ }) - await expect(session).toHaveAttribute("aria-selected", "true") - await expect(dialog.locator('[data-slot="search-list-header"]').first()).toHaveText("Session") + await expect(session).toBeVisible() const commands = dialog.getByRole("group", { name: "Commands", exact: true }).getByRole("option") + await expect(commands.first()).toHaveAttribute("aria-selected", "true") + await expect(dialog.locator('[data-slot="search-list-header"]').first()).toHaveText("Commands") await input.press("Alt+ArrowDown") + await expect(session).toHaveAttribute("aria-selected", "true") + await input.press("Alt+ArrowUp") await expect(commands.first()).toHaveAttribute("aria-selected", "true") await input.press("ArrowDown") await input.press("Alt+ArrowUp") await expect(commands.first()).toHaveAttribute("aria-selected", "true") - await input.press("Alt+ArrowUp") - await expect(session).toHaveAttribute("aria-selected", "true") }) test("selects the first session when it arrives above automatically selected files", async ({ page }) => { diff --git a/packages/app/e2e/regression/home.spec.ts b/packages/app/e2e/regression/home.spec.ts index 3ef6662e0bca..dee6a3655ce2 100644 --- a/packages/app/e2e/regression/home.spec.ts +++ b/packages/app/e2e/regression/home.spec.ts @@ -39,6 +39,41 @@ test("Home keeps the session list in every interface mode", async ({ page }) => } }) +test("a working Home session keeps its project avatar and shows its activity ring at the end of the row", async ({ + page, +}) => { + const working = { id: "ses_home_working", title: "Working session" } + const idle = { id: "ses_home_idle", title: "Idle session" } + await mockWorkspace(page, { + name: "HomeActivity", + sessions: [working, idle], + sessionStatus: { [working.id]: { type: "running" } }, + }) + await page.goto("/") + const ring = row(page, working.title).locator('[data-component="activity-ring"]') + await expect(ring).toBeVisible() + await expect(row(page, working.title).locator('[data-component="project-avatar-v2"]')).toBeVisible() + await expect(row(page, idle.title).locator('[data-component="activity-ring"]')).toHaveCount(0) + + // The ring trails the title rather than standing in for the avatar. + const title = await row(page, working.title).getByText(working.title, { exact: true }).boundingBox() + const box = await ring.boundingBox() + expect(box!.x).toBeGreaterThan(title!.x + title!.width) +}) + +test("Home lists projects and sessions when stored state hides every server", async ({ page }) => { + await mockStressTimeline(page) + await seed(page, { usedProjects: { local: [fixture.project.id] } }) + // A second seed merges into the server entry the first one wrote. + await seed(page, { storage: { "opencode.global.dat:server": { hidden: { [fixture.serverKey]: true } } } }) + await page.goto("/") + + await expect( + page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name }), + ).toBeVisible() + await expect(row(page, fixture.expected.targetTitle)).toBeVisible() +}) + test("the session context menu renames, exports, and deletes a Home session", async ({ page }) => { await openHome(page) const target = row(page, fixture.expected.targetTitle) diff --git a/packages/app/e2e/regression/mobile-session.spec.ts b/packages/app/e2e/regression/mobile-session.spec.ts index 7af867b036ae..ec2b9156067a 100644 --- a/packages/app/e2e/regression/mobile-session.spec.ts +++ b/packages/app/e2e/regression/mobile-session.spec.ts @@ -16,11 +16,11 @@ async function openStress(page: Page, extra: SeedInput = {}) { } for (const position of ["top", "bottom"] as const) { - test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({ + test(`mobile session tabs switch views and work with ${position} navigation`, async ({ page, }) => { await page.setViewportSize({ width: 390, height: 844 }) - await mockStressTimeline(page, { fileList: () => [], pty: {} }) + await mockStressTimeline(page, { fileList: () => [] }) await openStress(page, { settings: { general: { mobileTitlebarPosition: position } }, }) @@ -157,29 +157,10 @@ for (const position of ["top", "bottom"] as const) { await more.click() await expect(views.getByRole("button", { name: "Files", exact: true })).toHaveAttribute("aria-pressed", "true") - await views.getByRole("button", { name: "Terminal", exact: true }).click() + await page.keyboard.press("Escape") await expect(views).toBeHidden() - const panel = page.locator("#terminal-panel") - await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible() - await expect(panel.locator('[data-component="terminal"]')).toBeVisible() - await expect(panel.locator("textarea")).toBeEditable() - await expect(panel).toHaveCount(1) - await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original")) - await expect(composer).toBeHidden() - await tabs.getByRole("tab", { name: "Session", exact: true }).click() await expect(message).toBeVisible() - await expect(panel).toBeHidden() - await expect(panel).toHaveAttribute("data-cache-probe", "original") - - await page.keyboard.press("Control+Backquote") - await expect(picker).toHaveText("More...") - await expect(panel).toBeVisible() - await expect(panel).toHaveAttribute("data-cache-probe", "original") - await expect(panel.getByRole("tab", { name: /^Terminal/ })).toHaveCount(1) - - await tabs.getByRole("tab", { name: "Session", exact: true }).click() - await expect(panel).toBeHidden() // The view resets to Session whenever the routed session changes, including through Home. const trigger = page.locator('[data-slot="mobile-tabs-trigger"]') @@ -287,7 +268,7 @@ test("a narrow-screen palette pick keeps the view, and the side region shows the fileList: (path) => (path ? [] : [fileNode(fixture.directory, "README.md")]), fileContent: (path) => ({ type: "text", content: `contents:${path}` }), findFiles: ({ query }) => ("README.md".includes(query) ? [fileNode(fixture.directory, "README.md")] : []), - pty: {}, + persistentPty: {}, }) await openStress(page) await page.goto(sessionHref(fixture.targetID)) @@ -347,6 +328,7 @@ test.describe("touch", () => { await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`)) await expect(drawer).toBeHidden() await expect(trigger).toContainText(fixture.expected.sourceTitle) + await expect(trigger).toBeFocused() await trigger.click() await drawer.getByRole("button", { name: "Home", exact: true }).click() await expect(page).toHaveURL("/") diff --git a/packages/app/e2e/regression/multi-server.spec.ts b/packages/app/e2e/regression/multi-server.spec.ts index 288cc0b1aa18..1caa8e119cf1 100644 --- a/packages/app/e2e/regression/multi-server.spec.ts +++ b/packages/app/e2e/regression/multi-server.spec.ts @@ -233,7 +233,7 @@ test("a project's auto-accept choice overrides the default in both directions", onPermissionReply: (reply) => replies.push(reply.permissionID), }, }) - await seed(page, { tabs: [api.id, web.id] }) + await seed(page, { tabs: [api.id, web.id], usedProjects: { local: ["proj_api", "proj_web"] } }) const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]') const settings = page.getByTestId("settings-screen") diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index d69d13418d36..d2413619ba08 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -2,7 +2,14 @@ import { expect, test, type Locator, type Page } from "@playwright/test" import type { OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise" import { draftHref, expectPath, NO_PROVIDER, project, provider, seed, sessionHref, T0 } from "../utils/app" import { mockOpenCodeServer } from "../utils/mock-server" -import { newSessionLocation, openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace" +import { + newSessionLocation, + newSessionWorktree, + openDraft, + openSession, + openWorktreeDraft, + type WorkspaceInput, +} from "../utils/workspace" const directory = "C:/OpenCode/WorkspacePending" @@ -52,19 +59,32 @@ test("the new session screen does not show session details", async ({ page }) => }) test("the location shortcut keeps cycling after hovering its icon", async ({ page }) => { - const view = await openDraft(page, { name: "LocationShortcut" }) + const existing = "C:/OpenCode/LocationShortcut/.worktrees/quiet-river" + + const view = await openDraft(page, { + name: "LocationShortcut", + worktrees: () => [{ directory: "C:/OpenCode/LocationShortcut" }, { directory: existing, strategy: "git" }], + }) + const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control")) + const path = page.locator('[data-slot="new-session-location-path"]') + const icon = page.locator('[data-slot="new-session-location-icon"]') + + // The worktree trigger already says Local, so the local path has no icon of its own. + await expect(path).toHaveText(view.directory) + await expect(icon).toHaveCount(0) + await newSessionWorktree(page).click() + await page.getByRole("option", { name: /^quiet-river / }).click() await expect(view.editor).toBeFocused() + await expect(path).toHaveText(existing) + await icon.hover() + await expect(page.getByRole("tooltip")).toContainText("Toggle session location") await view.editor.press(`${modifier}+Alt+KeyL`) - await expect(newSessionLocation(page)).toContainText("New worktree") + await expect(newSessionWorktree(page)).toHaveText("Local") + await expect(icon).toHaveCount(0) await expect(view.editor).toBeFocused() await view.editor.press(`${modifier}+Alt+KeyL`) - await expect(newSessionLocation(page)).not.toContainText("New worktree") - await expect(page.locator('[data-slot="new-session-location-path"]')).toHaveText(view.directory) - await page.locator('[data-slot="new-session-location-icon"]').hover() - await expect(page.getByRole("tooltip")).toContainText("Toggle session location") - await view.editor.press(`${modifier}+Alt+KeyL`) - await expect(newSessionLocation(page)).toContainText("New worktree") + await expect(newSessionWorktree(page)).toContainText("New worktree") await expect(view.editor).toBeFocused() }) @@ -172,7 +192,10 @@ test("the combined location picker shows checkout branches and chooses the base await expect(page.getByRole("option")).toHaveCount(1) await page.getByRole("option", { name: /Custom project C:\/OpenCode\/BranchContext/ }).click() await expect(view.editor).toBeFocused() + await expect(screen.getByRole("button", { name: "Local", exact: true })).toBeVisible() + // The local checkout's branch is context, not a control: the app cannot switch it. await expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible() + await expect(screen.getByRole("button", { name: "timeline-quotes", exact: true })).toHaveCount(0) await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0) await select(/^New worktree/) await expect(base).toBeVisible() @@ -191,7 +214,7 @@ test("the combined location picker shows checkout branches and chooses the base .toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })]) }) -test("the checkout branch opens the current project's worktree step", async ({ page }) => { +test("the worktree trigger opens the current project's worktree step", async ({ page }) => { const existing = "C:/OpenCode/BranchWorktrees/.worktrees/quiet-river" const view = await openDraft(page, { @@ -201,13 +224,14 @@ test("the checkout branch opens the current project's worktree step", async ({ p }) const screen = page.locator('[data-component="new-session"]') - const branch = screen.getByRole("button", { name: "dev", exact: true }) + // The app cannot switch the checkout branch, so the local checkout names the mode instead of "dev". + const local = newSessionWorktree(page) const dialog = page.getByRole("dialog") const search = dialog.getByRole("combobox", { name: "Search worktrees", exact: true }) const options = dialog.getByRole("option") const back = dialog.getByRole("button", { name: "Back to projects", exact: true }) - await branch.click() + await local.click() await expect(search).toBeFocused() await expect(back).toHaveText("Run session in") await expect(options.locator(".command-palette-title")).toHaveText([ @@ -228,7 +252,7 @@ test("the checkout branch opens the current project's worktree step", async ({ p await expect(view.editor).toBeFocused() // Escape also steps back first, then closes. - await branch.focus() + await local.focus() await page.keyboard.press("Enter") await expect(search).toBeFocused() await search.press("Escape") @@ -236,11 +260,51 @@ test("the checkout branch opens the current project's worktree step", async ({ p await projects.press("Escape") await expect(dialog).toHaveCount(0) - await branch.click() + await local.click() await options.filter({ hasText: "quiet-river" }).click() await expect(dialog).toHaveCount(0) - await expect(newSessionLocation(page)).toContainText("quiet-river") - await expect(screen.getByRole("button", { name: "quiet-river-branch", exact: true })).toBeVisible() + await expect(newSessionWorktree(page)).toContainText("quiet-river") + await expect(screen.getByText("quiet-river-branch", { exact: true })).toBeVisible() + await expect(view.editor).toBeFocused() +}) + +test("the branch shortcut chooses a base branch and stays inactive on Local", async ({ page }) => { + const view = await openWorktreeDraft(page, { + name: "BranchShortcut", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => ["dev", "release/base"], + }) + + const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control")) + + const shortcut = `${modifier}+Alt+Period` + + const base = page.getByRole("button", { name: "from dev", exact: true }) + + const search = page.getByRole("combobox", { name: "Search branches", exact: true }) + + await base.hover() + await expect(page.getByRole("tooltip")).toContainText("Choose base branch") + await expect(page.getByRole("tooltip").locator('[data-component="keybind-v2"]')).toContainText(".") + await expect(view.editor).toBeFocused() + await view.editor.press(shortcut) + await expect(search).toBeFocused() + await search.fill("release/base") + await expect(page.getByRole("option")).toHaveText(["release/base"]) + await search.press("Enter") + await expect(page.getByRole("button", { name: "from release/base", exact: true })).toBeVisible() + await expect(view.editor).toBeFocused() + + await view.editor.press(shortcut) + await expect(search).toBeFocused() + await search.press(shortcut) + await expect(search).toHaveCount(0) + await expect(view.editor).toBeFocused() + + await view.editor.press(`${modifier}+Alt+KeyL`) + await expect(newSessionWorktree(page)).toHaveText("Local") + await view.editor.press(shortcut) + await expect(search).toHaveCount(0) await expect(view.editor).toBeFocused() }) @@ -499,9 +563,7 @@ test("a pending worktree session keeps Session as its mobile title until the gen const label = page.locator('[data-slot="mobile-tab-title"]') await expect(label).toHaveText("Session") const pending = await submitPending(page, mock) - await expect( - page.locator('[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'), - ).toBeVisible() + await expect(page.locator('[data-slot="mobile-tabs-trigger"] [data-component="activity-ring"]')).toBeVisible() mock.worktree.resolve({ status: 200, json: { directory: workspace } }) await expect(pending.shimmer).toHaveCount(0) await expect(submit(page)).toBeEnabled() @@ -621,7 +683,7 @@ for (const row of [ if (row.followUp) return await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible() - await expect(newSessionLocation(page)).toContainText("New worktree") + await expect(newSessionWorktree(page)).toContainText("New worktree") await expect(pending.message).toHaveCount(0) await expect(tabLink(page, pending.sessionID)).toHaveCount(0) expect(mock.creates).toEqual([]) @@ -638,7 +700,7 @@ test("retains the draft and reuses the created workspace after session creation await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible() await expect(editor(page)).toHaveText(text) await expect(submit(page)).toBeEnabled() - await expect(newSessionLocation(page)).toContainText("pending-workspace") + await expect(newSessionWorktree(page)).toContainText("pending-workspace") await expect(pending.shimmer).toHaveCount(0) await expect(pending.message).toHaveCount(0) expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })]) @@ -686,7 +748,7 @@ test("restores the draft after closing and revisiting a pending session that fai await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible() await expect(editor(page)).toHaveText(`${text}\n\n${followUp}`) await expect(submit(page)).toBeEnabled() - await expect(newSessionLocation(page)).toContainText("New worktree") + await expect(newSessionWorktree(page)).toContainText("New worktree") await expect(page.locator(`[data-titlebar-tab-link][href="${draftHref(draftID)}"]`)).toHaveCount(1) await expect(tab).toHaveCount(0) await expect(pending.message).toHaveCount(0) diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index 00141a7a520f..94456f7393c1 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -151,6 +151,27 @@ test("a rejected Git init releases its session's button without showing an error await expect(init).toBeEnabled() }) +test("opens file picker launchers when randomUUID is unavailable on insecure HTTP origins", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(globalThis, "isSecureContext", { configurable: true, value: false }) + Object.defineProperty(globalThis.crypto, "randomUUID", { configurable: true, value: undefined }) + }) + + await openSession(page, { + name: "ReviewOpenFileInsecure", + seed: { panes: { ses_reviewopenfileinsecure: { review: true } } }, + }) + + const panel = page.locator("#review-panel") + const tab = panel.getByRole("tab", { name: "Open file", exact: true }) + await openFileLauncher(panel) + await expect(tab).toHaveAttribute("aria-selected", "true") + await expect(panel.getByRole("combobox", { name: "Filter files" })).toBeFocused() + + await openFileLauncher(panel) + await expect(tab).toHaveCount(2) +}) + test("open file tab browses, searches, and tracks missing files", async ({ page }) => { const searches: { query: string; dirs?: string; limit?: number; directory: string }[] = [] const directory = "C:/OpenCode/ReviewOpenFile" diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index 73d2adcc6a07..09e55d4859c5 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -214,7 +214,9 @@ test.describe("location", () => { sessions: [{ id: "ses_location", title: "Location session", directory: worktree }], } - test("collapses the worktree name before the project and only then truncates the title", async ({ page }) => { + test("collapses the worktree name, then the project, then the usage details, and only then truncates the title", async ({ + page, + }) => { await page.setViewportSize({ width: 1440, height: 900 }) await page.clock.install() await openSession(page, { @@ -228,22 +230,27 @@ test.describe("location", () => { const row = header.locator(".session-header-layout") const review = page.getByRole("button", { name: "Toggle review", exact: true }) const tooltip = page.getByRole("tooltip") + const usage = header.getByRole("button", { name: "Toggle session context", exact: true }) + const usageMenu = header.getByRole("button", { name: "View context usage", exact: true }) await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible() await expect(project).toHaveText("Location") await expect(worktreeName).toHaveText("quiet-falcon") // Keep the viewport fixed: a pane can narrow a session independently of the window. for (const state of [ - { width: 1000, project: true, worktree: true, truncated: false }, - { width: 680, project: true, worktree: false, truncated: false }, - { width: 600, project: false, worktree: false, truncated: false }, - { width: 490, project: false, worktree: false, truncated: true }, - { width: 680, project: true, worktree: false, truncated: false }, - { width: 1000, project: true, worktree: true, truncated: false }, + { width: 1000, project: true, worktree: true, usage: true, truncated: false }, + { width: 680, project: true, worktree: false, usage: true, truncated: false }, + { width: 600, project: false, worktree: false, usage: true, truncated: false }, + { width: 550, project: false, worktree: false, usage: false, truncated: false }, + { width: 490, project: false, worktree: false, usage: false, truncated: true }, + { width: 680, project: true, worktree: false, usage: true, truncated: false }, + { width: 1000, project: true, worktree: true, usage: true, truncated: false }, ]) { await row.evaluate((element, width) => (element.style.inlineSize = `${width}px`), state.width) await expect(project).toBeVisible({ visible: state.project }) await expect(worktreeName).toBeVisible({ visible: state.worktree }) + await expect(usage).toBeVisible({ visible: state.usage }) + await expect(usageMenu).toBeVisible({ visible: !state.usage }) await expect(header.locator(".session-location-worktree-icon")).toBeVisible() await expect .poll(() => header.getByRole("heading").evaluate((element) => element.scrollWidth > element.clientWidth)) @@ -307,6 +314,20 @@ test.describe("location", () => { await location.click() await expect(page.locator('[data-slot="session-location-card"]')).toBeVisible() await expect(tooltip).toBeHidden() + await page.keyboard.press("Escape") + await expect(page.locator('[data-slot="session-location-card"]')).toBeHidden() + + // The compact usage opens its details in a menu, which also opens the context panel. + await row.evaluate((element) => (element.style.inlineSize = "490px")) + await usageMenu.click() + const details = page.getByRole("menu", { name: "View context usage", exact: true }) + await expect(details).toContainText("Tokens0") + await expect(details).toContainText("Cost$0.00") + await expect(details).toContainText("Context0%") + await page.screenshot({ path: test.info().outputPath("usage-menu.png") }) + await details.getByRole("menuitem", { name: "View context usage", exact: true }).click() + await expect(details).toBeHidden() + await expect(page.getByRole("tab", { name: "Context", exact: true })).toHaveAttribute("aria-selected", "true") }) test("collapses to the project avatar when there is no worktree", async ({ page }) => { @@ -386,10 +407,10 @@ test.describe("location", () => { await context.grantPermissions(["clipboard-read", "clipboard-write"]) await page.setViewportSize({ width: 1440, height: 900 }) - const { pty } = await openSession(page, { + const { persistentPty } = await openSession(page, { ...workspace, vcs: (directory) => ({ current: directory === worktree ? "feature/branch-menu" : "main", default: "main" }), - pty: {}, + persistentPty: {}, }) await expect(page.getByRole("button", { name: "More options", exact: true })).toBeVisible() @@ -409,9 +430,9 @@ test.describe("location", () => { await branch.click() await actions.getByRole("menuitem", { name: "Open in terminal", exact: true }).click() await expect(card).toBeHidden() - await expect.poll(() => pty.created.map((item) => item.cwd)).toEqual([worktree]) - await expect.poll(() => pty.sockets.length).toBe(1) - pty.send("$ pwd\r\n" + worktree + "\r\n") + await expect.poll(() => persistentPty.created.map((item) => item.cwd)).toEqual([worktree]) + await expect.poll(() => persistentPty.sockets.length).toBe(1) + persistentPty.output(persistentPty.created[0]!.id, "$ pwd\r\n" + worktree + "\r\n") const terminal = page.locator('[data-component="terminal"]') await expect(terminal).toHaveCSS("opacity", "1") await expect(terminal.locator("textarea")).toBeFocused() diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index f9cce652afad..d9e5587070c7 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -128,7 +128,6 @@ test("the project menu loads changes on demand and opens review", async ({ page "main", /1 Changed file.*\+7.*-3/, "", - "This computer", "C:/OpenCode/ProjectChanges", "", "Edit project", @@ -557,6 +556,8 @@ test("MCP rows toggle once per action and keep sign-in, errors, and management a } }) .toEqual({ width: 6, height: 6, alignment: 0, gap: 8 }) + // Like every other control in the menu, a row that toggles its server keeps the default cursor. + await expect(row("figma")).not.toHaveCSS("cursor", "pointer") await page.screenshot({ path: test.info().outputPath("mcp-toggles.png"), animations: "disabled" }) for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) { @@ -567,7 +568,11 @@ test("MCP rows toggle once per action and keep sign-in, errors, and management a ? await holdRoute(page, /\/api\/experimental\/mcp\/figma\/disconnect(?:\?|$)/, { method: "POST" }) : undefined - if (target === "label") await row("figma").locator('[data-slot="truncated-text-label"]').click() + if (target === "label") { + await row("figma").locator('[data-slot="truncated-text-label"]').click() + // A name that fits has nothing to reveal, so a click on it leaves no focus ring of its own. + await expect(row("figma").locator('[data-component="tooltip-v2-trigger"]')).not.toBeFocused() + } if (target === "dot") await row("figma").locator(".session-service-dot").click() @@ -673,11 +678,15 @@ test("Extensions lists open on hover, hold course into the open list, and follow test("Extensions lists open config files and skills in a side tab and start setup sessions", async ({ page }) => { const global = "C:/Users/kit/.config/opencode/opencode.jsonc" const skill = `${fixture.directory}/.opencode/skills/release-notes/SKILL.md` + let builtinReads = 0 await mockStressTimeline(page, { mcp: [{ name: "atlas-search", status: { status: "connected" } }], plugins: [], - skills: [{ id: "release-notes", name: "release-notes", path: skill, content: "Release notes" }], + skills: [ + { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md", content: "# Built-in OpenCode guide" }, + { id: "release-notes", name: "release-notes", path: skill, content: "Release notes" }, + ], configEntries: [ { type: "document", @@ -689,6 +698,16 @@ test("Extensions lists open config files and skills in a side tab and start setu ], fileContent: (path) => ({ type: "text", content: `contents:${path}` }), }) + + await page.route(/\/api\/fs\/read\/opencode\.md(\?|$)/, (route) => { + builtinReads += 1 + + return route.fulfill({ + status: 404, + json: { message: "Location not found: /builtin" }, + headers: cors, + }) + }) await page.goto(sessionHref(fixture.targetID)) const trigger = page.getByRole("button", { name: "Session details", exact: true }) const summary = page.getByRole("dialog", { name: "Session details", exact: true }) @@ -705,6 +724,15 @@ test("Extensions lists open config files and skills in a side tab and start setu await expect(page.getByRole("tab", { name: "opencode.jsonc", exact: true })).toHaveAttribute("aria-selected", "true") await expect(page.getByText("contents:opencode.jsonc", { exact: true })).toBeVisible() + await trigger.click() + await row("Skills").hover() + await list("Skills").getByRole("button", { name: "OpenCode", exact: true }).click() + await expect(summary).toBeHidden() + await expect(page.getByRole("tab", { name: "opencode.md", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("heading", { name: "Built-in OpenCode guide", exact: true })).toBeVisible() + await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0) + expect(builtinReads).toBe(0) + await trigger.click() await row("Skills").hover() await list("Skills").getByRole("button", { name: "release-notes", exact: true }).click() @@ -841,7 +869,14 @@ test("long MCP lists stay in the viewport in a real RTL locale", async ({ page } await summary.getByRole("button", { name: "MCP", exact: true }).click() const menu = page.getByRole("group", { name: "MCP", exact: true }) await expect(menu.locator(".session-mcp-row")).toHaveCount(30) - await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + const list = menu.locator("[data-scrollable]") + await expect.poll(() => list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + // Long names truncate; the list only scrolls vertically. + expect(await list.evaluate((element) => element.scrollWidth - element.clientWidth)).toBe(0) + // The submenu shows and moves without motion unless its Knobs toggle is on. + expect( + await page.locator(".session-service-flyout").evaluate((element) => getComputedStyle(element).transitionDuration), + ).toBe("0s") await expect .poll(async () => { const bounds = (await menu.boundingBox())! diff --git a/packages/app/e2e/regression/settings-search.spec.ts b/packages/app/e2e/regression/settings-search.spec.ts index 9706862f8b75..0f882095ef45 100644 --- a/packages/app/e2e/regression/settings-search.spec.ts +++ b/packages/app/e2e/regression/settings-search.spec.ts @@ -311,8 +311,12 @@ test("section search takes typed input, clears in place, and escapes in steps", }) for (const count of [0, 7, 8]) { - test(`Projects search appears from 8 projects (${count} projects)`, async ({ page }) => { - const view = await open(page, { count }) + test(`Projects search appears from 8 added projects (${count} projects)`, async ({ page }) => { + const view = await open(page, { + count, + seed: { usedProjects: { local: Array.from({ length: count }, (_, index) => `proj_search_${index}`) } }, + }) + await view.settings.getByRole("tab", { name: "Projects", exact: true }).click() const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true }) const projects = view.settings.getByRole("button", { name: /^OpenCode / }) diff --git a/packages/app/e2e/regression/settings-wsl.spec.ts b/packages/app/e2e/regression/settings-wsl.spec.ts index e2ad34195c44..4106b438c232 100644 --- a/packages/app/e2e/regression/settings-wsl.spec.ts +++ b/packages/app/e2e/regression/settings-wsl.spec.ts @@ -95,48 +95,6 @@ test("adding an SSH server while the SSH plugin is down fails instead of connect await expect(dialog.getByRole("button", { name: "Add server", exact: true })).toBeEnabled() }) -test("an open session's terminal follows its WSL server to the endpoint it restarts on", async ({ page }) => { - const restarted = "http://127.0.0.1:4098" - const directory = "/home/ubuntu/project" - const wsl = session({ id: "ses_wsl", directory, title: "WSL session" }) - - const config = { - directory, - project: project({ id: "proj_wsl", directory }), - provider: NO_PROVIDER, - sessions: [wsl], - pageMessages: () => ({ items: [] }), - } - - const servers = await mockServers(page, { - [SERVER]: { ...config, sessions: [] }, - [REMOTE_SERVER]: { ...config, pty: { prefix: "pty_before" } }, - [restarted]: { ...config, pty: { prefix: "pty_after" } }, - }) - - const path = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}` - await page.goto( - `/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", wsl: REMOTE_SERVER, restart: restarted, path })}`, - ) - await expectSessionTitle(page, wsl.title) - await page.keyboard.press("Control+Backquote") - await expect.poll(() => servers[REMOTE_SERVER]!.pty.sockets.map((socket) => socket.id)).toEqual(["pty_before1"]) - - await page.keyboard.press("Control+,") - const settings = page.getByTestId("settings-screen") - await settings.getByRole("tab", { name: "Servers", exact: true }).click() - const connection = settings.getByRole("region", { name: "Ubuntu", exact: true }) - await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click() - await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0) - await settings.getByRole("button", { name: "Back to app" }).click() - await expectSessionTitle(page, wsl.title) - - // The tab and its open terminal outlive the restart. The stopped server no longer knows the terminal, so it is - // recreated on the restarted one. - await expect.poll(() => servers[restarted]!.pty.sockets.map((socket) => socket.id)).toEqual(["pty_after1"]) - expect(servers[REMOTE_SERVER]!.pty.sockets.map((socket) => socket.id)).toEqual(["pty_before1"]) -}) - test("WSL session and draft tabs outlive the plugin going away until the server is removed", async ({ page }) => { const directory = "/home/ubuntu/project" const wsl = session({ id: "ses_wsl_tabs", directory, title: "WSL tabs session" }) diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index ff15ed572dfd..86f63ee26341 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -3,6 +3,8 @@ import type { ConfigEntry, OpenCodeEvent, SessionMessageInfo, WorktreeDirectory import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, seed, session } from "../utils/app" import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server" import { mockRemoteServer, mockWorkspace, openSession, openSettings, type WorkspaceInput } from "../utils/workspace" +import { recordNotifications } from "../utils/notifications" +import { installSseTransport } from "../utils/sse-transport" const directory = "C:/Projects/settings-demo" @@ -94,6 +96,146 @@ test("a settings page survives refresh", async ({ page }) => { await expect(page).toHaveURL("/settings?tab=appearance") }) +test("Questions settings default independently of old Permissions settings and survive reload", async ({ page }) => { + const { settings } = await open(page, { + seed: { + settings: { + notifications: { agent: false, permissions: false, errors: true }, + sounds: { permissionsEnabled: false, permissions: "alert-05" }, + }, + }, + }) + + await settings.getByRole("tab", { name: "Notifications", exact: true }).click() + await expect(settings.locator('[data-slot="settings-row-title"]')).toHaveText([ + "Agent", + "Permissions", + "Questions", + "Errors", + "Agent", + "Permissions", + "Questions", + "Errors", + "Interface", + ]) + const questions = settings.getByRole("switch", { name: "Questions", exact: true }) + + const permissions = settings.locator('[data-action="settings-notifications-permissions"]').getByRole("switch") + + const questionSound = settings.locator('[data-action="settings-sounds-questions"]') + + const permissionSound = settings.locator('[data-action="settings-sounds-permissions"]') + + await expect(questions).toBeChecked() + await expect(permissions).not.toBeChecked() + await expect(questionSound).toHaveText("Staplebops 05") + await expect(permissionSound).toHaveText("None") + await questions.locator("..").locator('[data-slot="switch-control"]').click() + await expect(questions).not.toBeChecked() + await questionSound.click() + await page.getByRole("option", { name: "Alert 02", exact: true }).click() + await expect(questionSound).toHaveText("Alert 02") + + await page.reload() + await expect(questions).not.toBeChecked() + await expect(questionSound).toHaveText("Alert 02") + await expect(permissions).not.toBeChecked() + await expect(permissionSound).toHaveText("None") + await questions.locator("..").locator('[data-slot="switch-control"]').click() + await questionSound.click() + await page.getByRole("option", { name: "None", exact: true }).click() + await expect(questions).toBeChecked() + await expect(questionSound).toHaveText("None") + await settings.getByRole("combobox", { name: "Search", exact: true }).fill("Questions") + await expect(settings.locator('[data-setting-target="settings-notifications-questions"]')).toBeVisible() + await expect(settings.locator('[data-setting-target="settings-sounds-questions"]')).toBeVisible() +}) + +test("request events use real Questions settings without disabling Permissions alerts", async ({ page }) => { + const recorded = await recordNotifications(page) + + const transport = await installSseTransport(page, { server: SERVER }) + + const workspace = await openSession(page, { + name: "Request notifications", + seed: { settings: { sounds: { agentEnabled: false, permissions: "alert-01", questions: "alert-02" } } }, + }) + + await transport.waitForConnection() + await page.getByRole("button", { name: "Home", exact: true }).click() + await page.getByRole("button", { name: "Settings", exact: true }).click() + const settings = page.getByTestId("settings-screen") + + await settings.getByRole("tab", { name: "Notifications", exact: true }).click() + const questions = settings.getByRole("switch", { name: "Questions", exact: true }) + + await expect(questions).toBeChecked() + + const event = ( + kind: "question" | "mcp-elicitation" | "websearch.provider" | "permission", + index: number, + ): OpenCodeEvent => { + const base = { + id: `evt_request_${index}`, + created: 1700000001000 + index, + location: { directory: workspace.directory }, + } + + if (kind === "permission") + return { + ...base, + type: "permission.asked", + data: { + id: `per_request_${index}`, + sessionID: workspace.session.id, + action: "shell", + resources: ["echo hello"], + metadata: {}, + }, + } + + return { + ...base, + type: "form.created", + data: { + form: { + id: `frm_request_${index}`, + sessionID: workspace.session.id, + title: "Input", + metadata: { kind }, + fields: [{ key: "answer", type: "string", title: "Continue?" }], + }, + }, + } + } + + await transport.send(event("question", 1)) + await expect + .poll(() => recorded.notifications) + .toEqual([{ title: "Question requires an answer", body: "Request notifications" }]) + await expect.poll(() => recorded.sounds).toEqual([expect.stringContaining("alert-02")]) + + await settings.locator('[data-action="settings-sounds-questions"]').click() + await page.getByRole("option", { name: "None", exact: true }).click() + await expect(settings.locator('[data-action="settings-sounds-questions"]')).toHaveText("None") + const previews = recorded.sounds.slice() + + await transport.send(event("mcp-elicitation", 2)) + await expect + .poll(() => recorded.notifications.map((item) => item.title)) + .toEqual(["Question requires an answer", "Input requested"]) + + await questions.locator("..").locator('[data-slot="switch-control"]').click() + await expect(questions).not.toBeChecked() + await transport.burst([event("question", 3), event("permission", 4), event("websearch.provider", 5)]) + await expect + .poll(() => recorded.notifications.map((item) => item.title)) + .toEqual(["Question requires an answer", "Input requested", "Permission required", "Permission required"]) + await expect + .poll(() => recorded.sounds) + .toEqual([...previews, expect.stringContaining("alert-01"), expect.stringContaining("alert-01")]) +}) + for (const locale of ["en", "ar"]) { test(`background activity alignment stays physical and persists (${locale})`, async ({ page }) => { const id = "ses_activity_settings" @@ -349,11 +491,45 @@ test("project list menus rename, edit, add, and stay inside the scrollport", asy await settings.getByRole("tab", { name: "Projects", exact: true }).click() const panel = settings.getByRole("tabpanel") const list = panel.getByRole("list") - await expect(list.getByRole("listitem")).toHaveCount(4) - const card = list.getByRole("listitem").filter({ hasText: "rebase" }) + await expect(list.getByRole("listitem")).toHaveCount(1) + await expect(list.getByRole("button", { name: "rebase", exact: true })).toBeVisible() + const others = panel.getByRole("button", { name: "Other projects", exact: true }) + await expect(others).toContainText("3") + await expect(others).toHaveAttribute("aria-expanded", "false") + await others.click() + await expect(others).toHaveAttribute("aria-expanded", "true") + await expect(panel.getByRole("listitem")).toHaveCount(4) + await others.click() + const search = panel.getByRole("searchbox", { name: "Search projects" }) + await search.fill("/projects/dinocms") + await expect(panel.getByRole("listitem")).toHaveCount(1) + await expect(panel.getByRole("button", { name: "dinocms", exact: true })).toBeVisible() + await expect(panel.getByRole("heading", { name: "Other projects", exact: true })).toBeVisible() + await search.fill("") + await expect(panel.getByRole("listitem")).toHaveCount(1) + await others.click() + await expect(panel.getByRole("listitem")).toHaveCount(4) + + const addTrigger = panel + .getByRole("listitem") + .filter({ hasText: "dinocms" }) + .getByRole("button", { name: "More options", exact: true }) + + await addTrigger.focus() + await addTrigger.press("Enter") + const menu = page.getByRole("menu") + await expect(menu.getByRole("menuitem")).toHaveText(["Add project", "Edit", "Rename"]) + const addProject = menu.getByRole("menuitem", { name: "Add project", exact: true }) + await expect(addProject).toBeFocused() + await addProject.press("Enter") + await expect(menu).toBeHidden() + await expect(others).toContainText("2") + const addedProjects = panel.getByRole("list").filter({ hasText: "rebase" }) + await expect(addedProjects.getByRole("listitem")).toHaveCount(2) + await expect(addedProjects.getByRole("button", { name: "dinocms", exact: true })).toBeFocused() + const card = panel.getByRole("listitem").filter({ hasText: "rebase" }) const more = card.getByRole("button", { name: "More options", exact: true }) await more.click() - const menu = page.getByRole("menu") await expect(menu.getByRole("menuitem")).toHaveText(["Edit", "Rename", "Clear notifications"]) await menu.getByRole("menuitem", { name: "Edit", exact: true }).click() await expect(panel.getByRole("heading", { name: "rebase", exact: true })).toBeVisible() @@ -403,6 +579,8 @@ test("project list menus rename, edit, add, and stay inside the scrollport", asy await select.click() await expect(picker).toBeHidden() await expect(panel.getByRole("heading", { name: "added", exact: true })).toBeVisible() + await page.getByRole("button", { name: "Home", exact: true }).click() + await expect(page.locator('[data-component="home-project-row"]').filter({ hasText: "dinocms" })).toBeVisible() }) test("project settings open as a nested view that keeps its route", async ({ page }) => { diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index 8507ad00044b..f734f1b3cb86 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -1,102 +1,8 @@ -import { expect, test, type Locator, type Page } from "@playwright/test" -import { tabKey } from "../utils/app" +import { expect, test, type Page } from "@playwright/test" import { fileDiff, openSession } from "../utils/workspace" import { openReviewLauncher } from "../utils/side-panel" -import { expectSessionTitle } from "../utils/waits" import { base64Encode } from "@opencode/util/encode" -test("review and terminal tabs follow the session tab and the terminal stays mounted", async ({ page }) => { - await page.setViewportSize({ width: 1440, height: 900 }) - const a = { id: "ses_side_a", title: "Alpha session" } - const b = { id: "ses_side_b", title: "Beta session" } - - const { directory, pty } = await openSession(page, { - name: "SidePanels", - sessions: [a, b], - pty: {}, - vcsDiff: Array.from({ length: 2_740 }, (_, index) => - fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`), - ), - seed: { panes: { [a.id]: { sessionWidth: 580 }, [b.id]: { sessionWidth: 520 } } }, - }) - - const chat = page.locator('[data-slot="session-chat-panel"]') - const review = page.locator('#review-panel [data-component="session-review-v2"]') - const terminal = page.locator('[data-component="terminal"]') - const reviewTab = page.locator("#session-side-panel-review-tab") - const terminalTab = page.locator("#review-panel").getByRole("tab", { name: "Terminal 1", exact: true }) - - const switchTab = async (title: string) => { - await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() - await expectSessionTitle(page, title) - } - - await page.getByRole("button", { name: "Toggle review" }).click() - await openReviewLauncher(page.locator("#review-panel")) - await expect(review).toBeVisible() - await expect(chat).toHaveCSS("width", "580px") - await expect(reviewTab).toHaveText("Files Changed 2740") - - // The terminal opens as a tab beside review in the same strip. - await page.keyboard.press("Control+Backquote") - await expect(terminalTab).toHaveAttribute("aria-selected", "true") - await expect(terminal).toBeVisible() - await expect(review).toBeHidden() - await expect.poll(() => pty.sockets.length).toBe(1) - await mark(terminal) - - await switchTab(b.title) - await expect(terminal).toBeHidden() - await expectMarked(terminal) - await page.getByRole("button", { name: "Toggle review" }).click() - await openReviewLauncher(page.locator("#review-panel")) - await expect(review).toBeVisible() - await expect(chat).toHaveCSS("width", "520px") - // The workspace's terminal reopens in this session's strip instead of starting another shell. - await page.keyboard.press("Control+Backquote") - await expect(terminalTab).toHaveAttribute("aria-selected", "true") - await expect(terminal).toBeVisible() - await expectMarked(terminal) - - await switchTab(a.title) - await expect(terminal).toBeVisible() - await expect(chat).toHaveCSS("width", "580px") - await expectMarked(terminal) - await reviewTab.click() - await expect(review).toBeVisible() - expect(pty.sockets).toHaveLength(1) - expect(pty.created).toHaveLength(1) - expect(pty.sockets[0]!.url.searchParams.get("location[directory]")).toBe(directory) - expect(pty.sockets[0]!.url.searchParams.get("ticket")).toBe("e2e-ticket") - expect(pty.tokens[0]?.headers["x-opencode-ticket"]).toBe("1") - - await terminalTab.click() - await expect(terminal).toBeVisible() - await expect - .poll(() => - page.evaluate( - (key) => JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")[key]?.review, - tabKey(a.id), - ), - ) - .toBe(true) - await page.reload() - await expectSessionTitle(page, a.title) - await expect(terminalTab).toHaveAttribute("aria-selected", "true") - await expect(terminal).toBeVisible() - await expect(chat).toHaveCSS("width", "580px") - await reviewTab.click() - await expect(review).toBeVisible() - - const tree = page.locator('#review-panel [data-component="file-tree-v2"]') - await expect(tree.getByRole("treeitem", { name: "generated-0000.ts" })).toBeVisible() - expect(await tree.locator('[data-type="item"]').count()).toBeLessThanOrEqual(60) - const viewport = tree.locator("[data-file-tree-virtualized-scroll]") - await viewport.hover() - await page.mouse.wheel(0, 100_000) - await expect(tree.getByRole("treeitem", { name: "generated-2739.ts" })).toBeVisible() -}) - test("review content stays aligned with the side region in both directions", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) @@ -260,15 +166,6 @@ test("tabs in a narrow side strip shrink to keep their close buttons, and a tab' await expect(side.getByRole("tab", { name: "b.md", exact: true })).toHaveCount(0) }) -// A DOM mark survives only while the same node stays mounted. -function mark(locator: Locator) { - return locator.evaluate((element) => element.setAttribute("data-e2e-mounted", "original")) -} - -function expectMarked(locator: Locator) { - return expect(locator).toHaveAttribute("data-e2e-mounted", "original") -} - async function expectTreeSentinel(page: Page) { const tree = page.locator('#review-panel [data-component="file-tree-v2"]') await expect(tree.getByRole("treeitem", { name: "action.yml" })).toBeVisible() diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 5d98c2735898..6943bc7b47dc 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -4,6 +4,7 @@ import { NO_PROVIDER, REMOTE_SERVER, SERVER, + T0, draftHref, expectPath, holdRoute, @@ -578,8 +579,19 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ await resize(-380, "260px") const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]') + + // The list feathers only the edges with more rows past them. + const feather = () => + scroll.evaluate((element) => { + const style = getComputedStyle(element) + + return [style.getPropertyValue("--vertical-tabs-fade-top"), style.getPropertyValue("--vertical-tabs-fade-bottom")] + }) + + await expect.poll(feather).toEqual(["0px", "16px"]) await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight)) await expect(tabB).toBeInViewport({ ratio: 1 }) + await expect.poll(feather).toEqual(["16px", "0px"]) await page.locator("html").evaluate((element) => element.setAttribute("dir", "rtl")) const home = sidebar.locator('[data-action="vertical-tabs-home"]') @@ -1236,6 +1248,43 @@ test("⌘W closes the session tab again once the window loses focus after the si await expect(sessions.alphaTab).toHaveCount(0) }) +test("⌘W in the side panel closes its last New tab with the panel, and only then the session tab", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + const alpha = { id: "ses_side_new_alpha", title: "Side new alpha" } + const beta = { id: "ses_side_new_beta", title: "Side new beta" } + await openSession(page, { name: "SideCloseNewTab", sessions: [alpha, beta] }) + + const panel = page.locator("#review-panel") + const toggle = page.getByRole("button", { name: "Toggle review", exact: true }) + const newTab = panel.getByRole("tab", { name: "New tab", exact: true }) + const composer = page.getByRole("textbox", { name: "Prompt", exact: true }) + const alphaTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`) + + await composer.click() + await page.keyboard.press("ControlOrMeta+Shift+R") + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect.poll(() => panel.evaluate((element) => element.contains(document.activeElement))).toBe(true) + // A launcher row in the New tab view holds focus as ⌘W removes it. + await panel.getByRole("button", { name: /^Open file/ }).focus() + + // The New tab was the panel's last tab, so closing it closes the panel too, and focus goes back. + await page.keyboard.press("ControlOrMeta+w") + await expect(newTab).toHaveCount(0) + await expect(toggle).toHaveAttribute("aria-expanded", "false") + await expect(composer).toBeFocused() + + // One press too many closes nothing rather than the session. + await page.keyboard.press("ControlOrMeta+w") + await expect(alphaTab).toHaveCount(1) + await expectPath(page, sessionHref(alpha.id)) + + // With no panel open, ⌘W from the composer closes the session tab. + await composer.click() + await page.keyboard.press("ControlOrMeta+w") + await expectPath(page, sessionHref(beta.id)) + await expect(alphaTab).toHaveCount(0) +}) + // Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform. test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => { await mockWorkspace(page, { name: "WindowsMenu", sessions: [] }) @@ -1325,6 +1374,27 @@ test("loads a tab's transcript while the pointer rests on it, and not when it pa expect(requested.filter((id) => id === b.id)).toHaveLength(1) }) +test("⌘W in a focused Context tab closes it, then the panel, and keeps the session tab", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + const alpha = { id: "ses_side_context_alpha", title: "Side context alpha" } + const beta = { id: "ses_side_context_beta", title: "Side context beta" } + await openSession(page, { name: "SideCloseContext", sessions: [alpha, beta] }) + + const panel = page.locator("#review-panel") + const toggle = page.getByRole("button", { name: "Toggle review", exact: true }) + const context = panel.getByRole("tab", { name: "Context", exact: true }) + const alphaTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`) + + await page.getByRole("button", { name: "Toggle session context" }).click() + await expect(context).toHaveAttribute("aria-selected", "true") + await panel.click({ position: { x: 100, y: 300 } }) + await page.keyboard.press("ControlOrMeta+w") + await expect(context).toHaveCount(0) + await expect(toggle).toHaveAttribute("aria-expanded", "false") + await expect(alphaTab).toHaveCount(1) + await expectPath(page, sessionHref(alpha.id)) +}) + // Closes Alpha's side tabs with ⌘W until the side panel empties and collapses, as an overshooting hand would. async function emptySidePanel(page: Page) { await page.setViewportSize({ width: 1440, height: 900 }) @@ -1358,3 +1428,43 @@ async function emptySidePanel(page: Page) { return { beta, alphaTab: page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`) } } + +test("opening a session from the inbox keeps the list's scroll position and rows", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 480 }) + + const sessions = Array.from({ length: 24 }, (_, index) => ({ + id: `ses_inbox_${String(index).padStart(2, "0")}`, + title: `Inbox session ${index}`, + time: { created: T0, updated: T0 + index, idle: T0 + index }, + })) + + await mockWorkspace(page, { + name: "Inbox", + sessions, + seed: { + settings: { appearance: { tabLayout: "inbox" } }, + tabs: [], + // Every seeded session finished a run after the inbox was first used, so Recent lists them all. + storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true, since: 0 } }, + }, + }) + await page.goto("/") + + const list = page.locator('[data-slot="sidebar-inbox-scroll"]') + const items = list.locator('[data-slot="sidebar-inbox-item"]') + const target = items.filter({ hasText: "Inbox session 0" }) + await expect(items).toHaveCount(sessions.length) + await list.evaluate((element) => element.scrollTo(0, element.scrollHeight)) + await expect(target).toBeInViewport({ ratio: 1 }) + const offset = await list.evaluate((element) => element.scrollTop) + expect(offset).toBeGreaterThan(0) + const row = await target.elementHandle() + + await target.click() + await expectPath(page, sessionHref("ses_inbox_00")) + await expect(target).toHaveAttribute("data-state", "active") + // Opening a session loads it into the client, which updates every section. + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + expect(await target.evaluate((element, before) => element === before, row)).toBe(true) + expect(await list.evaluate((element) => element.scrollTop)).toBe(offset) +}) diff --git a/packages/app/e2e/regression/terminal-input.spec.ts b/packages/app/e2e/regression/terminal-input.spec.ts deleted file mode 100644 index 5a69c2d885c8..000000000000 --- a/packages/app/e2e/regression/terminal-input.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { base64Encode, checksum } from "@opencode/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { sessionHref } from "../utils/app" -import { mockWorkspace, openSession, type WorkspaceInput } from "../utils/workspace" -import { expectSessionTitle } from "../utils/waits" - -const workspace = { name: "TerminalInput", pty: {} } satisfies WorkspaceInput - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("clears the terminal line with Command+Delete and suppresses printable Command chords", async ({ page }) => { - const { pty } = await openSession(page, workspace) - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => pty.sockets.length).toBe(1) - - await page.keyboard.press("Meta+k") - await page.keyboard.press("Meta+j") - await terminal.locator("textarea").evaluate((textarea) => { - const clipboardData = new DataTransfer() - clipboardData.setData("text/plain", "echo pasted") - textarea.dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true })) - }) - await expect.poll(() => pty.sockets[0]!.input.join("")).toBe("echo pasted") - - await page.keyboard.press("Meta+Backspace") - await expect.poll(() => pty.sockets[0]!.input.join("")).toBe("echo pasted\x15") -}) - -test("a custom Close tab shortcut closes the focused terminal, not the session tab", async ({ page }) => { - const { session } = await openSession(page, { - ...workspace, - seed: { settings: { keybinds: { "tab.close": "mod+e" } } }, - }) - - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) - - await page.keyboard.press("ControlOrMeta+e") - await expect(terminal).toHaveCount(0) - await expect(page).toHaveURL(sessionHref(session.id)) -}) - -test("reveals the terminal after its first output and hides the native caret", async ({ page }) => { - const { pty } = await openSession(page, workspace) - await page.keyboard.press("Control+Backquote") - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal).toHaveAttribute("contenteditable", "true") - await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)") - await expect(terminal).toHaveCSS("opacity", "0") - await expect.poll(() => pty.sockets.length).toBe(1) - - pty.send("\x1b[?25h") - await expect(terminal).toHaveCSS("opacity", "0") - pty.send("ready") - await expect(terminal).toHaveCSS("opacity", "1") -}) - -test("quotes selected terminal output into the composer", async ({ page }) => { - const { pty, editor } = await openSession(page, workspace) - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect.poll(() => pty.sockets.length).toBe(1) - pty.send("$ bun run build\r\nerror: Cannot find module './config'\r\n") - await expect(terminal).toHaveCSS("opacity", "1") - - const canvas = terminal.locator("canvas").first() - const box = await canvas.boundingBox() - - if (!box) throw new Error("The terminal has no canvas") - // From the first output row's start to well into the second. - await page.mouse.move(box.x + 2, box.y + 4) - await page.mouse.down() - await page.mouse.move(box.x + 320, box.y + 26, { steps: 8 }) - await page.mouse.up() - - const toolbar = page.getByRole("toolbar", { name: "Quote selection" }) - await expect(toolbar).toBeVisible() - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() - - const chips = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') - await expect(chips).toHaveCount(1) - await expect(chips).toContainText("Terminal") - await expect(toolbar).toBeHidden() - const comment = page.locator('[data-component="composer-quote-comment"] textarea') - await expect(comment).toBeFocused() - await comment.fill("Investigate this output") - - const atTile = async () => { - const popover = await page.locator('[data-component="composer-quote-comment"]').boundingBox() - const tile = await chips.nth(0).boundingBox() - - return ( - popover && tile && Math.abs(popover.x - tile.x) < 1 && Math.abs(popover.y + popover.height - (tile.y - 8)) < 1 - ) - } - - await expect.poll(atTile).toBe(false) - // Cached terminals can hide without a write or grid resize; the unsaved editor still follows its tile. - await page.getByRole("button", { name: "Add tab", exact: true }).click() - await expect(terminal).toBeHidden() - await expect.poll(atTile).toBe(true) - await expect(comment).toHaveValue("Investigate this output") - await page.locator("#review-panel").getByRole("tab", { name: "Terminal 1", exact: true }).click() - await expect(terminal).toBeVisible() - await expect.poll(atTile).toBe(false) - await expect(comment).toHaveValue("Investigate this output") - - // Any output invalidates terminal row identity; the same draft stays open beside its composer tile. - pty.send(Array.from({ length: 80 }, (_, index) => `output row ${index}\r\n`).join("")) - await expect.poll(atTile).toBe(true) - await expect(comment).toHaveValue("Investigate this output") - await comment.fill("") - await comment.press("Enter") - await expect(comment).toHaveCount(0) - await expect(editor).toBeFocused() - - // The plugin's Alt path skips the editor too, and either quote can still be commented from its tile. - const next = await canvas.boundingBox() - - if (!next) throw new Error("The terminal has no canvas") - await page.mouse.move(next.x + 2, next.y + 4) - await page.mouse.down() - await page.mouse.move(next.x + 320, next.y + 26, { steps: 8 }) - await page.mouse.up() - await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) - await expect(chips).toHaveCount(2) - await expect(comment).toHaveCount(0) - await expect(editor).toBeFocused() - await chips.nth(0).click() - await comment.fill("Why did the build fail?") - await comment.press("Enter") - await expect(chips.nth(0)).toContainText("Why did the build fail?") -}) - -test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { - const { pty, editor } = await openSession(page, workspace) - const terminal = page.locator('[data-component="terminal"]') - await editor.click() - await expect(editor).toBeFocused() - await page.keyboard.press("Control+Backquote") - await expect(terminal).toBeVisible() - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) - - await page.keyboard.type("x") - await expect(editor).toHaveText("") - await expect.poll(() => pty.sockets.length).toBe(1) - pty.send("ready") - await expect(terminal).toHaveCSS("opacity", "1") - await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) document.activeElement.blur() - }) - await page.keyboard.type("a") - await expect(editor).toBeFocused() - await expect(editor).toHaveText("a") -}) - -for (const mount of ["cached", "explicit"] as const) { - test(`keeps newer composer focus while a ${mount} terminal finishes mounting`, async ({ page }) => { - const ghostty = Promise.withResolvers() - const release = Promise.withResolvers() - await page.route(/ghostty-web/, async (route) => { - ghostty.resolve() - await release.promise - await route.continue() - }) - const cached = { id: "pty_cached", title: "Terminal 1" } - - const { pty, sessions } = await mockWorkspace(page, { - ...workspace, - pty: mount === "cached" ? { initial: [cached] } : {}, - seed: mount === "cached" ? restoredTerminal(cached) : undefined, - }) - - await page.goto(sessionHref(sessions[0]!.id), { waitUntil: "commit" }) - await expectSessionTitle(page, "TerminalInput") - const editor = page.locator('[data-component="composer-editor"]') - const terminal = page.locator('[data-component="terminal"]') - - if (mount === "explicit") { - await expect(editor).toBeEditable() - await page.keyboard.press("Control+Backquote") - } - - await expect(terminal).toBeVisible() - await ghostty.promise - await editor.click() - await expect(editor).toBeFocused() - - release.resolve() - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => pty.sockets.length).toBe(1) - pty.send("ready") - await expect(terminal).toHaveCSS("opacity", "1") - await expect(editor).toBeFocused() - - if (mount === "cached") expect(pty.created).toEqual([]) - }) -} - -test("reconnects the same terminal after a transient connection failure", async ({ page }) => { - const { pty } = await openSession(page, workspace) - let tickets = 0 - await page.route(/\/api\/pty\/[^/]+\/connect-token/, (route) => { - if (route.request().method() === "OPTIONS") return route.fallback() - tickets += 1 - - if (tickets !== 2) return route.fallback() - - return route.fulfill({ - status: 503, - headers: { "access-control-allow-origin": "*" }, - body: "Service Unavailable", - }) - }) - await page.keyboard.press("Control+Backquote") - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => pty.sockets.length).toBe(1) - const id = pty.sockets[0]!.id - await terminal.evaluate((element) => element.setAttribute("data-connection-probe", "original")) - - await pty.sockets[0]!.close(1011, "Temporary disconnection") - await expect.poll(() => tickets).toBe(3) - await expect.poll(() => pty.sockets.length).toBe(2) - expect(pty.sockets[1]!.id).toBe(id) - expect(pty.rejected).toEqual([]) - await expect(terminal).toHaveAttribute("data-connection-probe", "original") - expect(pty.created.map((item) => item.id)).toEqual([id]) -}) - -test("opens terminals as side panel tabs from the + menu", async ({ page }) => { - const { editor, pty } = await openSession(page, workspace) - const panel = page.locator("#review-panel") - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(panel.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute("aria-selected", "true") - await expect(terminal.locator("textarea")).toHaveCount(1) - - const add = async () => { - await editor.click() - await expect(editor).toBeFocused() - await panel.getByRole("button", { name: "Add tab", exact: true }).click() - await panel.getByRole("button", { name: /^Terminal/ }).click() - } - - for (const number of [2, 3]) { - await add() - await expect(panel.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute( - "aria-selected", - "true", - ) - const active = page.locator(`#terminal-wrapper-pty_${number} [data-component="terminal"]`) - await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true) - } - - // Closing a tab closes its terminal, and the next one takes the lowest free number. - await closeTab(page, "pty_2") - await expect.poll(() => pty.removed).toEqual(["pty_2"]) - await expect(panel.getByRole("tab", { name: /^Terminal / })).toHaveText(["Terminal 1", "Terminal 3"]) - await add() - await expect(panel.getByRole("tab", { name: /^Terminal / })).toHaveText(["Terminal 1", "Terminal 3", "Terminal 2"]) - await expect(panel.getByRole("tab", { name: "Terminal 2", exact: true })).toHaveAttribute("aria-selected", "true") -}) - -test("focuses the terminal opened from the command palette", async ({ page }) => { - const { editor } = await openSession(page, workspace) - const terminal = page.locator('[data-component="terminal"]') - await editor.click() - await page.keyboard.press("ControlOrMeta+Shift+P") - const palette = page.getByRole("dialog") - const search = palette.getByRole("combobox") - await expect(search).toBeFocused() - await search.fill("Toggle terminal") - await expect(palette.getByRole("option", { name: /^Toggle terminal/ })).toHaveAttribute("aria-selected", "true") - - await search.press("Enter") - await expect(palette).toHaveCount(0) - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) -}) - -test("closing the last restored terminal does not create a hidden replacement", async ({ page }) => { - const cached = { id: "pty_cached", title: "Terminal 1" } - const { pty } = await openSession(page, { ...workspace, pty: { initial: [cached] }, seed: restoredTerminal(cached) }) - const panel = page.locator("#review-panel") - const tab = panel.getByRole("tab", { name: "Terminal 1", exact: true }) - - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => pty.sockets.length).toBe(1) - expect(pty.created).toEqual([]) - - await closeTab(page, cached.id) - await expect(terminal).toBeHidden() - await expect.poll(() => pty.removed).toEqual([cached.id]) - expect(pty.created).toEqual([]) - - await page.keyboard.press("Control+Backquote") - await expect(terminal.locator("textarea")).toHaveCount(1) - await expect.poll(() => pty.created.map((item) => item.id)).toEqual(["pty_1"]) - await expect(tab).toHaveAttribute("aria-selected", "true") -}) - -// A terminal tab restored open in the side panel, with the terminal in the pre-plugin store, which the terminal -// plugin imports. -function restoredTerminal(pty: { id: string; title: string }) { - const key = `terminal:${pty.id}` - - return { - panes: { ses_terminalinput: { review: true } }, - storage: { - [legacyTerminalKey()]: { active: pty.id, all: [{ ...pty, titleNumber: 1 }] }, - "opencode.global.dat:layout": { - sessionTabs: { [`local\u0000${directory()}/ses_terminalinput`]: { all: [key], active: key } }, - }, - }, - } -} - -function closeTab(page: Page, id: string) { - return page - .locator(`#review-panel [data-value="terminal:${id}"]`) - .first() - .getByRole("button", { name: "Close tab", exact: true }) - .click() -} - -function directory() { - return base64Encode("C:/OpenCode/TerminalInput") -} - -function legacyTerminalKey() { - const dir = directory() - const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-") - - return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal` -} diff --git a/packages/app/e2e/regression/terminal.spec.ts b/packages/app/e2e/regression/terminal.spec.ts new file mode 100644 index 000000000000..524d24b14f59 --- /dev/null +++ b/packages/app/e2e/regression/terminal.spec.ts @@ -0,0 +1,523 @@ +import { expect, test, type Page } from "@playwright/test" +import { holdRoute } from "../utils/app" +import { terminalScreen, terminalText } from "../utils/terminal" +import { openSession } from "../utils/workspace" + +test.use({ viewport: { width: 1440, height: 900 } }) + +test.beforeEach(async ({ context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) +}) + +const SESSION = "ses_terminals" + +test("restores a terminal from the server's snapshot after a reload", async ({ page }) => { + const output = "first-line\r\n" + + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_restore", sessionID: SESSION, title: "Build", output }] }, + }) + + const screen = terminalScreen(page, "pty_restore") + + await openSide(page) + await side(page).getByRole("tab", { name: "Build", exact: true }).click() + await expect.poll(() => terminalText(page, screen)).toBe("first-line") + expect(persistentPty.sockets.map((socket) => socket.cursor)).toEqual([Buffer.byteLength(output)]) + + // Output the page never saw: only the server's snapshot can restore it. + const snapshot = await holdRoute(page, /\/api\/experimental\/persistent-pty\/pty_restore\/snapshot/) + await page.reload() + await snapshot.arrived + persistentPty.output("pty_restore", "while-away\r\n") + snapshot.release() + + await expect(side(page).getByRole("tab", { name: "Build", exact: true })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminalText(page, screen)).toBe("first-line\nwhile-away") + expect(persistentPty.sockets.map((socket) => socket.cursor)).toEqual([ + Buffer.byteLength(output), + Buffer.byteLength(output + "while-away\r\n"), + ]) +}) + +test("after a reload, the close shortcut closes the restored terminal", async ({ page }) => { + // Elsewhere Ctrl+W reaches the shell, so the terminal hands only macOS's Cmd+W to the app. + await page.addInitScript(() => Object.defineProperty(navigator, "platform", { get: () => "MacIntel" })) + + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { + initial: [ + { id: "pty_build", sessionID: SESSION, title: "Build" }, + { id: "pty_shell", sessionID: SESSION, title: "Shell" }, + ], + }, + }) + + await openSide(page) + await showTerminal(page, "Shell", "pty_shell") + + // The restored tab is selected before the server lists the session's terminals. + const list = await holdRoute(page, /\/api\/experimental\/session\/ses_terminals\/terminal(\?|$)/, { method: "GET" }) + await page.reload() + await list.arrived + list.release() + + await expect(side(page).getByRole("tab", { name: "Shell", exact: true })).toHaveAttribute("aria-selected", "true") + // The terminal connects once it has mounted, and only a mounted terminal takes a click. + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual(["pty_shell", "pty_shell"]) + await terminalScreen(page, "pty_shell").click() + await expect(terminalScreen(page, "pty_shell").locator("textarea")).toBeFocused() + await page.keyboard.press("Meta+W") + + await expect(side(page).getByRole("tab", { name: "Shell", exact: true })).toHaveCount(0) + await expect(side(page).getByRole("tab", { name: "Build", exact: true })).toBeVisible() + expect(persistentPty.removed).toEqual(["pty_shell"]) +}) + +test("a terminal another client creates joins the strip without taking it over", async ({ page }) => { + const { persistentPty } = await openSession(page, { name: "Terminals", persistentPty: {} }) + + await openSide(page) + await expect(side(page).getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true") + persistentPty.add({ id: "pty_remote", sessionID: SESSION, title: "Remote shell", output: "from-elsewhere\r\n" }) + + const tab = side(page).getByRole("tab", { name: "Remote shell", exact: true }) + await expect(tab).toHaveAttribute("aria-selected", "false") + await expect(side(page).getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true") + expect(persistentPty.sockets).toHaveLength(0) + + await tab.click() + await expect.poll(() => terminalText(page, terminalScreen(page, "pty_remote"))).toBe("from-elsewhere") +}) + +test("a new terminal opens focused, takes control, and streams input and output", async ({ page }) => { + const { persistentPty } = await openSession(page, { name: "Terminals", persistentPty: {} }) + + await openSide(page) + await side(page) + .getByRole("button", { name: /^Terminal/ }) + .click() + + await expect(side(page).getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + expect(persistentPty.created.map((info) => ({ title: info.title, cwd: info.cwd }))).toEqual([ + { title: "Terminal 1", cwd: "C:/OpenCode/Terminals" }, + ]) + const id = persistentPty.created[0]!.id + await expect(terminalScreen(page, id).locator("textarea")).toBeFocused() + await expect.poll(() => persistentPty.sockets.map((socket) => socket.takeover)).toEqual([true]) + const socket = persistentPty.sockets[0]! + await expect.poll(() => socket.frames.filter((frame) => frame.type === 0).length).toBeGreaterThan(0) + expect(persistentPty.controller(id)).toBe(socket.attachmentID) + + await page.keyboard.type("echo hi") + await expect + .poll(() => + socket.frames + .filter((frame) => frame.type === 1) + .map((frame) => frame.data) + .join(""), + ) + .toBe("echo hi") + + persistentPty.output(id, "hi\r\n") + await expect.poll(() => terminalText(page, terminalScreen(page, id))).toBe("hi") +}) + +test("the New terminal shortcut creates the next numbered terminal", async ({ page }) => { + const { persistentPty, editor } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_first", sessionID: SESSION, title: "Terminal 1" }] }, + }) + + await openSide(page) + await expect(side(page).getByRole("tab", { name: "Terminal 1", exact: true })).toBeVisible() + await editor.click() + await page.keyboard.press("Control+Alt+T") + + await expect(side(page).getByRole("tab", { name: "Terminal 2", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + expect(persistentPty.created.map((info) => info.title)).toEqual(["Terminal 2"]) + await expect(terminalScreen(page, persistentPty.created[0]!.id).locator("textarea")).toBeFocused() +}) + +test("another client's control ends at a click, which resizes the terminal to the pane", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_shared", sessionID: SESSION, title: "Shared" }] }, + }) + + await openSide(page) + await side(page).getByRole("tab", { name: "Shared", exact: true }).click() + await expect.poll(() => persistentPty.sockets.length).toBe(1) + const socket = persistentPty.sockets[0]! + await expect.poll(() => persistentPty.controller("pty_shared")).toBe(socket.attachmentID) + + // The other client controls the terminal at its own size. + persistentPty.takeControl("pty_shared") + persistentPty.resize("pty_shared", { cols: 40, rows: 10 }) + const sent = socket.frames.length + + await terminalScreen(page, "pty_shared").click() + await expect.poll(() => persistentPty.controller("pty_shared")).toBe(socket.attachmentID) + await expect.poll(() => socket.frames.length).toBeGreaterThan(sent) + const pane = () => paneSize(socket.frames.at(-1)!) + expect(socket.frames.slice(sent).map(paneSize)).not.toContainEqual({ cols: 40, rows: 10 }) + await expect.poll(() => persistentPty.terminals[0]!.size).toEqual(pane()) + + // While it controls the terminal, the pane's new size follows. + const before = pane() + await page.setViewportSize({ width: 1200, height: 600 }) + await expect.poll(() => pane().rows).toBeLessThan(before.rows) + await expect.poll(() => persistentPty.terminals[0]!.size).toEqual(pane()) +}) + +test("a terminal whose shell exits leaves the strip", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { + initial: [ + { id: "pty_build", sessionID: SESSION, title: "Build" }, + { id: "pty_shell", sessionID: SESSION, title: "Shell" }, + ], + }, + }) + + await openSide(page) + await side(page).getByRole("tab", { name: "Build", exact: true }).click() + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual(["pty_build"]) + + persistentPty.exit("pty_build", 0) + await expect(side(page).getByRole("tab", { name: "Build", exact: true })).toHaveCount(0) + await expect(side(page).getByRole("tab", { name: "Shell", exact: true })).toBeVisible() + await expect(page.getByText("Terminal disconnected", { exact: true })).toHaveCount(0) +}) + +test("a dropped connection says the terminal disconnected and keeps its tab", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_drop", sessionID: SESSION, title: "Build", output: "still-here\r\n" }] }, + }) + + await openSide(page) + await side(page).getByRole("tab", { name: "Build", exact: true }).click() + const screen = terminalScreen(page, "pty_drop") + await expect.poll(() => terminalText(page, screen)).toBe("still-here") + + await persistentPty.close("pty_drop") + await expect(side(page).getByText("Terminal disconnected", { exact: true })).toBeVisible() + await expect(side(page).getByRole("tab", { name: "Build", exact: true })).toHaveAttribute("aria-selected", "true") + expect(persistentPty.sockets).toHaveLength(1) + expect(await terminalText(page, screen)).toBe("still-here") +}) + +test("after a server restart, showing the terminal again restores it from a new snapshot", async ({ page }) => { + const output = "before-restart\r\n" + + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_handoff", sessionID: SESSION, title: "Build", output }] }, + }) + + await openSide(page) + await side(page).getByRole("tab", { name: "Build", exact: true }).click() + const screen = terminalScreen(page, "pty_handoff") + await expect.poll(() => terminalText(page, screen)).toBe("before-restart") + + await persistentPty.restart() + await expect(side(page).getByText("Terminal disconnected", { exact: true })).toBeVisible() + // The adopted daemon kept the shell running. + persistentPty.output("pty_handoff", "after-restart\r\n") + + // Showing the tab again takes a new snapshot and reconnects. + await side(page).getByRole("button", { name: "Add tab", exact: true }).click() + await expect(side(page).getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true") + await side(page).getByRole("tab", { name: "Build", exact: true }).click() + await expect.poll(() => terminalText(page, screen)).toBe("before-restart\nafter-restart") + await expect(side(page).getByText("Terminal disconnected", { exact: true })).toHaveCount(0) + expect(persistentPty.sockets.map((socket) => socket.cursor)).toEqual([ + Buffer.byteLength(output), + Buffer.byteLength(output + "after-restart\r\n"), + ]) +}) + +test("a server without persistent terminals offers no terminal commands", async ({ page }) => { + const { editor } = await openSession(page, { name: "Terminals", persistentPty: { capability: false } }) + + await openSide(page) + const launchers = side(page).getByRole("tabpanel", { name: "New tab", exact: true }) + await expect(launchers.getByRole("button", { name: /^Open file/ })).toBeVisible() + await expect(launchers.getByRole("button", { name: /^Terminal/ })).toHaveCount(0) + + await editor.click() + await page.keyboard.type("/") + await expect(page.getByRole("button", { name: /^\/open/ })).toBeVisible() + await expect(page.getByRole("button", { name: /^\/terminal/ })).toHaveCount(0) +}) + +test("selected terminal output quotes into the composer", async ({ page }) => { + await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_quote", sessionID: SESSION, title: "Build", output: "quote-me\r\n" }] }, + }) + + await openSide(page) + await side(page).getByRole("tab", { name: "Build", exact: true }).click() + const screen = terminalScreen(page, "pty_quote") + await expect.poll(() => terminalText(page, screen)).toBe("quote-me") + + await page + .getByRole("toolbar", { name: "Quote selection", exact: true }) + .getByRole("button", { name: "Quote" }) + .click() + await expect(page.locator('[data-component="composer"]').getByText("quote-me")).toBeVisible() +}) + +test("renaming a terminal tab renames it on the server", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_named", sessionID: SESSION, title: "Terminal 1" }] }, + }) + + await openSide(page) + const strip = side(page).getByRole("tablist") + await showTerminal(page, "Terminal 1", "pty_named") + await strip.getByText("Terminal 1", { exact: true }).dblclick() + const field = strip.getByRole("textbox", { name: "Rename", exact: true }) + await expect(field).toBeFocused() + await field.fill("Dev server") + await field.press("Enter") + + await expect(strip.getByRole("tab", { name: "Dev server", exact: true })).toBeVisible() + expect(persistentPty.updates).toEqual([{ id: "pty_named", body: { title: "Dev server" } }]) + await expect.poll(() => persistentPty.terminals[0]!.title).toBe("Dev server") +}) + +test("a rename from another client relabels the tab", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_named", sessionID: SESSION, title: "Terminal 1" }] }, + }) + + await openSide(page) + await expect(side(page).getByRole("tab", { name: "Terminal 1", exact: true })).toBeVisible() + persistentPty.rename("pty_named", "Logs") + await expect(side(page).getByRole("tab", { name: "Logs", exact: true })).toBeVisible() + await expect(side(page).getByRole("tab", { name: "Terminal 1", exact: true })).toHaveCount(0) +}) + +test("clearing a terminal's name resets a renamed one and leaves a never-renamed one alone", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { + initial: [ + { id: "pty_plain", sessionID: SESSION, title: "Terminal 1" }, + { id: "pty_renamed", sessionID: SESSION, title: "Terminal 2", userTitle: "Deploy" }, + ], + }, + }) + + await openSide(page) + const strip = side(page).getByRole("tablist") + const field = strip.getByRole("textbox", { name: "Rename", exact: true }) + + await showTerminal(page, "Terminal 1", "pty_plain") + await strip.getByText("Terminal 1", { exact: true }).dblclick() + await expect(field).toBeFocused() + await field.fill("") + await field.press("Enter") + await expect(field).toHaveCount(0) + await expect(strip.getByRole("tab", { name: "Terminal 1", exact: true })).toBeVisible() + + await showTerminal(page, "Deploy", "pty_renamed") + await strip.getByText("Deploy", { exact: true }).dblclick() + await expect(field).toBeFocused() + await field.fill("") + await field.press("Enter") + + await expect(strip.getByRole("tab", { name: "Terminal 2", exact: true })).toBeVisible() + await expect(strip.getByRole("tab", { name: "Deploy", exact: true })).toHaveCount(0) + // The first clear sent nothing: the only update is the second. + expect(persistentPty.updates).toEqual([{ id: "pty_renamed", body: { title: null } }]) +}) + +test("a server whose terminal service cannot rename yet keeps the old name and says when it will", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { + renameSupported: false, + initial: [{ id: "pty_named", sessionID: SESSION, title: "Terminal 1" }], + }, + }) + + await openSide(page) + const strip = side(page).getByRole("tablist") + await showTerminal(page, "Terminal 1", "pty_named") + await strip.getByText("Terminal 1", { exact: true }).dblclick() + const field = strip.getByRole("textbox", { name: "Rename", exact: true }) + await expect(field).toBeFocused() + await field.fill("Dev server") + await field.press("Enter") + + await expect( + page.getByText("Terminal renaming will be available after OpenCode's terminal service restarts", { exact: true }), + ).toBeVisible() + await expect(strip.getByRole("tab", { name: "Terminal 1", exact: true })).toBeVisible() + await expect(strip.getByRole("tab", { name: "Dev server", exact: true })).toHaveCount(0) + expect(persistentPty.terminals[0]!.title).toBe("Terminal 1") +}) + +test.describe("narrow screens", () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test("the Terminal view opens from More... and shows the session's terminal", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_mobile", sessionID: SESSION, title: "Build", output: "on-mobile\r\n" }] }, + }) + + const tabs = page.getByRole("tablist", { name: "Session view", exact: true }) + const views = page.getByRole("dialog", { name: "More options", exact: true }) + await tabs.getByRole("tab", { name: "More...", exact: true }).click() + await views.getByRole("button", { name: "Terminal", exact: true }).click() + await expect(views).toBeHidden() + + const panel = page.locator("#terminal-panel") + await expect(panel.getByRole("tab", { name: "Build", exact: true })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminalText(page, terminalScreen(page, "pty_mobile"))).toBe("on-mobile") + expect(persistentPty.created).toEqual([]) + }) + + test("an empty session's Terminal view starts its first terminal", async ({ page }) => { + const { persistentPty } = await openSession(page, { name: "Terminals", persistentPty: {} }) + + await page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "More..." }).click() + await page + .getByRole("dialog", { name: "More options", exact: true }) + .getByRole("button", { name: "Terminal" }) + .click() + + const panel = page.locator("#terminal-panel") + await expect(panel.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute("aria-selected", "true") + expect(persistentPty.created.map((info) => info.title)).toEqual(["Terminal 1"]) + await expect(terminalScreen(page, persistentPty.created[0]!.id).locator("textarea")).toBeEditable() + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual([persistentPty.created[0]!.id]) + }) + + test("double-clicking a terminal tab that is not selected yet starts renaming it", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { + initial: [ + { id: "pty_first", sessionID: SESSION, title: "Build" }, + { id: "pty_second", sessionID: SESSION, title: "Deploy" }, + ], + }, + }) + + await page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "More..." }).click() + await page + .getByRole("dialog", { name: "More options", exact: true }) + .getByRole("button", { name: "Terminal" }) + .click() + + const panel = page.locator("#terminal-panel") + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual(["pty_first"]) + const field = panel.getByRole("textbox", { name: "Rename", exact: true }) + await panel.getByText("Deploy", { exact: true }).dblclick() + await expect(field).toBeFocused() + // The terminal connects after it mounts and asks for focus. + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual(["pty_first", "pty_second"]) + await expect(field).toBeFocused() + }) + + test("Escape cancels a terminal rename and Enter renames once", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_mobile", sessionID: SESSION, title: "Build" }] }, + }) + + await page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "More..." }).click() + await page + .getByRole("dialog", { name: "More options", exact: true }) + .getByRole("button", { name: "Terminal" }) + .click() + + const panel = page.locator("#terminal-panel") + const field = panel.getByRole("textbox", { name: "Rename", exact: true }) + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual(["pty_mobile"]) + + await panel.getByText("Build", { exact: true }).dblclick() + await expect(field).toBeFocused() + await field.fill("Cancelled") + await field.press("Escape") + await expect(field).toHaveCount(0) + await expect(panel.getByRole("tab", { name: "Build", exact: true })).toBeVisible() + + await panel.getByText("Build", { exact: true }).dblclick() + await expect(field).toBeFocused() + await field.fill("Deploy") + await field.press("Enter") + await expect(panel.getByRole("tab", { name: "Deploy", exact: true })).toBeVisible() + expect(persistentPty.updates).toEqual([{ id: "pty_mobile", body: { title: "Deploy" } }]) + }) +}) + +// Selecting the tab mounts its terminal, which asks for focus; the rename field keeps it. +test("double-clicking a terminal tab that is not selected yet starts renaming it", async ({ page }) => { + const { persistentPty } = await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_named", sessionID: SESSION, title: "Terminal 1" }] }, + }) + + // The terminal mounts once ghostty-web loads, which here waits until the rename field has focus. + const ghostty = await holdRoute(page, /\/_assets\/ghostty-web-[^/]+\.js$/) + await openSide(page) + const strip = side(page).getByRole("tablist") + const field = strip.getByRole("textbox", { name: "Rename", exact: true }) + await strip.getByText("Terminal 1", { exact: true }).dblclick() + await expect(field).toBeFocused() + ghostty.release() + // The terminal connects after it mounts and asks for focus. + await expect.poll(() => persistentPty.sockets.map((socket) => socket.id)).toEqual(["pty_named"]) + await expect(field).toBeFocused() +}) + +// The menu returns focus to the tab as it closes; choosing Rename moves it to the rename field. +test("a terminal tab's context menu starts renaming it", async ({ page }) => { + await openSession(page, { + name: "Terminals", + persistentPty: { initial: [{ id: "pty_named", sessionID: SESSION, title: "Terminal 1" }] }, + }) + + await openSide(page) + const strip = side(page).getByRole("tablist") + await showTerminal(page, "Terminal 1", "pty_named") + await strip.getByText("Terminal 1", { exact: true }).click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + await expect(strip.getByRole("textbox", { name: "Rename", exact: true })).toBeFocused() +}) + +function side(page: Page) { + return page.getByRole("complementary", { name: "Review and files", exact: true }) +} + +async function openSide(page: Page) { + await page.getByRole("button", { name: "Toggle review", exact: true }).click() + await expect(side(page)).toBeVisible() +} + +async function showTerminal(page: Page, title: string, ptyID: string) { + await side(page).getByRole("tab", { name: title, exact: true }).click() + await expect(terminalScreen(page, ptyID).locator("textarea")).toBeFocused() +} + +function paneSize(frame: { cols: number; rows: number }) { + return { cols: frame.cols, rows: frame.rows } +} diff --git a/packages/app/e2e/regression/timeline-history.spec.ts b/packages/app/e2e/regression/timeline-history.spec.ts index 4e8fb868bde7..472d84c78339 100644 --- a/packages/app/e2e/regression/timeline-history.spec.ts +++ b/packages/app/e2e/regression/timeline-history.spec.ts @@ -5,12 +5,15 @@ import { trackPageErrors } from "../utils/errors" import { fixture, installTimelineSettings, mockStressTimeline } from "../utils/session-fixture" import { assistantMessage, + historyMessages, messageUpdated, + partUpdated, sessionID, setupTimeline, status, textPart, userMessage, + validateTimelineMessages, } from "../utils/timeline" import { expectSessionTitle } from "../utils/waits" import { openSession } from "../utils/workspace" @@ -413,6 +416,92 @@ test.describe("timeline history", () => { } }) } + + test("pages the timeline from the composer, resuming follow at the end and loading history at the top", async ({ + page, + }) => { + const messages = validateTimelineMessages([ + ...historyMessages(30), + userMessage(), + assistantMessage([textPart("prt_page_keys_text", "Paging from the composer")], { completed: false }), + ]) + + const older: string[] = [] + + const timeline = await setupTimeline(page, { + reducedMotion: true, + message: (_, id) => messages.find((message) => message.id === id), + pageMessages: (_, limit, before) => { + const end = before ? messages.findIndex((message) => message.id === before) : messages.length + const start = Math.max(0, end - limit) + + return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined } + }, + onMessages: (request) => { + if (request.before && request.phase === "start") older.push(request.before) + }, + }) + + const scroller = timelineScroller(page) + const editor = page.locator('[data-component="composer-editor"]') + const suggestions = page.locator('[data-component="composer-suggestions"]') + const top = () => scroller.evaluate((element) => element.scrollTop) + + const fromEnd = () => + scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop) + + const caret = () => + editor.evaluate((element) => { + const selection = window.getSelection()! + const range = document.createRange() + range.setStart(element, 0) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + + return range.toString().length + }) + + await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible") + await expect.poll(fromEnd).toBeLessThanOrEqual(1) + await editor.click() + + // An open popup keeps the page keys: the timeline stays put and the query stays open. + await page.keyboard.type("/") + await expect(suggestions).toBeVisible() + const resting = await top() + await editor.press("PageUp") + expect(await top()).toBe(resting) + await expect(suggestions).toBeVisible() + await expect(editor).toHaveText("/") + await editor.press("Escape") + await expect(suggestions).toBeHidden() + await editor.press("Backspace") + + await page.keyboard.type("keep this draft") + + const pageHeight = await page + .locator('[data-slot="session-timeline-visible"]') + .evaluate((element) => element.clientHeight) + + await editor.press("PageUp") + await expect.poll(top).toBeLessThan(resting - pageHeight * 0.6) + expect(resting - (await top())).toBeLessThan(pageHeight) + await expect(editor).toBeFocused() + await expect(editor).toHaveText("keep this draft") + expect(await caret()).toBe("keep this draft".length) + + // Paging back to the end follows new content again, as a wheel scroll there does. + for (let index = 0; index < 10 && (await fromEnd()) > 1; index++) await editor.press("PageDown") + await expect.poll(fromEnd).toBeLessThanOrEqual(1) + await timeline.send(partUpdated(textPart("prt_page_keys_tail", "Arrived after paging down.\n\n".repeat(20)))) + await timeline.waitForPart("prt_page_keys_tail") + await expect.poll(fromEnd).toBeLessThanOrEqual(1) + + // Paging to the top loads the older page, as scrolling there does. + for (let index = 0; index < 30 && (await top()) > 1; index++) await editor.press("PageUp") + await expect.poll(() => older[0]).toBe(messages[messages.length - 40]!.id) + await expect(editor).toBeFocused() + await expect(editor).toHaveText("keep this draft") + }) }) declare global { diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index 707e47552e0c..ef0608d0c8a4 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -15,6 +15,7 @@ import { inboxDelivered, inboxEnqueued, messageUpdated, + partDelta, partUpdated, reasoningPart, renderedPartID, @@ -1010,6 +1011,33 @@ test.describe("Working", () => { }) }) +test("grows a streaming row without a ResizeObserver loop error", async ({ page }) => { + // Each streamed paragraph grows its row. An observer on the content that holds every row saw that height inside the + // row observer's own delivery, and Chrome reported every such frame as an uncaught error. + await page.addInitScript(() => { + window.addEventListener("error", (event) => { + if (!event.message.startsWith("ResizeObserver loop")) return + document.documentElement.dataset.resizeObserverLoops = String( + Number(document.documentElement.dataset.resizeObserverLoops ?? 0) + 1, + ) + }) + }) + + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([textPart("prt_growth", "Streaming.")], { completed: false })], + }) + + const text = page.locator(`[data-timeline-part-id="${renderedPartID("prt_growth")}"]`) + + for (const line of [1, 2, 3, 4, 5, 6]) { + await timeline.send(partDelta("prt_growth", `\n\nParagraph ${line}.`)) + await expect(text).toContainText(`Paragraph ${line}.`) + } + + await expect(text.locator("p")).toHaveCount(7) + expect(await page.evaluate(() => document.documentElement.dataset.resizeObserverLoops)).toBeUndefined() +}) + test.describe("background shortcut", () => { test("offers a standalone running subagent to the background with Ctrl+B", async ({ page }) => { await setupTimeline(page, { diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 1f236218f81b..37ee25b02aca 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -1,5 +1,6 @@ import { Predicate, Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/http-api" +import { PersistentPty } from "@opencode/schema/persistent-pty" import { Pty } from "@opencode/schema/pty" import { Worktree } from "@opencode/schema/worktree" @@ -69,6 +70,18 @@ export class MockPtyNotFound extends Schema.TaggedError()("PtyN message: Schema.String, }) {} +// The server's error when a service it needs, such as the opencode-pty daemon, cannot run. +export class MockServiceUnavailable extends Schema.TaggedError()("ServiceUnavailableError", { + message: Schema.String, + service: Schema.optional(Schema.String), +}) {} + +// The server's error for a feature the connected opencode-pty daemon lacks, such as `rename`. +export class MockPtyFeatureUnsupported extends Schema.TaggedError()( + "PtyFeatureUnsupportedError", + { feature: Schema.String, message: Schema.String }, +) {} + // The server's error for a request without its password. export class MockUnauthorized extends Schema.TaggedError()("UnauthorizedError", { message: Schema.String, @@ -81,7 +94,16 @@ export class MockUnsupported extends Schema.TaggedError()("Mock const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501)) -const PtyMissing = [MockNotFound.pipe(HttpApiSchema.status(404)), MockPtyNotFound.pipe(HttpApiSchema.status(404))] +const PersistentPtyUnavailable = MockServiceUnavailable.pipe(HttpApiSchema.status(503)) + +const PersistentPtyMissing = [MockPtyNotFound.pipe(HttpApiSchema.status(404)), PersistentPtyUnavailable] + +// `PersistentPty.UpdateInput` once terminal renaming lands; the schema still requires `size`. +const PersistentPtyUpdate = Schema.Struct({ + attachmentID: Schema.optional(Schema.String), + size: Schema.optional(Schema.Struct({ cols: Schema.Number, rows: Schema.Number })), + title: Schema.optional(Schema.NullOr(Schema.String)), +}) const Group = HttpApiGroup.make("mock") .add(HttpApiEndpoint.get("info", "/api/info", { success: Json })) @@ -250,45 +272,54 @@ const Group = HttpApiGroup.make("mock") }), ) .add( - HttpApiEndpoint.get("ptyList", "/api/pty", { + HttpApiEndpoint.get("persistentPtyList", "/api/experimental/session/:sessionID/terminal", { + params: SessionParams, success: Json, - error: MockNotFound.pipe(HttpApiSchema.status(404)), + error: PersistentPtyUnavailable, }), ) .add( - HttpApiEndpoint.post("ptyCreate", "/api/pty", { - payload: Pty.CreateInput, + HttpApiEndpoint.post("persistentPtyCreate", "/api/experimental/session/:sessionID/terminal", { + params: SessionParams, + payload: PersistentPty.CreateInput, success: Json, - error: MockNotFound.pipe(HttpApiSchema.status(404)), + error: PersistentPtyUnavailable, }), ) .add( - HttpApiEndpoint.get("ptyGet", "/api/pty/:ptyID", { + HttpApiEndpoint.get("persistentPtyGet", "/api/experimental/persistent-pty/:ptyID", { params: PtyParams, success: Json, - error: PtyMissing, + error: PersistentPtyMissing, }), ) .add( - HttpApiEndpoint.put("ptyUpdate", "/api/pty/:ptyID", { + HttpApiEndpoint.put("persistentPtyUpdate", "/api/experimental/persistent-pty/:ptyID", { params: PtyParams, - payload: Pty.UpdateInput, + payload: PersistentPtyUpdate, success: Json, - error: PtyMissing, + error: [...PersistentPtyMissing, MockPtyFeatureUnsupported.pipe(HttpApiSchema.status(501))], }), ) .add( - HttpApiEndpoint.delete("ptyRemove", "/api/pty/:ptyID", { + HttpApiEndpoint.delete("persistentPtyRemove", "/api/experimental/persistent-pty/:ptyID", { params: PtyParams, success: NoContent, - error: PtyMissing, + error: PersistentPtyMissing, + }), + ) + .add( + HttpApiEndpoint.get("persistentPtySnapshot", "/api/experimental/persistent-pty/:ptyID/snapshot", { + params: PtyParams, + success: Json, + error: PersistentPtyMissing, }), ) .add( - HttpApiEndpoint.post("ptyConnectToken", "/api/pty/:ptyID/connect-token", { + HttpApiEndpoint.post("persistentPtyConnectToken", "/api/experimental/persistent-pty/:ptyID/connect-token", { params: PtyParams, success: Json, - error: PtyMissing, + error: PersistentPtyMissing, }), ) .add( @@ -321,6 +352,18 @@ const Group = HttpApiGroup.make("mock") success: NoContent, }), ) + .add( + HttpApiEndpoint.post("sessionView", "/api/session/:sessionID/view", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionView", "/api/session/:sessionID/view", { + params: SessionParams, + success: NoContent, + }), + ) .add( HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", { params: SessionParams, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 0ccbbcdf5c00..c769bf0da132 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -12,11 +12,14 @@ import { MockBadRequest, MockInternal, MockNotFound, + MockPtyFeatureUnsupported, MockPtyNotFound, + MockServiceUnavailable, MockShellNotFound, MockUnauthorized, MockUnsupported, } from "./mock-api" +import { createPersistentPty, type MockPersistentPtyConfig, type MockServerEvent } from "./persistent-pty" import { installSseTransport } from "./sse-transport" type Resolvable = T | (() => T) @@ -144,9 +147,9 @@ export interface MockServerConfig { generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }> onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void onBackground?: (input: { sessionID: string }) => void - // Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}` (prefix must start with "pty"). - // `directory` is the owning workspace (Location); `cwd` is the reported working directory (default: `directory`). - pty?: { prefix?: string; initial?: { id: string; title: string; directory?: string; cwd?: string }[] } + // Serves the session-scoped persistent PTY routes and their connect WebSockets (`e2e/utils/persistent-pty.ts`). + // Without it, the server has a working daemon and no terminals; `capability: false` makes the routes answer 503. + persistentPty?: MockPersistentPtyConfig // Answers 500 InvalidDirectory when a request names a directory this server does not own. strictDirectory?: boolean // Answers 401 UnauthorizedError unless a request carries this password; a function may change it mid-test. @@ -155,38 +158,6 @@ export interface MockServerConfig { pairing?: { code: string; token: string } } -export type MockPtyInfo = { - id: string - title: string - command: string - args: string[] - cwd: string - status: "running" | "exited" - pid: number -} - -export type MockPtySocket = { - id: string - url: URL - input: string[] - closed: boolean - send(data: string): void - close(code: number, reason: string): Promise -} - -export type MockPty = { - list: MockPtyInfo[] - created: MockPtyInfo[] - removed: string[] - updates: { id: string; body: unknown }[] - tokens: { id: string; headers: Record; ticket: string }[] - sockets: MockPtySocket[] - // WebSockets closed with 1008 because the PTY, its workspace, or an unused issued ticket did not match. - rejected: { id: string; url: URL; reason: string }[] - // Writes output to the newest open socket, optionally for one PTY. - send(data: string, id?: string): void -} - // The page relays events as opaque values; benchmarks also push their own payloads. type MockStream = { push: (payloads: readonly unknown[]) => void } @@ -305,7 +276,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { // Delivers events on this server's mock stream; buffered until the app connects. // An origin served by an SSE transport receives the events as one burst on its active connection. - const push = (payloads: readonly OpenCodeEvent[]) => + const push = (payloads: readonly MockServerEvent[]) => page.evaluate( ({ server, payloads }) => { // SAFETY: only the init script, `installSseTransport`, and benchmarks write these globals, in the declared shapes. @@ -322,7 +293,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { ) // Server-side events the mock publishes itself; delivery failures other than a missing document fail the test. - const emit = (events: OpenCodeEvent[]) => + const emit = (events: MockServerEvent[]) => void push(events).catch((cause: unknown) => { if (page.isClosed() || retryableDelivery(cause)) return throw cause @@ -449,40 +420,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } - if (config.pty) { - const host = new URL(server).host - await page.routeWebSocket( - (url) => url.host === host && /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname), - (ws) => { - const url = new URL(ws.url()) - const id = decodeURIComponent(url.pathname.split("/")[3]!) - const reason = transport.pty.admit(id, url) - - if (reason) { - transport.pty.rejected.push({ id, url, reason }) - - return ws.close({ code: 1008, reason }) - } - - const socket: MockPtySocket = { - id, - url, - input: [], - closed: false, - send: (data) => ws.send(data), - close: (code, reason) => ws.close({ code, reason }), - } - - ws.onMessage((message) => socket.input.push(message.toString())) - ws.onClose(() => { - socket.closed = true - }) - transport.pty.sockets.push(socket) - }, - ) - } + const host = new URL(server).host + await page.routeWebSocket( + (url) => url.host === host && /^\/api\/experimental\/persistent-pty\/[^/]+\/connect$/.test(url.pathname), + (ws) => transport.persistentPty.connect(ws), + ) - return { server, pty: transport.pty, push } + return { server, persistentPty: transport.persistentPty, push } } // Answers every Location-scoped request for `directory` as the server does once that folder is deleted. @@ -535,15 +479,18 @@ export async function mockServers(page: Page, servers: Record void = () => {}) { - const pty = createPty(config) +export function createMockServerHandler( + config: MockServerConfig, + emit: (events: MockServerEvent[]) => void = () => {}, +) { + const persistentPty = createPersistentPty({ config: config.persistentPty, directory: config.directory }, emit) const web = HttpRouter.toWebHandler( HttpApiBuilder.layer(MockApi).pipe( @@ -552,7 +499,7 @@ export function createMockServerHandler(config: MockServerConfig, emit: (events: cursors: new Map(), nextCursor: 0, sessionCreates: 0, - pty, + persistentPty, emit, mcp: new Map(), attempts: new Map(), @@ -563,7 +510,7 @@ export function createMockServerHandler(config: MockServerConfig, emit: (events: { disableLogger: true }, ) - return { ...web, pty } + return { ...web, persistentPty } } const corsHeaders = { @@ -608,91 +555,6 @@ function retryableDelivery(cause: unknown) { ].some((text) => message.includes(text)) } -const PTY_TICKET = "e2e-ticket" - -function createPty(config: MockServerConfig) { - const info = (id: string, title: string, cwd = config.directory): MockPtyInfo => ({ - id, - title, - command: "cmd.exe", - args: [], - cwd, - status: "running", - pid: 1, - }) - - // Core scopes PTYs by Location, which can differ from the process cwd, so ownership is kept apart from `cwd`. - const owners = new Map((config.pty?.initial ?? []).map((item) => [item.id, item.directory ?? config.directory])) - // Issued, not yet used connect tickets, each bound to one PTY and workspace. - const tickets: { id: string; directory: string; ticket: string }[] = [] - const issued = { count: 0 } - const owns = (item: MockPtyInfo, directory: string) => owners.get(item.id) === directory - - const pty: MockPty = { - list: (config.pty?.initial ?? []).map((item) => info(item.id, item.title, item.cwd ?? item.directory)), - created: [], - removed: [], - updates: [], - tokens: [], - sockets: [], - rejected: [], - send(data, id) { - const socket = pty.sockets.findLast((item) => !item.closed && (id === undefined || item.id === id)) - - if (!socket) throw new Error(`No open PTY socket${id ? ` for ${id}` : ""}`) - socket.send(data) - }, - } - - return Object.assign(pty, { - info, - // The first `${prefix}` no initial, created, or removed PTY has used. - allocate() { - const prefix = config.pty?.prefix ?? "pty_" - const used = new Set([...pty.list, ...pty.created].map((item) => item.id).concat(pty.removed)) - - const number = Array.from({ length: used.size + 1 }, (_, index) => index + 1).find( - (value) => !used.has(`${prefix}${value}`), - )! - - return { id: `${prefix}${number}`, number } - }, - add(created: MockPtyInfo, directory: string) { - owners.set(created.id, directory) - pty.created.push(created) - pty.list.push(created) - }, - find: (id: string, directory: string) => pty.list.find((item) => item.id === id && owns(item, directory)), - owned: (directory: string) => pty.list.filter((item) => owns(item, directory)), - // Unique per server; the first stays `e2e-ticket` so single-terminal specs can assert a fixed value. - issue(id: string, directory: string) { - issued.count += 1 - const ticket = issued.count === 1 ? PTY_TICKET : `${PTY_TICKET}-${issued.count}` - tickets.push({ id, directory, ticket }) - - return ticket - }, - // Returns why a connect URL is refused: the PTY must exist, belong to the requested workspace, and carry the exact - // unused ticket issued for that PTY and workspace. Admission consumes the ticket. - admit(id: string, url: URL) { - const found = pty.list.find((item) => item.id === id) - - if (!found) return "PTY not found" - const directory = url.searchParams.get("location[directory]") || config.directory - - if (!owns(found, directory)) return "PTY belongs to another workspace" - const ticket = url.searchParams.get("ticket") - - const index = tickets.findIndex( - (item) => item.id === id && item.directory === directory && item.ticket === ticket, - ) - - if (index < 0) return "No unused ticket was issued for this PTY" - tickets.splice(index, 1) - }, - }) -} - // Every directory this server's configuration names: its own, project and inventory worktrees, and session locations. function ownedDirectories(config: MockServerConfig) { const projects = [config.project, ...(config.projects ? resolve(config.projects) : [])].flatMap((item) => @@ -735,8 +597,8 @@ function mockHandlers( cursors: Map nextCursor: number sessionCreates: number - pty: ReturnType - emit: (events: OpenCodeEvent[]) => void + persistentPty: ReturnType + emit: (events: MockServerEvent[]) => void // MCP status overrides by `\n`, and OAuth attempt creation times by attempt ID. mcp: Map attempts: Map @@ -746,21 +608,23 @@ function mockHandlers( const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay)) const configEntries = config.configEntries ?? [] - const ptyEnabled = Effect.suspend(() => - config.pty ? Effect.void : Effect.fail(new MockNotFound({ message: "PTY is not enabled for this mock server" })), + // Without a persistent PTY config the mock is a server with a working daemon and no terminals, since the desktop + // lists a session's terminals whenever its side panel renders. + const persistentPtyEnabled = Effect.suspend(() => + state.persistentPty.capability !== false + ? Effect.void + : Effect.fail(new MockServiceUnavailable({ message: "opencode-pty is not available", service: "opencode-pty" })), ) - // PTYs are scoped to the workspace (`location[directory]`) they were created in, like the real server. - const findPty = (id: string, request: { url: string }) => - ptyEnabled.pipe( + const findPersistentPty = (id: string) => + persistentPtyEnabled.pipe( Effect.andThen(() => Effect.suspend(() => { - const directory = requestDirectory(config, request) - const found = state.pty.find(id, directory) + const found = state.persistentPty.find(id) return found ? Effect.succeed(found) - : Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY not found: ${id}` })) + : Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY session not found: ${id}` })) }), ), ) @@ -880,6 +744,10 @@ function mockHandlers( hostname: config.hostname, urls: config.server ? [config.server] : [], paths: { tmp: "/tmp/opencode" }, + capabilities: + state.persistentPty.capability === undefined + ? undefined + : { persistentPty: state.persistentPty.capability }, }), config: () => Effect.succeed(configEntries), reference: () => Effect.succeed({ location: location(config), data: [] }), @@ -1179,67 +1047,56 @@ function mockHandlers( }), // Like the server, which also answers 204 for a shell that already ended. shellRemove: () => noContent, - ptyList: (ctx) => - ptyEnabled.pipe( + persistentPtyList: (ctx) => + persistentPtyEnabled.pipe(Effect.map(() => ({ data: state.persistentPty.list(ctx.params.sessionID) }))), + persistentPtyCreate: (ctx) => + persistentPtyEnabled.pipe( Effect.map(() => { - const directory = requestDirectory(config, ctx.request) + const session = config.sessions.find((item) => item.id === ctx.params.sessionID) + const directory = session ? currentSession(session, config.directory).location.directory : undefined - return { location: location(config, directory), data: state.pty.owned(directory) } - }), - ), - ptyCreate: (ctx) => - ptyEnabled.pipe( - Effect.map(() => { - const next = state.pty.allocate() - const directory = requestDirectory(config, ctx.request) - - const created = state.pty.info( - next.id, - ctx.payload.title ?? `Terminal ${next.number}`, - ctx.payload.cwd ?? directory, - ) - - state.pty.add(created, directory) - - return { location: location(config, directory), data: created } + return { + data: state.persistentPty.create(ctx.params.sessionID, { + ...ctx.payload, + cwd: ctx.payload.cwd ?? directory, + }), + } }), ), - ptyGet: (ctx) => - findPty(ctx.params.ptyID, ctx.request).pipe( - Effect.map((data) => ({ location: location(config, requestDirectory(config, ctx.request)), data })), - ), - ptyUpdate: (ctx) => - findPty(ctx.params.ptyID, ctx.request).pipe( - Effect.map((found) => { - state.pty.updates.push({ id: found.id, body: ctx.payload }) - - if (ctx.payload.title) found.title = ctx.payload.title - - return { location: location(config, requestDirectory(config, ctx.request)), data: found } - }), + persistentPtyGet: (ctx) => findPersistentPty(ctx.params.ptyID).pipe(Effect.map((data) => ({ data }))), + persistentPtyUpdate: (ctx) => + findPersistentPty(ctx.params.ptyID).pipe( + Effect.flatMap((found) => + ctx.payload.title !== undefined && !state.persistentPty.renameSupported + ? Effect.fail( + new MockPtyFeatureUnsupported({ + feature: "rename", + message: "Terminal renaming will be available after OpenCode's terminal service restarts", + }), + ) + : Effect.sync(() => ({ data: state.persistentPty.update(found.id, ctx.payload) })), + ), ), - ptyRemove: (ctx) => - findPty(ctx.params.ptyID, ctx.request).pipe( + persistentPtyRemove: (ctx) => + findPersistentPty(ctx.params.ptyID).pipe( Effect.map((found) => { - state.pty.removed.push(found.id) - state.pty.list.splice(state.pty.list.indexOf(found), 1) + state.persistentPty.remove(found.id) return HttpApiSchema.NoContent.make() }), ), - ptyConnectToken: (ctx) => - findPty(ctx.params.ptyID, ctx.request).pipe( - Effect.map((found) => { - const directory = requestDirectory(config, ctx.request) - const ticket = state.pty.issue(found.id, directory) - state.pty.tokens.push({ - id: found.id, - headers: Object.fromEntries(Object.entries(ctx.request.headers)), - ticket, - }) - - return { location: location(config, directory), data: { ticket, expires_in: 60 } } - }), + persistentPtySnapshot: (ctx) => + findPersistentPty(ctx.params.ptyID).pipe( + Effect.map((found) => ({ data: state.persistentPty.snapshot(found.id) })), + ), + persistentPtyConnectToken: (ctx) => + findPersistentPty(ctx.params.ptyID).pipe( + Effect.map((found) => ({ + data: { + ticket: state.persistentPty.issue(found.id, Object.fromEntries(Object.entries(ctx.request.headers))), + expires_in: 60, + }, + })), ), sessionList: (ctx) => { const sessions = config.sessions @@ -1315,6 +1172,7 @@ function mockHandlers( }), sessionRemove: () => noContent, sessionShell: () => noContent, + sessionView: () => noContent, sessionForm: (ctx) => Effect.succeed({ data: resolve(config.forms ?? []).filter( diff --git a/packages/app/e2e/utils/notifications.ts b/packages/app/e2e/utils/notifications.ts new file mode 100644 index 000000000000..6b6f3cd51a4d --- /dev/null +++ b/packages/app/e2e/utils/notifications.ts @@ -0,0 +1,51 @@ +import type { Page } from "@playwright/test" + +export async function recordNotifications(page: Page) { + const notifications: { title: string; body?: string }[] = [] + + const sounds: string[] = [] + + await page.exposeBinding("recordNotification", (_source, notification: { title: string; body?: string }) => { + notifications.push(notification) + }) + await page.exposeBinding("recordNotificationSound", (_source, src: string) => { + sounds.push(src) + }) + await page.addInitScript(() => { + // SAFETY: exposeBinding above installs these callbacks before the app starts. + const host: Window & { + recordNotification?: (notification: { title: string; body?: string }) => Promise + recordNotificationSound?: (src: string) => Promise + } = window + + document.hasFocus = () => false + Object.defineProperty(window, "Notification", { + value: class extends EventTarget { + static permission = "granted" + onclick: (() => void) | null = null + + constructor(title: string, options?: NotificationOptions) { + super() + void host.recordNotification!({ title, body: options?.body }) + } + + close() {} + }, + }) + Object.defineProperty(window, "Audio", { + value: class { + currentTime = 0 + + constructor(readonly src: string) {} + + play() { + return host.recordNotificationSound!(this.src) + } + + pause() {} + }, + }) + }) + + return { notifications, sounds } +} diff --git a/packages/app/e2e/utils/persistent-pty.ts b/packages/app/e2e/utils/persistent-pty.ts new file mode 100644 index 000000000000..4f1355d4c1e6 --- /dev/null +++ b/packages/app/e2e/utils/persistent-pty.ts @@ -0,0 +1,542 @@ +import type { WebSocketRoute } from "@playwright/test" +import type { OpenCodeEvent } from "@opencode/client/promise" +import type { PersistentPty } from "@opencode/schema/persistent-pty" +import { Predicate } from "effect" + +// Serves the session-scoped persistent PTY routes (`/api/experimental/.../terminal`, `/api/experimental/persistent-pty`) +// and their connect WebSockets, like a server backed by the opencode-pty daemon. +export type MockPersistentPtyConfig = { + // `server.info().capabilities.persistentPty`; omitted by default, like a server that predates the field. `false` + // also makes the routes answer 503 like a server without the daemon. + capability?: boolean + // Whether PUT renames (the daemon lists the `rename` feature). When false, a title update answers 501 + // PtyFeatureUnsupportedError. Default true. + renameSupported?: boolean + // Terminals that exist before the page loads; they publish no events. + initial?: MockPersistentPtySeed[] +} + +export type MockPersistentPtySeed = { + // Default: the next `pty_persistent_`. + id?: string + sessionID: string + // The create title (default "Terminal"). + title?: string + userTitle?: string | null + command?: string + args?: string[] + // Default: the server directory. + cwd?: string + size?: { cols: number; rows: number } + // Output the terminal already wrote; snapshots replay it as their checkpoint. + output?: string + foregroundProcess?: string | null +} + +// `PersistentPty.Info`, with the `userTitle` that terminal renaming adds. +export type MockPersistentPtyInfo = { + id: string + title: string + userTitle: string | null + command: string + args: string[] + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number + sessionID: string + foregroundProcess: string | null + size: { cols: number; rows: number } + output: { head: number; tail: number } +} + +// `PersistentPty.UpdateInput` once renaming lands: `size` optional, `title: null` clears the user title. +export type MockPersistentPtyUpdate = { + attachmentID?: string + size?: { cols: number; rows: number } + title?: string | null +} + +// Published on rename; not yet part of `OpenCodeEvent`. +export type MockPersistentPtyUpdated = { + id: string + created: number + type: "persistent-pty.updated" + data: { sessionID: string; terminal: MockPersistentPtyInfo } +} + +export type MockServerEvent = OpenCodeEvent | MockPersistentPtyUpdated + +// The JSON text frames the server sends; output travels as binary frames. +export type MockPersistentPtyMessage = + | { + type: "attached" + attachmentID: string + inputProtocol: 0 | 1 + info: MockPersistentPtyInfo + role: "controller" | "observer" + generation: number + replay: { requestedOffset: number; availableOffset: number; endOffset: number; truncated: boolean } + } + | { type: "replay_complete"; endOffset: number } + | { type: "resized"; cols: number; rows: number; generation: number; checkpoint: string } + | { type: "controller_changed"; attachmentID?: string; generation: number } + | { type: "exited"; exitCode?: number; finalOffset: number } + | { type: "title_changed"; title: string } + +// One decoded `input_protocol=1` frame: type 0 takes control at a size, type 1 also writes `data`. +export type MockPersistentPtyFrame = { type: 0 | 1; cols: number; rows: number; data: string } + +export type MockPersistentPtySocket = { + id: string + attachmentID: string + url: URL + cursor: number + takeover: boolean + role: "controller" | "observer" + inputProtocol: 0 | 1 + frames: MockPersistentPtyFrame[] + // Text written to the terminal (type 1 frame data, or raw messages without the input protocol). + input: string[] + // JSON control messages sent to the client, in order. + messages: MockPersistentPtyMessage[] + closed: boolean + close(code?: number, reason?: string): Promise +} + +export type MockPersistentPty = { + // Read by `/api/info`; tests may change both mid-test. + capability: boolean | undefined + renameSupported: boolean + // Live terminals in creation order. + terminals: MockPersistentPtyInfo[] + created: MockPersistentPtyInfo[] + removed: string[] + updates: { id: string; body: MockPersistentPtyUpdate }[] + tokens: { id: string; headers: Record; ticket: string }[] + sockets: MockPersistentPtySocket[] + // Connects refused because the terminal, or an unused ticket issued for it, was missing. + rejected: { id: string; url: URL; reason: string }[] + // A terminal created by another client: it appears through `persistent-pty.added`. + add(seed: MockPersistentPtySeed): MockPersistentPtyInfo + // Shell output: advances `output.tail` and streams to every open socket of the terminal. + output(id: string, data: string | Uint8Array): void + // The shell exits: `exited` on every socket, which then closes, then the server removes the terminal. + exit(id: string, exitCode?: number | null): void + // Another client closed the terminal: its sockets close and `persistent-pty.removed` is published. + remove(id: string): void + // Another attachment (default "mock-other-client"), or none, takes control: `controller_changed` on every socket. + takeControl(id: string, attachmentID?: string | null): void + // The canonical size changes: `resized` with a checkpoint on every socket. + resize(id: string, size: { cols: number; rows: number }): void + // The shell sets its OSC title: `title_changed` when the effective title changes. + setOscTitle(id: string, title: string): void + // Another client renames the terminal (`null` clears): `title_changed` and `persistent-pty.updated`. + rename(id: string, title: string | null): void + // The current controller's attachment ID. + controller(id: string): string | undefined + // Closes the terminal's open sockets, as a dropped connection does. + close(id: string, code?: number, reason?: string): Promise + // The server restarts and adopts the daemon: terminals and output stay, open sockets close (1012) and unused tickets + // expire. The event stream is not touched; use `installSseTransport(...).disconnect()` to drop it as well. + restart(): Promise +} + +type Terminal = { + info: MockPersistentPtyInfo + createTitle: string + oscTitle: string + bytes: Uint8Array + controller?: string + generation: number +} + +type Connection = MockPersistentPtySocket & { ws: WebSocketRoute } + +const DEFAULT_SIZE = { cols: 80, rows: 24 } + +const OTHER_CLIENT = "mock-other-client" + +export function createPersistentPty( + input: { config?: MockPersistentPtyConfig; directory: string }, + emit: (events: MockServerEvent[]) => void, +) { + const terminals = new Map() + const tickets: { id: string; ticket: string }[] = [] + const counters = { id: 0, ticket: 0, event: 0 } + const connections: Connection[] = [] + const encoder = new TextEncoder() + + const event = (type: T) => { + counters.event += 1 + + return { id: `evt_mock_persistent_pty_${counters.event}`, created: Date.now(), type } + } + + const open = (id: string) => connections.filter((item) => item.id === id && !item.closed) + + const send = (connection: Connection, message: MockPersistentPtyMessage | Uint8Array) => { + if (connection.closed) return + + if (message instanceof Uint8Array) return connection.ws.send(Buffer.from(message)) + connection.messages.push(message) + connection.ws.send(JSON.stringify(message)) + } + + const broadcast = (id: string, message: MockPersistentPtyMessage | Uint8Array) => + open(id).forEach((item) => send(item, message)) + + // Server-side closes release control here instead of relying on Playwright's `onClose`. + const close = (items: Connection[], code: number, reason: string) => { + const closing = items.filter((item) => !item.closed) + closing.forEach((item) => (item.closed = true)) + closing.forEach(release) + + return Promise.all(closing.map((item) => item.ws.close({ code, reason }))).then(() => undefined) + } + + // Like the daemon, control passes to the most recent remaining attachment. A late close of a replaced socket that + // shares the controller's attachment ID is an observer by then, so it leaves the new controller alone. + const release = (connection: Connection) => { + const terminal = terminals.get(connection.id) + + if (!terminal || connection.role !== "controller" || terminal.controller !== connection.attachmentID) return + setController(terminal, open(connection.id).at(-1)?.attachmentID) + } + + const get = (id: string) => { + const found = terminals.get(id) + + if (!found) throw new Error(`No persistent PTY ${id}`) + + return found + } + + const effectiveTitle = (terminal: Terminal) => terminal.info.userTitle || terminal.oscTitle || terminal.createTitle + + const retitle = (terminal: Terminal) => { + const title = effectiveTitle(terminal) + + if (title === terminal.info.title) return + terminal.info.title = title + broadcast(terminal.info.id, { type: "title_changed", title }) + } + + const checkpoint = (terminal: Terminal) => Buffer.from(terminal.bytes).toString("base64") + + const setController = (terminal: Terminal, attachmentID: string | undefined) => { + terminal.generation += 1 + terminal.controller = attachmentID + connections + .filter((item) => item.id === terminal.info.id) + .forEach((item) => (item.role = item.attachmentID === attachmentID ? "controller" : "observer")) + broadcast(terminal.info.id, { + type: "controller_changed", + attachmentID, + generation: terminal.generation, + }) + } + + const resize = (terminal: Terminal, size: { cols: number; rows: number }) => { + if (terminal.info.size.cols === size.cols && terminal.info.size.rows === size.rows) return + terminal.info.size = { cols: size.cols, rows: size.rows } + broadcast(terminal.info.id, { + type: "resized", + cols: size.cols, + rows: size.rows, + generation: terminal.generation, + checkpoint: checkpoint(terminal), + }) + } + + // Skips IDs that a seed claims or that a terminal used, live or removed. + const allocate = (): string => { + counters.id += 1 + const id = `pty_persistent_${counters.id}` + + if (terminals.has(id) || pty.removed.includes(id) || input.config?.initial?.some((seed) => seed.id === id)) { + return allocate() + } + + return id + } + + const build = (seed: MockPersistentPtySeed) => { + const id = seed.id ?? allocate() + + if (!id.startsWith("pty")) throw new Error(`Persistent PTY IDs must start with "pty": ${id}`) + const bytes = encoder.encode(seed.output ?? "") + const createTitle = seed.title ?? "Terminal" + + const terminal: Terminal = { + createTitle, + oscTitle: "", + bytes, + generation: 0, + info: { + id, + title: seed.userTitle || createTitle, + userTitle: seed.userTitle ?? null, + command: seed.command ?? "cmd.exe", + args: seed.args ?? [], + cwd: seed.cwd ?? input.directory, + status: "running", + pid: 1, + sessionID: seed.sessionID, + foregroundProcess: seed.foregroundProcess ?? null, + size: seed.size ?? DEFAULT_SIZE, + output: { head: 0, tail: bytes.length }, + }, + } + + terminals.set(id, terminal) + + return terminal.info + } + + const drop = (terminal: Terminal) => { + terminals.delete(terminal.info.id) + pty.terminals.splice(pty.terminals.indexOf(terminal.info), 1) + pty.removed.push(terminal.info.id) + tickets.splice(0, tickets.length, ...tickets.filter((item) => item.id !== terminal.info.id)) + void close(open(terminal.info.id), 1000, "") + emit([ + { ...event("persistent-pty.removed"), data: { sessionID: terminal.info.sessionID, ptyID: terminal.info.id } }, + ]) + } + + const added = (info: MockPersistentPtyInfo) => { + pty.terminals.push(info) + pty.created.push(info) + emit([{ ...event("persistent-pty.added"), data: { sessionID: info.sessionID, terminal: info } }]) + + return info + } + + const rename = (terminal: Terminal, title: string | null) => { + terminal.info.userTitle = title || null + retitle(terminal) + emit([ + { ...event("persistent-pty.updated"), data: { sessionID: terminal.info.sessionID, terminal: terminal.info } }, + ]) + } + + // Type 0 and type 1 frames both make the sender controller at the frame's size, as the daemon does. + const interact = (connection: Connection, size: { cols: number; rows: number }, data?: Uint8Array) => { + const terminal = terminals.get(connection.id) + + if (!terminal || connection.closed) return + + if (terminal.controller !== connection.attachmentID) setController(terminal, connection.attachmentID) + resize(terminal, size) + + if (data) connection.input.push(new TextDecoder().decode(data)) + } + + const pty: MockPersistentPty = { + capability: input.config?.capability, + renameSupported: input.config?.renameSupported ?? true, + terminals: [], + created: [], + removed: [], + updates: [], + tokens: [], + sockets: connections, + rejected: [], + add: (seed) => added(build(seed)), + output(id, data) { + const terminal = get(id) + const bytes = Predicate.isString(data) ? encoder.encode(data) : data + const next = new Uint8Array(terminal.bytes.length + bytes.length) + next.set(terminal.bytes) + next.set(bytes, terminal.bytes.length) + terminal.bytes = next + terminal.info.output = { head: terminal.info.output.head, tail: next.length } + + if (bytes.length > 0) broadcast(id, bytes) + }, + exit(id, exitCode) { + const terminal = get(id) + terminal.info.status = "exited" + terminal.info.exitCode = exitCode ?? undefined + broadcast(id, { + type: "exited", + exitCode: exitCode ?? undefined, + finalOffset: terminal.info.output.tail, + }) + drop(terminal) + }, + remove: (id) => drop(get(id)), + takeControl: (id, attachmentID = OTHER_CLIENT) => setController(get(id), attachmentID ?? undefined), + resize: (id, size) => resize(get(id), size), + setOscTitle(id, title) { + const terminal = get(id) + terminal.oscTitle = title + retitle(terminal) + }, + rename: (id, title) => rename(get(id), title), + controller: (id) => get(id).controller, + close: (id, code = 1011, reason = "mock socket closed") => close(open(id), code, reason), + restart() { + tickets.splice(0) + terminals.forEach((terminal) => (terminal.controller = undefined)) + + return close(connections, 1012, "server restarted") + }, + } + + pty.terminals.push(...(input.config?.initial ?? []).map(build)) + + return Object.assign(pty, { + find: (id: string) => terminals.get(id)?.info, + list: (sessionID: string) => pty.terminals.filter((item) => item.sessionID === sessionID), + create(sessionID: string, body: PersistentPty.CreateInput) { + return added( + build({ + sessionID, + title: body.title, + cwd: body.cwd, + command: body.command, + args: [...body.args], + size: body.size, + }), + ) + }, + // Resizes when `size` is present and renames when `title` is present, like the server's update handler. + update(id: string, body: MockPersistentPtyUpdate) { + const terminal = get(id) + pty.updates.push({ id, body }) + + if (body.size) resize(terminal, body.size) + + if (body.title !== undefined) rename(terminal, body.title) + + return terminal.info + }, + snapshot(id: string) { + const terminal = get(id) + + return { + info: terminal.info, + text: new TextDecoder().decode(terminal.bytes), + checkpoint: checkpoint(terminal), + cursor: { x: 0, y: 0 }, + } + }, + issue(id: string, headers: Record) { + counters.ticket += 1 + const ticket = `e2e-persistent-ticket-${counters.ticket}` + tickets.push({ id, ticket }) + pty.tokens.push({ id, headers, ticket }) + + return ticket + }, + // Follows the server's connect handler: `attached`, the replay from `cursor` as one binary frame, then + // `replay_complete`; a controller attachment then broadcasts `controller_changed`. + connect(ws: WebSocketRoute) { + const url = new URL(ws.url()) + const id = decodeURIComponent(url.pathname.split("/")[4] ?? "") + const terminal = terminals.get(id) + const ticket = url.searchParams.get("ticket") + const index = tickets.findIndex((item) => item.id === id && item.ticket === ticket) + const cursor = Number(url.searchParams.get("cursor") ?? "0") + + const reason = + !terminal || pty.capability === false + ? "terminal unavailable" + : index < 0 + ? "No unused ticket was issued for this terminal" + : !Number.isSafeInteger(cursor) || cursor < 0 + ? "Invalid cursor" + : undefined + + if (!terminal || reason) { + pty.rejected.push({ id, url, reason: reason ?? "terminal unavailable" }) + + return ws.close({ + code: reason === "terminal unavailable" ? 4404 : 1008, + reason: reason ?? "terminal unavailable", + }) + } + + tickets.splice(index, 1) + const role = url.searchParams.get("role") === "observer" ? "observer" : "controller" + const takeover = url.searchParams.get("takeover") === "true" + const attachmentID = url.searchParams.get("attachment_id") ?? crypto.randomUUID() + + if (role === "controller" && terminal.controller && terminal.controller !== attachmentID && !takeover) { + pty.rejected.push({ id, url, reason: "terminal already has a controller" }) + + return ws.close({ code: 4404, reason: "terminal unavailable" }) + } + + const connection: Connection = { + ws, + id, + attachmentID, + url, + cursor, + takeover, + role, + inputProtocol: url.searchParams.get("input_protocol") === "1" ? 1 : 0, + frames: [], + input: [], + messages: [], + closed: false, + close: (code = 1011, reason = "mock socket closed") => close([connection], code, reason), + } + + ws.onMessage((message) => { + const data = Predicate.isString(message) ? Buffer.from(message) : message + + if (connection.inputProtocol === 0) return interact(connection, terminal.info.size, data) + + if (data.byteLength < 5) return + const type = data[0] + const cols = data.readUInt16BE(1) + const rows = data.readUInt16BE(3) + + if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return + const payload = data.subarray(5) + connection.frames.push({ type, cols, rows, data: type === 1 ? new TextDecoder().decode(payload) : "" }) + interact(connection, { cols, rows }, type === 1 ? payload : undefined) + }) + ws.onClose(() => { + if (connection.closed) return + connection.closed = true + release(connection) + }) + connections.push(connection) + + if (role === "controller" && terminal.controller !== attachmentID) { + terminal.generation += 1 + terminal.controller = attachmentID + } + + connection.role = terminal.controller === attachmentID ? "controller" : "observer" + const start = Math.max(terminal.info.output.head, Math.min(cursor, terminal.bytes.length)) + + send(connection, { + type: "attached", + attachmentID, + inputProtocol: connection.inputProtocol, + info: terminal.info, + role: connection.role, + generation: terminal.generation, + replay: { + requestedOffset: cursor, + availableOffset: start, + endOffset: terminal.bytes.length, + truncated: false, + }, + }) + + if (start < terminal.bytes.length) send(connection, terminal.bytes.subarray(start)) + send(connection, { type: "replay_complete", endOffset: terminal.bytes.length }) + + if (role !== "controller") return + connections.filter((item) => item.id === id && item !== connection).forEach((item) => (item.role = "observer")) + broadcast(id, { type: "controller_changed", attachmentID, generation: terminal.generation }) + }, + }) +} diff --git a/packages/app/e2e/utils/terminal.ts b/packages/app/e2e/utils/terminal.ts new file mode 100644 index 000000000000..ff1b699e8fa6 --- /dev/null +++ b/packages/app/e2e/utils/terminal.ts @@ -0,0 +1,31 @@ +import type { Locator, Page } from "@playwright/test" + +/** The screen of one terminal in the terminal panel; cached terminals of other tabs stay mounted but hidden. */ +export function terminalScreen(page: Page, ptyID: string) { + return page.locator(`#terminal-wrapper-${ptyID} [data-component="terminal"]`) +} + +/** + * The terminal's text as the user can copy it: drags across its canvas and copies with Ctrl+Shift+C. Needs the + * clipboard permissions. The drag is a pointerdown, so the terminal also takes control, as a click would. + */ +export async function terminalText(page: Page, screen: Locator) { + const box = await screen.locator("canvas").boundingBox() + + if (!box) throw new Error("The terminal has no canvas") + + // An empty selection copies nothing, so an earlier copy must not pass for the screen. + await page.evaluate(() => navigator.clipboard.writeText("")) + await page.mouse.move(box.x + 1, box.y + 1) + await page.mouse.down() + await page.mouse.move(box.x + box.width - 1, box.y + box.height - 1, { steps: 4 }) + await page.mouse.up() + await page.keyboard.press("Control+Shift+C") + const text = await page.evaluate(() => navigator.clipboard.readText()) + + return text + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() +} diff --git a/packages/app/e2e/utils/timeline.ts b/packages/app/e2e/utils/timeline.ts index f001cbb0d989..5a8ae0fd5b89 100644 --- a/packages/app/e2e/utils/timeline.ts +++ b/packages/app/e2e/utils/timeline.ts @@ -125,7 +125,6 @@ type TimelineServerInput = Partial< | "fileList" | "fileContent" | "findFiles" - | "pty" | "keepalive" | "onPermissionReply" | "shellCommands" @@ -322,7 +321,7 @@ export async function setupTimeline( return { transport, - pty: mock.pty, + persistentPty: mock.persistentPty, send, async sendAll(sequence: { event: TimelineEvent; delay: number }[]) { for (const item of sequence) await send(item.event, item.delay) diff --git a/packages/app/e2e/utils/workspace.ts b/packages/app/e2e/utils/workspace.ts index 7a0035674817..20109c6a216f 100644 --- a/packages/app/e2e/utils/workspace.ts +++ b/packages/app/e2e/utils/workspace.ts @@ -53,7 +53,7 @@ export async function mockWorkspace(page: Page, input: WorkspaceInput) { ...input.seed, }) - return { server: SERVER, directory, projectID, sessions, pty: mock.pty, push: mock.push } + return { server: SERVER, directory, projectID, sessions, persistentPty: mock.persistentPty, push: mock.push } } // Opens a session (default: the first) and waits until its composer accepts input. @@ -87,11 +87,16 @@ export async function openDraft(page: Page, input: WorkspaceInput & { draftID?: // Opens a draft on the local project with "New worktree" selected. Worktree creation stays pending until the test // resolves `worktree`; POST order (`worktree`, `session`, `prompt`), worktree requests, session creates and prompts are // recorded. The draft tab comes first, then a tab per session. -// The new-session trigger that shows the draft's project and worktree and opens the location dialog. +// The new-session trigger that shows the draft's project and opens the location dialog on its projects. export function newSessionLocation(page: Page) { return page.locator('[data-component="new-session"] [data-action="prompt-project"]') } +// The new-session trigger that shows the draft's worktree, or Local, and opens the project's worktree step. +export function newSessionWorktree(page: Page) { + return page.locator('[data-component="new-session"] [data-action="prompt-worktree"]') +} + export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) { const worktree = Promise.withResolvers<{ status: number; json: unknown }>() const calls: string[] = [] @@ -139,13 +144,10 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr await page.goto(draftHref(draftID)) const editor = page.locator('[data-component="composer-editor"]') await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT }) - await newSessionLocation(page).click() - // The dialog opens on projects; → lists the current project's worktrees. - const search = page.getByRole("combobox", { name: "Search projects", exact: true }) - await expect(search).toBeFocused() - await search.press("ArrowRight") + await newSessionWorktree(page).click() + await expect(page.getByRole("combobox", { name: "Search worktrees", exact: true })).toBeFocused() await page.getByRole("option", { name: /^New worktree/ }).click() - await expect(newSessionLocation(page)).toContainText("New worktree") + await expect(newSessionWorktree(page)).toContainText("New worktree") await expect(editor).toBeEditable() return { ...workspace, draftID, editor, worktree, calls, worktreeRequests, creates, prompts } diff --git a/packages/app/package.json b/packages/app/package.json index fb38bb74a0fc..cd67034c4541 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/app", - "version": "2.2.2", + "version": "2.2.4", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/composer/attachments/attachments.ts b/packages/app/src/composer/attachments/attachments.ts index f8953eb10a1f..989275182dbc 100644 --- a/packages/app/src/composer/attachments/attachments.ts +++ b/packages/app/src/composer/attachments/attachments.ts @@ -2,7 +2,7 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { createBlobReference } from "@/runtime/persistence/drafts" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" import type { ComposerPrompt } from "../types" import type { ImageAttachmentPart, PathAttachmentPart } from "../state" import type { AttachmentDestination } from "./destination" diff --git a/packages/app/src/composer/attachments/destination.ts b/packages/app/src/composer/attachments/destination.ts index 76d583c961f7..7cd1578f2243 100644 --- a/packages/app/src/composer/attachments/destination.ts +++ b/packages/app/src/composer/attachments/destination.ts @@ -2,7 +2,7 @@ import type { Accessor } from "solid-js" import { useServer } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { authTokenFromCredentials } from "@/runtime/server/api" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" import { useWorkspaceLocation } from "@/workspaces/location" import type { ComposerControls } from "../adapter" diff --git a/packages/app/src/composer/comment-note.ts b/packages/app/src/composer/comment-note.ts index 227c4c4591b0..47ed31c5fcb6 100644 --- a/packages/app/src/composer/comment-note.ts +++ b/packages/app/src/composer/comment-note.ts @@ -1,7 +1,7 @@ import type { SessionMessageUser } from "@opencode/client/promise" import { Option, Schema } from "effect" import { Persistence } from "@/runtime/persistence/schema" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" import { FileSelection } from "@/workspaces/files/types" import { durableNote, LegacyBrowserNote, NoteComment, QuoteComment, type ContextItem } from "./schema" diff --git a/packages/app/src/composer/comments.tsx b/packages/app/src/composer/comments.tsx index 10b04218f99f..64537a1015e9 100644 --- a/packages/app/src/composer/comments.tsx +++ b/packages/app/src/composer/comments.tsx @@ -7,7 +7,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage" import { useServerSDK } from "@/runtime/server/client" import type { ServerScope } from "@/runtime/server/scope" import { createScopedCache } from "@/runtime/server/scoped-cache" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" import type { SelectedLineRange } from "@/workspaces/files/model" import { useWorkspaceLocation } from "@/workspaces/location" import { CommentStore, type LineComment } from "./schema" @@ -80,9 +80,12 @@ function group(comments: LineComment[]) { } function createCommentSessionState(store: Store, setStore: SetStoreFunction) { - const [state, setState] = createStore({ - focus: null as CommentFocus | null, - active: null as CommentFocus | null, + const [state, setState] = createStore<{ + focus: CommentFocus | null + active: CommentFocus | null + }>({ + focus: null, + active: null, }) // Reuse the previous array when contents are unchanged so consumers keep a stable diff --git a/packages/app/src/composer/editor/actions.test.ts b/packages/app/src/composer/editor/actions.test.ts index eb877b03e5be..9d3ebc5332d3 100644 --- a/packages/app/src/composer/editor/actions.test.ts +++ b/packages/app/src/composer/editor/actions.test.ts @@ -104,4 +104,47 @@ describe("Composer store", () => { ]) expect(prompt.state.cursor).toBe(9) }) + + test("inserts consecutive mentions without a range and replaces typed triggers after an existing mention", () => { + const prompt = createComposerEditorActions( + createStore({ + prompt: [{ type: "text", content: "", start: 0, end: 0 }], + context: { items: [] }, + }), + ) + + prompt.addMention({ type: "file", path: "src/a.ts", content: "@src/a.ts", start: 0, end: 0 }) + prompt.addMention({ type: "file", path: "src/b.ts", content: "@src/b.ts", start: 0, end: 0 }) + + expect(prompt.state.prompt).toEqual([ + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 0, end: 9 }, + { type: "text", content: " ", start: 9, end: 10 }, + { type: "file", path: "src/b.ts", content: "@src/b.ts", start: 10, end: 19 }, + { type: "text", content: " ", start: 19, end: 20 }, + ]) + expect(prompt.state.cursor).toBe(20) + + prompt.addText("@src/c") + prompt.addMention({ type: "file", path: "src/c.ts", content: "@src/c.ts", start: 0, end: 0 }) + + expect(prompt.state.prompt).toEqual([ + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 0, end: 9 }, + { type: "text", content: " ", start: 9, end: 10 }, + { type: "file", path: "src/b.ts", content: "@src/b.ts", start: 10, end: 19 }, + { type: "text", content: " ", start: 19, end: 20 }, + { type: "file", path: "src/c.ts", content: "@src/c.ts", start: 20, end: 29 }, + { type: "text", content: " ", start: 29, end: 30 }, + ]) + expect(prompt.state.cursor).toBe(30) + + prompt.setPrompt([{ type: "file", path: "src/a.ts", content: "@src/a.ts", start: 0, end: 9 }], 9) + prompt.addMention({ type: "file", path: "src/b.ts", content: "@src/b.ts", start: 0, end: 0 }) + + expect(prompt.state.prompt).toEqual([ + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 0, end: 9 }, + { type: "file", path: "src/b.ts", content: "@src/b.ts", start: 9, end: 18 }, + { type: "text", content: " ", start: 18, end: 19 }, + ]) + expect(prompt.state.cursor).toBe(19) + }) }) diff --git a/packages/app/src/composer/editor/actions.ts b/packages/app/src/composer/editor/actions.ts index c1b06fd62e60..9d07c455516c 100644 --- a/packages/app/src/composer/editor/actions.ts +++ b/packages/app/src/composer/editor/actions.ts @@ -80,14 +80,19 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) { mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart | ComposerSessionPart, range?: { start: number; end: number }, ) { - const text = store() - .prompt.map((part) => ("content" in part ? part.content : "")) - .join("") - - const end = range?.end ?? store().cursor ?? text.length - const start = range?.start ?? text.slice(0, end).lastIndexOf("@") - setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention)) - setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1) + const prompt = withOffsets(store().prompt) + const length = promptLength(prompt) + const end = range?.end ?? Math.min(store().cursor ?? length, length) + + const part = prompt.find( + (item): item is Extract => + item.type === "text" && end >= item.start && end <= item.end, + ) + + const index = part ? part.content.slice(0, end - part.start).lastIndexOf("@") : -1 + const start = range?.start ?? (part && index >= 0 ? part.start + index : end) + setStore()("prompt", insertMention(prompt, start, end, mention)) + setStore()("cursor", start + mention.content.length + 1) clearRetry() }, removeAttachment(id: string) { @@ -132,28 +137,36 @@ function insertMention( end: number, mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart | ComposerSessionPart, ): ComposerPrompt { - if (start === 0 && end === 0) { - return withOffsets([mention, { type: "text", content: " ", start: 0, end: 0 }, ...prompt]) - } - let position = 0 + let inserted = false const parts = prompt.flatMap((part) => { if (isAttachment(part)) return [part] const partStart = position position += part.content.length - if (part.type !== "text" || start < partStart || end > position) return [part] - const before = part.content.slice(0, start - partStart) - const after = part.content.slice(end - partStart) + if (inserted) return [part] - return [ - ...(before ? [{ type: "text" as const, content: before, start: 0, end: 0 }] : []), - mention, - { type: "text" as const, content: ` ${after}`, start: 0, end: 0 }, - ] + if (part.type === "text" && start >= partStart && end <= position) { + inserted = true + const before = part.content.slice(0, start - partStart) + const after = part.content.slice(end - partStart) + + return [ + ...(before ? [{ type: "text" as const, content: before, start: 0, end: 0 }] : []), + mention, + { type: "text" as const, content: ` ${after}`, start: 0, end: 0 }, + ] + } + + if (start !== end || start > partStart) return [part] + inserted = true + + return [mention, { type: "text", content: " ", start: 0, end: 0 }, part] }) + if (!inserted && start === end) parts.push(mention, { type: "text", content: " ", start: 0, end: 0 }) + return withOffsets(parts) } diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 7c7fad0f3771..ed030bfb1d89 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -423,6 +423,7 @@ export function ComposerEditor(props: ComposerEditorProps) { {(control) => ( - + {(option) => ( - + {option.label} )} @@ -1323,9 +1335,11 @@ function ComposerEditorPopover(props: { type="button" data-suggestion-id={item.id} data-active={props.activeID === item.id ? "" : undefined} - class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover" + class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start" classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }} - onPointerMove={() => props.onActiveChange(item)} + onPointerMove={(event) => { + if (event.movementX || event.movementY) props.onActiveChange(item) + }} onClick={() => props.onSelect(item)} >
diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index 452fed263476..0044a37e4161 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -166,7 +166,7 @@ export function createComposerEditor(input: { ) } - const [found, setFound] = createStore<{ files: ComposerSuggestion[] }>({ files: [] }) + const [found, setFound] = createStore<{ query?: string; files: ComposerSuggestion[] }>({ files: [] }) let fileQuery: string | undefined let fileAbort = new AbortController() onCleanup(() => abort(fileAbort)) @@ -181,7 +181,7 @@ export function createComposerEditor(input: { fileAbort = controller if (query === undefined || !query.trim() || /\s/.test(query)) { - if (found.files.length) setFound("files", []) + if (found.files.length) setFound({ query: undefined, files: [] }) return } @@ -189,7 +189,7 @@ export function createComposerEditor(input: { const timer = setTimeout( () => void Promise.resolve(input.searchContextFiles(query, controller.signal)).then( - (files) => !controller.signal.aborted && setFound("files", files), + (files) => !controller.signal.aborted && setFound({ query, files }), () => undefined, ), 150, @@ -263,6 +263,12 @@ export function createComposerEditor(input: { })), ) + // Files from the previous query stay listed while the next search runs, but the keyboard can't highlight or pick them. + const stale = (item: ComposerSuggestion) => + state.popover.type === "context" && item.kind === "file" && !item.recent && found.query !== state.popover.query + + const selectable = () => suggestions().flatMap((item) => (stale(item) ? [] : [item.id])) + const suggestions = () => { if (state.popover.type !== "history") return list().flat() const query = state.popover.query.trim().toLowerCase() @@ -324,6 +330,8 @@ export function createComposerEditor(input: { } function dispatch(event: ComposerInteractionEvent) { + if (event.type === "popover.active" && !selectable().includes(event.id)) return false + if (event.type === "popover.close") cancelFocus() if ( @@ -398,7 +406,7 @@ export function createComposerEditor(input: { key: event.key, ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey, composing: event.isComposing, - ids: suggestions().map((item) => item.id), + ids: selectable(), empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0), }) @@ -413,6 +421,21 @@ export function createComposerEditor(input: { if (handled) return true + // The suggestion lists don't page, and a page key must not move the caret out of the query or page the timeline. + if ( + state.popover.type !== "closed" && + (event.key === "PageUp" || event.key === "PageDown") && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.isComposing + ) { + event.preventDefault() + + return true + } + if (event.key === "Escape" && input.view.submit.queue?.editing()) { event.preventDefault() input.view.submit.queue.cancelEdit() @@ -452,9 +475,9 @@ export function createComposerEditor(input: { createEffect(() => { if (state.popover.type === "closed") return - const ids = suggestions().map((item) => item.id) + const ids = selectable() - if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return + if (state.popover.activeID ? state.popover.chosen && ids.includes(state.popover.activeID) : ids.length === 0) return dispatch({ type: "popover.results", ids }) }) diff --git a/packages/app/src/composer/suggestions/machine.test.ts b/packages/app/src/composer/suggestions/machine.test.ts index ec1a268998ed..3663461dafc5 100644 --- a/packages/app/src/composer/suggestions/machine.test.ts +++ b/packages/app/src/composer/suggestions/machine.test.ts @@ -146,9 +146,28 @@ test.each<{ name: "loops active popover items with arrow keys", from: { popover: { type: "context", query: "", activeID: "second" } }, event: key("ArrowDown", { ids: ["first", "second"] }), - popover: { type: "context", query: "", activeID: "first" }, + popover: { type: "context", query: "", activeID: "first", chosen: true }, handled: true, }, + { + name: "moves a highlight the user didn't choose to the first row when results reorder", + from: { popover: { type: "context", query: "se", activeID: "server" } }, + event: { type: "popover.results", ids: ["session", "server"] }, + popover: { type: "context", query: "se", activeID: "session", chosen: false }, + }, + { + name: "keeps a chosen highlight when results reorder", + from: { popover: { type: "context", query: "se", activeID: "server", chosen: true } }, + event: { type: "popover.results", ids: ["session", "server"] }, + popover: { type: "context", query: "se", activeID: "server", chosen: true }, + }, + { + name: "forgets the chosen highlight when the query changes", + from: { popover: { type: "context", query: "se", activeID: "server", chosen: true } }, + event: { type: "input.changed", value: "@ses" }, + draft: persisted("@ses"), + popover: { type: "context", query: "ses" }, + }, ])("$name", (row) => { const result = transitionComposer( { ...createComposerInteractionState(), ...row.from }, diff --git a/packages/app/src/composer/suggestions/machine.ts b/packages/app/src/composer/suggestions/machine.ts index 33c7a48b93b9..617845935596 100644 --- a/packages/app/src/composer/suggestions/machine.ts +++ b/packages/app/src/composer/suggestions/machine.ts @@ -5,10 +5,10 @@ export type ComposerInteractionState = { mode: "normal" | "shell" popover: | { type: "closed" } - | { type: "context"; query: string; activeID?: string } - | { type: "command-inline"; query: string; activeID?: string } - | { type: "command-menu"; query: string; activeID?: string } - | { type: "history"; query: string; activeID?: string } + | { type: "context"; query: string; activeID?: string; chosen?: boolean } + | { type: "command-inline"; query: string; activeID?: string; chosen?: boolean } + | { type: "command-menu"; query: string; activeID?: string; chosen?: boolean } + | { type: "history"; query: string; activeID?: string; chosen?: boolean } drag: "idle" | "active" focus: "editor" | "command-search" | "history-search" | "external" activeContextID?: string @@ -176,11 +176,11 @@ function queryChanged(state: ComposerInteractionState, query: string): ComposerE if (state.popover.type === "closed") return unchanged(state) if (state.popover.type === "history") - return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }) + return changed({ ...state, popover: { ...state.popover, query, activeID: undefined, chosen: undefined } }) const popover = state.popover.type === "context" ? "context" : "command" - return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [ + return changed({ ...state, popover: { ...state.popover, query, activeID: undefined, chosen: undefined } }, [ { type: "popover.filter", popover, query }, ]) } @@ -188,21 +188,28 @@ function queryChanged(state: ComposerInteractionState, query: string): ComposerE function resultsChanged(state: ComposerInteractionState, ids: string[]): ComposerEditorTransition { if (state.popover.type === "closed") return unchanged(state) - const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0] + // Until the user picks a row, the highlight follows the first row as results arrive and reorder. + const kept = + !!state.popover.activeID && + ids.includes(state.popover.activeID) && + (state.popover.chosen || state.popover.type === "history") + + const activeID = kept ? state.popover.activeID : ids[0] if (activeID === state.popover.activeID) return unchanged(state) return changed( - { ...state, popover: { ...state.popover, activeID } }, + { ...state, popover: { ...state.popover, activeID, chosen: kept && state.popover.chosen } }, state.popover.type === "history" && activeID ? [{ type: "history.preview", id: activeID }] : [], ) } function activeChanged(state: ComposerInteractionState, id: string): ComposerEditorTransition { - if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state) + if (state.popover.type === "closed" || (state.popover.activeID === id && state.popover.chosen)) + return unchanged(state) return changed( - { ...state, popover: { ...state.popover, activeID: id } }, + { ...state, popover: { ...state.popover, activeID: id, chosen: true } }, state.popover.type === "history" ? [{ type: "history.preview", id }] : [], ) } @@ -296,7 +303,7 @@ function keyDown( : index return changed( - { ...state, popover: { ...state.popover, activeID: event.ids[next] } }, + { ...state, popover: { ...state.popover, activeID: event.ids[next], chosen: true } }, state.popover.type === "history" ? [{ type: "history.preview", id: event.ids[next] }] : [], true, ) diff --git a/packages/app/src/home/projects/reveal.ts b/packages/app/src/home/projects/reveal.ts index 5de6f8369ff9..d9bf5c048ef4 100644 --- a/packages/app/src/home/projects/reveal.ts +++ b/packages/app/src/home/projects/reveal.ts @@ -1,6 +1,6 @@ import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" -import { ServerConnection } from "@/runtime/server/registry" +import { ServerConnection, useServers } from "@/runtime/server/registry" import { displayName } from "@opencode/ui/project-avatar" import { errorMessage } from "@/shell/layout/helpers" import type { LocalProject } from "@/shell/state/layout" @@ -9,9 +9,10 @@ import { showToast } from "@/shell/notifications/toast" export function useRevealProject() { const language = useLanguage() const platform = usePlatform() + const servers = useServers() const available = (conn: ServerConnection.Any) => - platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn) + platform.platform === "desktop" && !!platform.revealPath && servers.local(conn) return { available, diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx index 93e231f8509d..46cdcb13fd68 100644 --- a/packages/app/src/home/projects/view.tsx +++ b/packages/app/src/home/projects/view.tsx @@ -1,5 +1,6 @@ import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" import { createStore } from "solid-js/store" +import { createResizeObserver } from "@solid-primitives/resize-observer" import { Popover } from "@kobalte/core/popover" import { displayName, @@ -149,7 +150,17 @@ export function HomeProjectsView(props: HomeProjectsViewProps) { } function HomeProjectsPanel(props: HomeProjectsViewProps) { + let observer: MutationObserver | undefined + let frame = 0 const [contextMenu, setContextMenu] = createStore<{ open: string | undefined }>({ open: undefined }) + const updateFades = (element: HTMLDivElement) => { + element.toggleAttribute("data-fade-top", element.scrollTop > 0) + element.toggleAttribute("data-fade-bottom", element.scrollTop + element.clientHeight < element.scrollHeight - 1) + } + onCleanup(() => { + observer?.disconnect() + cancelAnimationFrame(frame) + }) const contextMenuProps = { contextMenuOpen: (id: string) => contextMenu.open === id, @@ -197,8 +208,19 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
-
-
+
+
{ + createResizeObserver(element, () => updateFades(element)) + observer = new MutationObserver(() => updateFades(element)) + observer.observe(element, { childList: true, subtree: true }) + frame = requestAnimationFrame(() => updateFades(element)) + }} + onScroll={(event) => updateFades(event.currentTarget)} + > ( - + <> + {/* A busy session shows its activity ring instead of the unread dot. */} + + + + + )} /> ) } -function HomeSessionLeading(props: { record: HomeSessionRecord; open: boolean; unread: boolean; loading: boolean }) { +function HomeSessionLeading(props: { record: HomeSessionRecord; open: boolean; unread: boolean }) { return (
@@ -264,7 +269,6 @@ function HomeSessionLeading(props: { record: HomeSessionRecord; open: boolean; u project={props.record.project} directory={props.record.session.location.directory} unread={props.unread} - loading={props.loading} />
) @@ -472,7 +476,7 @@ function HomeSessionSearchResultRow( props.onSearchSelect(props.record, { background: true }) }} > - +
@@ -622,7 +626,7 @@ function HomeSessionRow( class="flex h-10 min-w-0 w-full flex-1 items-center py-3 ps-1.5 pe-3 md:ps-3" classList={{ "gap-1.5": props.desktop, "gap-2": !props.desktop, "md:pe-10": SHOW_HOME_SESSION_ARCHIVE }} > - +
- +
location(project()) : undefined} - /> - } + branch={} onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()} onClick={() => location()} + onWorktree={project().id && project().vcs === "git" ? () => location(project()) : undefined} /> )} @@ -280,7 +275,10 @@ function fitPath(value: string, width: number, font: string) { } } -/** The composer tray: combined location picker, Git context, and the session's directory. */ +const TRIGGER = + "flex h-7 items-center gap-1.5 rounded-sm text-start ps-1.5 pe-2 text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none" + +/** The composer tray: project and worktree pickers, Git context, and the session's directory. */ function NewSessionLocation(props: { project: PromptProject /** "main", "create", or a worktree directory. */ @@ -289,6 +287,8 @@ function NewSessionLocation(props: { branch?: JSX.Element onboarding: boolean onClick: () => void + /** Opens the project's worktree step; without it the project has no worktrees to choose. */ + onWorktree?: () => void }) { const language = useLanguage() const command = useCommand() @@ -312,61 +312,85 @@ function NewSessionLocation(props: { return (
-
- -
- - {language.t("workspace.onboarding.title")} -
- {language.t("workspace.onboarding.description")} -
- ) : ( - language.t("session.new.workspace.trigger.tooltip") - ) - } - contentClass={props.onboarding ? "max-w-[280px]" : undefined} - class="flex min-w-0 max-w-full" - > - - + {displayName(props.project)} + + + + {(onWorktree) => ( + <> + + +
+ + {language.t("workspace.onboarding.title")} +
+ + {language.t("workspace.onboarding.description")} + +
+ ) : ( + language.t("session.location.worktree") + ) + } + contentClass={props.onboarding ? "max-w-[280px]" : undefined} + class="flex min-w-12 shrink-[1000]" + > + + + + )} + +
{props.branch}
{/* An open tooltip can outlive this row; keep its path instead of a stale Show accessor. */} @@ -390,7 +414,7 @@ function NewSessionLocation(props: { const style = getComputedStyle(row) setLayout({ - width: row.clientWidth - (icon?.clientWidth ?? 0) - parseFloat(style.columnGap), + width: row.clientWidth - (icon ? icon.clientWidth + parseFloat(style.columnGap) : 0), font: style.font, }) }) @@ -398,24 +422,23 @@ function NewSessionLocation(props: { class="flex min-w-0 flex-1 items-center justify-end gap-1.5" dir="ltr" > - - {language.t("session.location.toggle")} - + {/* The worktree trigger already says Local, so only a worktree marks its path. */} + + + {language.t("session.location.toggle")} + + + } + > + + - } - > - - - - + +
void - /** Opens the project's worktree picker; the checkout branch is static text without it. */ - onWorktree?: () => void -}) { +export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) { const language = useLanguage() + const command = useCommand() + const [state, setState] = createStore({ open: false, truncated: false }) let outside = false + let trigger: HTMLButtonElement | undefined + const creating = () => props.workspace.selection.value() === "create" // Fetch before opening and retain the result across closes, rather than showing a smaller loading panel. @@ -42,6 +43,33 @@ export function NewSessionBranch(props: { return result?.directory === props.workspace.project.root() ? result : undefined } + const changeOpen = (open: boolean) => { + setState("open", open) + + // Revalidate on every open; the loaded list stays on screen meanwhile. + if (open && initial()) branchActions.refetch() + } + + command.register("new-session-branch", () => [ + { + id: "session.branch.choose", + title: language.t("command.session.branch.choose"), + category: language.t("command.category.workspace"), + keybind: "mod+alt+.", + disabled: !creating() || !props.workspace.bar.branch(), + onSelect: (source) => { + changeOpen(source === "palette" || !state.open) + + // Pending opens have no close-autofocus handler. + if (!state.open && !initial()) props.onDone() + }, + }, + ]) + + createEffect(() => { + if (!creating()) setState("open", false) + }) + // Until content mounts, Kobalte has no dismissable layer to cancel a pending open. createEffect(() => { if (!state.open || initial()) return @@ -69,9 +97,6 @@ export function NewSessionBranch(props: { ? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" }) : props.workspace.bar.branch() - // A truncated name keeps its full-text tooltip; otherwise the clickable checkout branch explains its action. - const hint = () => !creating() && !!props.onWorktree && !state.truncated - const observe = (element: HTMLSpanElement) => createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth)) @@ -79,55 +104,40 @@ export function NewSessionBranch(props: { +
+ + {language.t("command.session.branch.choose")} + + + + + {label()} +
+
+ } + disabled={!creating() && !state.truncated} class="flex min-w-0 max-w-[220px]" contentClass="max-w-[calc(100vw-32px)] break-all" > - - - {label()} - -
- } - > - -
+
+ + + {label()} + +
} > - { - setState("open", open) - - // Revalidate on every open, as before the preload; the loaded list stays on screen meanwhile. - if (open && initial()) branchActions.refetch() - }} - placement="bottom-start" - gutter={4} - > + diff --git a/packages/app/src/providers/catalog/order.ts b/packages/app/src/providers/catalog/order.ts index c0ed38b9c852..ca3b975f9863 100644 --- a/packages/app/src/providers/catalog/order.ts +++ b/packages/app/src/providers/catalog/order.ts @@ -2,6 +2,7 @@ export const popularProviders = [ "opencode-go", "opencode", "anthropic", + "claude-agent", "github-copilot", "openai", "google", diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts index 22e3c74ec381..80764377f129 100644 --- a/packages/app/src/providers/connect/controller.ts +++ b/packages/app/src/providers/connect/controller.ts @@ -32,6 +32,8 @@ type Polling = { // and connects with its own methods, the same as the TUI. export const CONSOLE_INTEGRATION = "opencode" +export const CLAUDE_AGENT_INTEGRATION = "claude-agent" + // Same order as the TUI: browser and command sign-ins first, API keys last. export function connectMethods(integration: IntegrationInfo) { return integration.methods diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 0858d3bcff50..6932a67e2284 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -21,6 +21,7 @@ import { onMount, Show, Switch, + untrack, } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" @@ -36,6 +37,7 @@ import { useIntegrations } from "@/providers/catalog/integrations" import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group" import type { ModelSelection } from "@/providers/models/selection" import { decode64 } from "@/runtime/persistence/base64" +import { ExternalLink } from "@/runtime/platform/external-link" import { SettingsList } from "@/settings/list" import { useTabs } from "@/shell/tabs/tabs" import { Persist, persisted } from "@/runtime/persistence/storage" @@ -43,6 +45,7 @@ import { Persistence } from "@/runtime/persistence/schema" import { Schema } from "effect" import type { FormAnswer, FormField, FormValue, IntegrationInfo } from "@opencode/client/promise" import { + CLAUDE_AGENT_INTEGRATION, CONSOLE_INTEGRATION, connectMethods, createProviderConnectionController, @@ -265,8 +268,12 @@ export const DialogConnectProvider: Component<{ ) } -// The TUI's integration groups and order, with connected integrations pulled up into their own group first. -const POPULAR = ["opencode", "opencode-go", "openai", "github-copilot", "anthropic", "google"] +const CLAUDE_CODE_SETUP_URL = "https://code.claude.com/docs/en/setup" + +const CLAUDE_AGENT_DOCS_URL = "https://opencode.ai/v2/docs/providers#claude-agent" + +// The TUI's integration groups and order, plus Claude Agent, with connected integrations pulled up into their own group first. +const POPULAR = ["opencode", "opencode-go", "openai", "github-copilot", "anthropic", "claude-agent", "google"] function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { const integrations = useIntegrations(() => props.directory) @@ -511,6 +518,7 @@ function ProviderConnection(props: { const accountActions = useProviderAccountActions(directory) const integrationID = props.provider const isConsole = props.provider === CONSOLE_INTEGRATION + const isClaudeAgent = props.provider === CLAUDE_AGENT_INTEGRATION const remote = isConsole && authServerName(sdk.server) !== undefined const [state, setState] = createStore<{ @@ -527,6 +535,7 @@ function ProviderConnection(props: { switching?: string activating?: string renaming?: string + claudeAgent?: "checking" | "confirming" | "timeout" }>({ adding: false, direct: false, @@ -556,50 +565,63 @@ function ProviderConnection(props: { const controller = createProviderConnectionController({ provider: () => integrationID, directory, - manual: () => !!props.accounts && accounts().length > 0, + // Claude Agent explains its setup before connecting, so its lone method never starts on its own. + manual: () => isClaudeAgent || (!!props.accounts && accounts().length > 0), prepare: isConsole ? prepareConsoleCatalog : undefined, pollInterval: isConsole ? 500 : undefined, onComplete: () => { const method = controller.currentMethod() props.onConnected?.(method?.type === "oauth" ? method.id : undefined) - // The picker only lists the newest model per family by default, which hides most of - // what a new connection just unlocked. Show everything the connected integration offers. - global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id }))) - if (state.catalogPending) { - setState("noModels", true) + // Saving the connection only records the choice; wait for the server's check of `claude`. + if (isClaudeAgent) { + setState("claudeAgent", "checking") return } - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), - description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), - }) + showConnected() + }, + }) - // Like the TUI, every connection continues to that provider's models, not just the first. - // MCP servers have no models, so they close instead. - const first = mcp() ? undefined : connectionGroups()[0]?.models[0] + function showConnected() { + // The picker only lists the newest model per family by default, which hides most of + // what a new connection just unlocked. Show everything the connected integration offers. + global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id }))) - if (first) { - setState({ models: true, selectedModel: modelKey(first) }) - props.onFirstConnection({ id: props.provider, name: provider().name }) + if (state.catalogPending) { + setState("noModels", true) - return - } + return + } - // Keep the "connected, but no models" state visible so the workspace can be fixed. - if (isConsole) { - setState("noModels", true) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), + description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + }) - return - } + // Like the TUI, every connection continues to that provider's models, not just the first. + // MCP servers have no models, so they close instead. + const first = mcp() ? undefined : connectionGroups()[0]?.models[0] - dialog.close() - }, - }) + if (first) { + setState({ models: true, selectedModel: modelKey(first) }) + props.onFirstConnection({ id: props.provider, name: provider().name }) + + return + } + + // Keep the "connected, but no models" state visible so the workspace can be fixed. + if (isConsole) { + setState("noModels", true) + + return + } + + dialog.close() + } const connectionProviders = createMemo(() => (data.location.provider.list(location()) ?? []).filter( @@ -625,6 +647,58 @@ function ProviderConnection(props: { const managedProviders = createMemo(() => (isConsole ? consoleProviderGroup(connectionProviders()) : undefined)) + // The server lists the active account first, and its status is the latest check of `claude`. + const claudeAgentProblem = () => (isClaudeAgent ? serverAccounts()[0]?.status : undefined) + + // A healthy connection keeps the usual accounts list; anything else shows the setup and its latest check. + const claudeAgentSetup = () => + isClaudeAgent && + controller.auth.state() !== "error" && + (!showAccounts() || !!state.claudeAgent || !!claudeAgentProblem() || controller.methodIndex() !== undefined) + + // A check that outlives the timeout can still finish, so a late result replaces the timeout message. + createEffect(() => { + if (!state.claudeAgent) return + + if (claudeAgentProblem()) return setState("claudeAgent", undefined) + + if (state.claudeAgent === "confirming" || connectionGroups().length === 0) return + setState("claudeAgent", "confirming") + void untrack(confirmClaudeAgent) + }) + + // A signed-out `claude` still lists its models, and the login problem arrives in a separate event, + // so read the connection again before treating the models as a working login. + async function confirmClaudeAgent() { + const ref = location() + data.location.integration.invalidate(ref) + await data.location.integration.sync(ref).catch(() => undefined) + + if (state.claudeAgent !== "confirming" || claudeAgentProblem()) return + setState("claudeAgent", undefined) + showConnected() + } + + createEffect(() => { + if (state.claudeAgent !== "checking") return + // The server allows 30 seconds for `claude` to start and 30 more to read each model's context size. + const timer = setTimeout(() => setState("claudeAgent", "timeout"), 65_000) + onCleanup(() => clearTimeout(timer)) + }) + + // Connecting again is what makes the server check `claude` again. + const checkClaudeAgent = async () => { + const previous = serverAccounts().map((account) => account.id) + setState("claudeAgent", undefined) + await controller.auth.select(0) + + if (controller.auth.state() !== "ready") return + // Each connect saves a new account, so drop the ones this check replaces. + await Promise.all( + previous.map((credentialID) => sdk.api.credential.remove({ credentialID }).catch(() => undefined)), + ) + } + // The server loads the Console workspace's providers after the grant lands, so the first refresh // can still show only the free catalog. Poll briefly for the workspace providers before moving on. async function prepareConsoleCatalog(active: () => boolean) { @@ -719,7 +793,7 @@ function ProviderConnection(props: { controller.authorization()?.mode === "auto" createEffect(() => { - if (showAccounts()) return props.onTitle(provider().name) + if (showAccounts() && !claudeAgentSetup()) return props.onTitle(provider().name) if (isConsole && controller.auth.state() === "error") return props.onTitle(language.t("provider.connect.opencode.errorTitle")) @@ -1141,6 +1215,7 @@ function ProviderConnection(props: { props.onAccounts( showAccounts() && controller.methodIndex() === undefined && + !claudeAgentSetup() && !(state.noModels && controller.auth.state() !== "error"), ), ) @@ -1405,8 +1480,8 @@ function ProviderConnection(props: { const addAccount = () => { setState({ adding: true, direct: false, confirming: undefined }) - // Like the TUI: a lone method starts directly, several show the method list. - if (controller.methods().length !== 1) return + // Like the TUI: a lone method starts directly, several show the method list. Claude Agent shows its setup first. + if (isClaudeAgent || controller.methods().length !== 1) return setState("direct", true) void controller.auth.select(0) } @@ -1559,6 +1634,72 @@ function ProviderConnection(props: { ) } + function ClaudeAgentSetup() { + const problem = () => + claudeAgentProblem()?.message ?? + (state.claudeAgent === "timeout" ? language.t("provider.connect.claudeAgent.timeout") : undefined) + + const checking = () => + state.claudeAgent === "checking" || + state.claudeAgent === "confirming" || + controller.auth.state() === "pending" || + controller.auth.state() === "refreshing" + + return ( +
+

+ {language.t("provider.connect.claudeAgent.description")}{" "} + {language.t("provider.connect.claudeAgent.learnMore")} +

+ + {(message) => ( +
+ + {message()} +
+ )} +
+ + {language.t("provider.connect.claudeAgent.checking")}} + > +
+ + +
+
+
+ ) + } + function ConsoleNoModels() { return (
@@ -1770,6 +1911,9 @@ function ProviderConnection(props: { />
+ + + diff --git a/packages/app/src/runtime/i18n/desktop-native.ts b/packages/app/src/runtime/i18n/desktop-native.ts index 041350010238..e9ac685bdb2b 100644 --- a/packages/app/src/runtime/i18n/desktop-native.ts +++ b/packages/app/src/runtime/i18n/desktop-native.ts @@ -1,3 +1,5 @@ +import { flow, Option, Schema } from "effect" + export const DESKTOP_NATIVE_LOCALES = [ "en", "zh", @@ -231,6 +233,15 @@ const normalizeScript = (script?: string) => (script === "Aran" ? "Arab" : scrip export const DESKTOP_NATIVE_ENGLISH = { "desktop.microphoneUsage": "OpenCode uses your microphone for voice calls.", + "desktop.documentsFolderUsage": + "OpenCode uses files in your Documents folder for projects you open and commands you run there.", + "desktop.desktopFolderUsage": "OpenCode uses files on your Desktop for projects you open and commands you run there.", + "desktop.downloadsFolderUsage": + "OpenCode uses files in your Downloads folder for projects you open and commands you run there.", + "desktop.networkVolumesUsage": + "OpenCode uses files on network volumes for projects you open and commands you run there.", + "desktop.removableVolumesUsage": + "OpenCode uses files on removable volumes for projects you open and commands you run there.", "desktop.menu.app": "OpenCode", "desktop.menu.file": "File", "desktop.menu.edit": "Edit", @@ -302,6 +313,9 @@ export const DESKTOP_NATIVE_ENGLISH = { "desktop.picker.error.notSelected": "File was not selected by the picker", "desktop.picker.error.sizeLimit": "Selected attachments exceed the {{limit}} MB limit", + + "desktop.contextMenu.spellcheck": "Spellcheck", + "desktop.contextMenu.spellcheckLanguages": "Spellcheck Languages", } as const export type DesktopNativeKey = keyof typeof DESKTOP_NATIVE_ENGLISH @@ -310,6 +324,7 @@ export type DesktopNativeMessages = Record export type DesktopNativeBundle = { locale: DesktopNativeLocale; messages: DesktopNativeMessages } +// SAFETY: DESKTOP_NATIVE_ENGLISH is a const literal, so its own keys are exactly DesktopNativeKey. export const DESKTOP_NATIVE_KEYS = Object.keys(DESKTOP_NATIVE_ENGLISH) as DesktopNativeKey[] export const DESKTOP_NATIVE_MAX_PAYLOAD_BYTES = 64 * 1024 @@ -320,35 +335,24 @@ export function createDesktopNativeBundle( ): DesktopNativeBundle { return { locale, + // SAFETY: the entries map every DesktopNativeKey to a string, which is exactly DesktopNativeMessages. messages: Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key) => [key, translate(key)])) as DesktopNativeMessages, } } -export function parseDesktopNativeBundle(value: unknown): DesktopNativeBundle | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined - - try { - if (new TextEncoder().encode(JSON.stringify(value)).byteLength > DESKTOP_NATIVE_MAX_PAYLOAD_BYTES) return undefined - } catch { - return undefined - } +const DesktopNativeBundleSchema = Schema.Struct({ + locale: Schema.Literals(DESKTOP_NATIVE_LOCALES), + messages: Schema.Record(Schema.Literals(DESKTOP_NATIVE_KEYS), Schema.String), +}).check( + Schema.makeFilter( + (bundle) => new TextEncoder().encode(JSON.stringify(bundle)).byteLength <= DESKTOP_NATIVE_MAX_PAYLOAD_BYTES, + ), +) - const bundle = value as { locale?: unknown; messages?: unknown } +const decodeDesktopNativeBundle = Schema.decodeUnknownOption(DesktopNativeBundleSchema, { onExcessProperty: "error" }) - if (!DESKTOP_NATIVE_LOCALES.some((locale) => locale === bundle.locale)) return undefined - - if (!bundle.messages || typeof bundle.messages !== "object" || Array.isArray(bundle.messages)) return undefined - const messages = bundle.messages as Record - const keys = Object.keys(messages) - - if (keys.length !== DESKTOP_NATIVE_KEYS.length) return undefined - - if (!DESKTOP_NATIVE_KEYS.every((key) => typeof messages[key] === "string")) return undefined - - if (!keys.every((key) => key in DESKTOP_NATIVE_ENGLISH)) return undefined - - return bundle as DesktopNativeBundle -} +/** Validates a bundle the renderer sent over IPC: a supported locale, exactly the native keys, and a bounded size. */ +export const parseDesktopNativeBundle = flow(decodeDesktopNativeBundle, Option.getOrUndefined) export function formatDesktopNativeMessage(message: string, params?: Record) { if (!params) return message diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 7f3e0724b6e8..3ea280e06667 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -65,6 +65,7 @@ export const dict = { "command.usage.view": "View usage", "command.usage.view.description": "See your OpenCode Go allowance and Console budgets", "command.session.location.cycle": "Cycle session location", + "command.session.branch.choose": "Choose base branch", "session.location.toggle": "Toggle session location", "command.palette": "Search everything", @@ -147,6 +148,7 @@ export const dict = { "dialog.provider.opencode.tagline": "Reliable optimized models", "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", "dialog.provider.anthropic.note": "Direct access to Claude models via API key", + "dialog.provider.claudeAgent.note": "Use your Claude plan or another Claude Code login", "dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot", "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", "dialog.provider.google.note": "Gemini models for fast, structured responses", @@ -227,6 +229,16 @@ export const dict = { "provider.connect.remote.description": "Your OpenCode credentials will be stored on this server. Models will be available through this server.", "provider.connect.title.anthropicProMax": "Login with Anthropic", + "provider.connect.claudeAgent.description": + "Use your Claude plan or another Claude Code login. Install Claude Code and sign in first.", + "provider.connect.claudeAgent.learnMore": "Learn more", + "provider.connect.claudeAgent.install": "Install Claude Code", + "provider.connect.claudeAgent.signIn.command": "Sign-in command", + "provider.connect.claudeAgent.connect": "Connect", + "provider.connect.claudeAgent.checkAgain": "Check again", + "provider.connect.claudeAgent.checking": "Checking Claude Code…", + "provider.connect.claudeAgent.timeout": + "Couldn't confirm that Claude Code is ready. Make sure it's installed and signed in, then check again.", "provider.connect.command.waiting": "Waiting for command to finish…", "provider.connect.command.expired": "Authentication expired", "provider.connect.form.required": "Answer required", @@ -550,6 +562,8 @@ export const dict = { "error.chain.configInvalidWithMessage": "Config file at {{path}} is invalid: {{message}}", "notification.permission.title": "Permission required", + "notification.question.title": "Question requires an answer", + "notification.elicitation.title": "Input requested", "notification.session.responseReady.title": "Response ready", "notification.session.error.title": "Session error", @@ -665,6 +679,7 @@ export const dict = { "session.new.project.new": "New project", "session.new.project.search": "Search projects", "session.new.project.add": "Add project", + "session.new.project.trigger.tooltip": "Choose project", "session.new.worktree.creating": "Creating worktree", "session.new.workspace.runIn": "Run session in", "session.new.workspace.triggerLocal": "Local", @@ -809,6 +824,7 @@ export const dict = { "settings.tab.about": "About", "settings.about.version": "Version {{version}}", "settings.about.devVersion": "development", + "settings.about.copyVersionFailed": "Failed to copy version", "settings.about.license": "Released under the MIT License", "settings.about.writtenByNames": "Written by {{names}}", "settings.about.illustratedByNames": "Illustrated by {{names}}", @@ -876,6 +892,7 @@ export const dict = { "settings.server.preferences.websearch.description": "Select the search provider agents use to search the web", "settings.projects.title": "Projects", "settings.projects.description": "Manage projects and their settings", + "settings.projects.other": "Other projects", "settings.projects.empty": "No projects found", "settings.projects.empty.title": "No projects yet", "settings.projects.empty.description": "Add a project to get started", @@ -1002,6 +1019,8 @@ export const dict = { "settings.general.row.followUpBehavior.steer": "Steer", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", + "settings.general.row.spellcheck.title": "Spellcheck", + "settings.general.row.spellcheck.description": "Underline misspelled words while typing", "sound.option.none": "None", "sound.option.alert01": "Alert 01", @@ -1055,6 +1074,8 @@ export const dict = { "Show system notification when the agent is complete or needs attention", "settings.general.notifications.permissions.title": "Permissions", "settings.general.notifications.permissions.description": "Show system notification when a permission is required", + "settings.general.notifications.questions.title": "Questions", + "settings.general.notifications.questions.description": "Show system notification when the agent asks a question", "settings.general.notifications.errors.title": "Errors", "settings.general.notifications.errors.description": "Show system notification when an error occurs", @@ -1062,6 +1083,8 @@ export const dict = { "settings.general.sounds.agent.description": "Play sound when the agent is complete or needs attention", "settings.general.sounds.permissions.title": "Permissions", "settings.general.sounds.permissions.description": "Play sound when a permission is required", + "settings.general.sounds.questions.title": "Questions", + "settings.general.sounds.questions.description": "Play sound when the agent asks a question", "settings.general.sounds.errors.title": "Errors", "settings.general.sounds.errors.description": "Play sound when an error occurs", "settings.general.sounds.interface.title": "Interface", diff --git a/packages/app/src/runtime/i18n/hr.ts b/packages/app/src/runtime/i18n/hr.ts index ad34590b973a..f13b2b0a629d 100644 --- a/packages/app/src/runtime/i18n/hr.ts +++ b/packages/app/src/runtime/i18n/hr.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Predloženo", "command.category.view": "Prikaz", "command.category.project": "Projekt", diff --git a/packages/app/src/runtime/i18n/hu.ts b/packages/app/src/runtime/i18n/hu.ts index 2d4d17c2267e..a2e99ff2926c 100644 --- a/packages/app/src/runtime/i18n/hu.ts +++ b/packages/app/src/runtime/i18n/hu.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Javasolt", "command.category.view": "Nézet", "command.category.project": "Projekt", diff --git a/packages/app/src/runtime/i18n/is.ts b/packages/app/src/runtime/i18n/is.ts index 4a4081ec114f..e3649604fd53 100644 --- a/packages/app/src/runtime/i18n/is.ts +++ b/packages/app/src/runtime/i18n/is.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Tillögur", "command.category.view": "Skoða", "command.category.project": "Verkefni", diff --git a/packages/app/src/runtime/i18n/lt.ts b/packages/app/src/runtime/i18n/lt.ts index a7972957eff8..9201a751cfa0 100644 --- a/packages/app/src/runtime/i18n/lt.ts +++ b/packages/app/src/runtime/i18n/lt.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Siūloma", "command.category.view": "Rodinys", "command.category.project": "Projektas", diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx index ecbf0b2eb427..38a12334f229 100644 --- a/packages/app/src/runtime/platform/platform.tsx +++ b/packages/app/src/runtime/platform/platform.tsx @@ -108,6 +108,12 @@ type PlatformBase = { /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */ setPinchZoomEnabled?(enabled: boolean): Promise | void + /** Get whether the native spellchecker underlines misspelled words (desktop only) */ + getSpellcheckEnabled?(): Promise + + /** Turn the native spellchecker on or off (desktop only) */ + setSpellcheckEnabled?(enabled: boolean): Promise + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/runtime/plugin/host-apis.tsx b/packages/app/src/runtime/plugin/host-apis.tsx index 3b180b1e9592..b7a5dab43c9f 100644 --- a/packages/app/src/runtime/plugin/host-apis.tsx +++ b/packages/app/src/runtime/plugin/host-apis.tsx @@ -16,6 +16,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { useTheme } from "@opencode/ui/theme/context" import { toaster } from "@opencode/ui/toast" import { base64Encode } from "@opencode/util/encode" +import { uuid } from "@opencode/util/uuid" import { type App, type Layout, @@ -535,7 +536,7 @@ export function createPluginAttachment(apis: HostApis) { return live().data }, get local() { - return ServerConnection.local(listed()) + return servers.local(listed()) }, get builtin() { return ServerConnection.builtin(listed()) @@ -1023,7 +1024,7 @@ export function createPluginAttachment(apis: HostApis) { id: error ? settings.sounds.errors() : settings.sounds.agent(), })) - const key = `plugin:${plugin}\0${options.id ?? crypto.randomUUID()}` + const key = `plugin:${plugin}\0${options.id ?? uuid()}` const result = { notification: false, sound: false } const session = options.session diff --git a/packages/app/src/runtime/plugin/panel-menu.ts b/packages/app/src/runtime/plugin/panel-menu.ts index 9bcc15fe395e..56b03dd228d6 100644 --- a/packages/app/src/runtime/plugin/panel-menu.ts +++ b/packages/app/src/runtime/plugin/panel-menu.ts @@ -1,15 +1,29 @@ import { createMemo } from "solid-js" -import { MenuItem } from "@opencode/plugin/gui" +import { MenuItem, type SessionPanelMenuItem } from "@opencode/plugin/gui" import { usePluginHost } from "./host" -/** The side region's + menu: every plugin's `session.panel` item, in order. */ -export function createSessionPanelMenu() { +export type SessionPanelMenuEntry = { + readonly key: string + readonly item: SessionPanelMenuItem +} + +/** The side region's + menu entries, keyed by `${plugin}:${id}` in display order. */ +export function createSessionPanelMenuEntries() { const host = usePluginHost() return createMemo(() => host - .list(MenuItem) - .flatMap((item) => (item.menu === "session.panel" ? [item] : [])) - .toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)), + .items(MenuItem) + .flatMap((entry): SessionPanelMenuEntry[] => + entry.value.menu === "session.panel" ? [{ key: `${entry.plugin}:${entry.value.id}`, item: entry.value }] : [], + ) + .toSorted((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0)), ) } + +/** The side region's + menu: every plugin's `session.panel` item, in order. */ +export function createSessionPanelMenu() { + const entries = createSessionPanelMenuEntries() + + return createMemo(() => entries().map((entry) => entry.item)) +} diff --git a/packages/app/src/runtime/plugin/panel-trigger.tsx b/packages/app/src/runtime/plugin/panel-trigger.tsx index acb84d9bca24..fb6bbd278efb 100644 --- a/packages/app/src/runtime/plugin/panel-trigger.tsx +++ b/packages/app/src/runtime/plugin/panel-trigger.tsx @@ -97,16 +97,18 @@ export function PanelTrigger(props: { const compactClose = () => tooltip( props.onClose(props.value)} aria-label={language.t("common.closeTab")} />) - // The close button `closable` asks for; a plain one by default. - const close = () => { - const closable = props.tab.closable + const closable = createMemo(() => props.tab.closable) - if (closable === false) return undefined + // Memoized by `closable` mode so a refreshed RegionEntry never recreates the button mid-click. + const close = createMemo(() => { + const mode = closable() - if (closable === "compact") return compactClose() + if (mode === false) return undefined - return closeButton(closable === "hover") - } + if (mode === "compact") return compactClose() + + return closeButton(mode === "hover") + }) return (
input.tabs().all() const providers = createMemo(() => host.items(BuiltinPanel).filter((item) => item.value.region === input.region)) - const entries = createMemo(() => - providers().flatMap((item) => { - const prefix = `${item.plugin}:` - const open = stored().flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])) - - return item.value.list(panelInput(input.view, input.screen, { open })).map( - (tab): RegionEntry => ({ - key: panelKey(item.plugin, tab.id), - plugin: item.plugin, - tab, - provider: item.value, - }), - ) - }), + const entries = createMemo( + (previous) => { + const prior = new Map(previous.map((entry) => [entry.key, entry])) + + return providers().flatMap((item) => { + const prefix = `${item.plugin}:` + const open = stored().flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])) + + return item.value.list(panelInput(input.view, input.screen, { open })).map((tab): RegionEntry => { + const key = panelKey(item.plugin, tab.id) + const existing = prior.get(key) + + if (existing && existing.tab === tab && existing.provider === item.value) return existing + + return { + key, + plugin: item.plugin, + tab, + provider: item.value, + } + }) + }) + }, + [], + { equals: same }, ) const byKey = createMemo(() => new Map(entries().map((entry) => [entry.key, entry]))) @@ -327,12 +338,29 @@ export function createRegion(input: { if (state.adding.some((value) => !live(value))) setState("adding", (list) => list.filter(live)) }) + const empty = createMemo(() => strip().length === 0) + + // Focus leaves before the close starts, back to where it was; otherwise the panel hands it to its toggle. + const giveBackFocus = () => { + const inside = !!document.getElementById("review-panel")?.contains(document.activeElement) + const back = returnFocus + returnFocus = undefined + + if (inside && back?.isConnected) back.focus({ preventScroll: true }) + } + + // Emptying the region closes it, so focus goes back as the user's own close would give it. + const emptied = () => { + giveBackFocus() + input.onEmptied?.() + } + // Closing the last New tab of a strip with no tabs closes the region, as closing its last tab does. const dismissNewTab = (id: string) => batch(() => { setState("adding", (list) => list.filter((value) => value.id !== id)) - if (stored().length === 0 && !state.adding.some(live)) input.onEmptied?.() + if (empty() && !state.adding.some(live)) emptied() }) return { @@ -342,7 +370,7 @@ export function createRegion(input: { /** The drawn strip of the pane that holds the selected tab, by key. */ keys: () => panes[activePane()].keys(), /** The strip holds no tab, hidden ones included. */ - empty: createMemo(() => strip().length === 0), + empty, entry: (key: string) => byKey().get(key), active, selected: createMemo(() => { @@ -396,12 +424,7 @@ export function createRegion(input: { const focused = document.activeElement if (side.opened()) { - const inside = !!document.getElementById("review-panel")?.contains(focused) - const back = returnFocus - returnFocus = undefined - - // Focus leaves before the close starts, back to where it was; otherwise the panel hands it to its toggle. - if (inside && back?.isConnected) back.focus({ preventScroll: true }) + giveBackFocus() side.toggle() return @@ -431,7 +454,7 @@ export function createRegion(input: { batch(() => { input.tabs().close(key) - if (last) input.onEmptied?.() + if (last) emptied() }) if (entry) diff --git a/packages/app/src/runtime/plugin/side-region.tsx b/packages/app/src/runtime/plugin/side-region.tsx index 7d249cd99097..789d9d325366 100644 --- a/packages/app/src/runtime/plugin/side-region.tsx +++ b/packages/app/src/runtime/plugin/side-region.tsx @@ -30,11 +30,12 @@ import { import { useCommand } from "@/shell/commands/command" import { guardClosingFocus } from "@/shell/panel-motion" import { useLanguage } from "@/runtime/i18n/language" +import { same } from "@/runtime/persistence/equality" import { useLayout } from "@/shell/state/layout" import { useSessionLayout } from "@/session/session-layout" import type { Sizing } from "@/session/helpers" import { PanelTrigger } from "./panel-trigger" -import { createSessionPanelMenu } from "./panel-menu" +import { createSessionPanelMenuEntries, type SessionPanelMenuEntry } from "./panel-menu" import { RegionContent, type Region, type RegionPane } from "./panels" import { PluginSlot } from "./render" import { createTabStripScroll } from "./tab-strip-scroll" @@ -201,9 +202,9 @@ export function PaneTabs(props: { }) { const language = useLanguage() const { tabs } = useSessionLayout() - const menu = createSessionPanelMenu() - const primary = createMemo(() => menu().find((item) => item.primary)) - const launchers = createMemo(() => menu().filter((item) => !item.primary && !item.opened)) + const menu = createSessionPanelMenuEntries() + const primary = createMemo(() => menu().find((entry) => entry.item.primary)?.item) + const launchers = createMemo(() => menu().filter((entry) => !entry.item.primary && !entry.item.opened)) // The next tab this pane's "+" menu opens lands in this pane. const target = () => tabs().target(props.pane.id) @@ -254,8 +255,9 @@ export function PaneTabs(props: { selectionEvent.target instanceof Element && selectionEvent.target.closest('[role="tab"]') ) { - props.region.newTabs(props.pane.id).forEach((entry) => props.region.dismissNewTab(entry.id)) + // Select first: dismissing the only New tab with nothing selected lets Kobalte pick the first tab meanwhile. props.region.select(value) + props.region.newTabs(props.pane.id).forEach((entry) => props.region.dismissNewTab(entry.id)) } }} > @@ -331,7 +333,7 @@ export function PaneTabs(props: {
0} @@ -495,9 +497,11 @@ function AddButton(props: { item: SessionPanelMenuItem; onChoose: () => void }): ) } -function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () => void }): JSX.Element { +function AddMenu(props: { items: readonly SessionPanelMenuEntry[]; onChoose: () => void }): JSX.Element { const language = useLanguage() const command = useCommand() + const keys = createMemo(() => props.items.map((entry) => entry.key), [], { equals: same }) + const byKey = createMemo(() => new Map(props.items.map((entry) => [entry.key, entry.item]))) // A chosen item runs once the closing menu has returned focus to "+", and the tab strip has passed it on to the // selected tab. Releasing that focus first lets the item move it where its tab needs it, e.g. into a new terminal, // without the strip taking it back for the tab it remembers. @@ -530,27 +534,35 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () = }) }} > - - {(item) => { - const keybind = createMemo(() => (item.keybind ? command.keybindParts(item.keybind) : [])) - - return ( - (chosen = item)} - shortcut={ - 0}> - - - } - > -
- {(icon) => } - {item.title} -
-
- ) - }} + + {(key) => ( + + {(item) => { + const keybind = createMemo(() => { + const bind = item().keybind + + return bind ? command.keybindParts(bind) : [] + }) + + return ( + (chosen = item())} + shortcut={ + 0}> + + + } + > +
+ {(icon) => } + {item().title} +
+
+ ) + }} +
+ )}
@@ -561,14 +573,21 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () = /** The + menu items as rows, then the session's recently closed tabs, in the new tab view. */ function Launchers(props: { - items: readonly SessionPanelMenuItem[] + items: readonly SessionPanelMenuEntry[] onChoose: () => void closed: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void } }): JSX.Element { + const keys = createMemo(() => props.items.map((entry) => entry.key), [], { equals: same }) + const byKey = createMemo(() => new Map(props.items.map((entry) => [entry.key, entry.item]))) + return (
- {(item) => } + + {(key) => ( + {(item) => } + )} +
diff --git a/packages/app/src/runtime/server/api.ts b/packages/app/src/runtime/server/api.ts index 5da5f219b344..249607d33c09 100644 --- a/packages/app/src/runtime/server/api.ts +++ b/packages/app/src/runtime/server/api.ts @@ -1,10 +1,4 @@ -import { - OpenCode, - type MessageListInput, - type OpenCodeClient, - type SessionInboxListInput, - type SessionMessageGetInput, -} from "@opencode/client/promise" +import { OpenCode, withMediaRefs, type MediaRequestOptions } from "@opencode/client/promise" import type { ServerConnection } from "@/runtime/server/registry" import { decode64 } from "@/runtime/persistence/base64" @@ -25,12 +19,8 @@ export function authFromToken(token: string | null) { } } -export const MEDIA_URI_CAPABILITY = "experimental-media-uri" - -export const CAPABILITIES_HEADER = "X-OpenCode-Capabilities" - /** `mediaRefs: false` reads stored media as inline bytes, for callers such as export that need self-contained messages. */ -export type ServerRequestOptions = OpenCode.RequestOptions & { readonly mediaRefs?: false } +export type ServerRequestOptions = MediaRequestOptions export function createApiForServer(input: { server: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }) { return withMediaRefs( @@ -48,42 +38,6 @@ export function createApiForServer(input: { server: ServerConnection.HttpBase; f ) } -/** - * Only the message and inbox reads can return stored media as `media:` refs, so only they carry the capability. - * A custom header makes a cross-origin request preflighted, which every other request must keep avoiding. - */ -function withMediaRefs(api: OpenCodeClient) { - return { - ...api, - message: { - ...api.message, - list: (input: MessageListInput, options?: ServerRequestOptions) => - api.message.list(input, mediaRefOptions(options)), - }, - session: { - ...api.session, - message: { - ...api.session.message, - get: (input: SessionMessageGetInput, options?: ServerRequestOptions) => - api.session.message.get(input, mediaRefOptions(options)), - }, - inbox: { - ...api.session.inbox, - list: (input: SessionInboxListInput, options?: ServerRequestOptions) => - api.session.inbox.list(input, mediaRefOptions(options)), - }, - }, - } -} - -function mediaRefOptions(options: ServerRequestOptions | undefined): OpenCode.RequestOptions | undefined { - if (options?.mediaRefs === false) return options - const headers = new Headers(options?.headers) - headers.set(CAPABILITIES_HEADER, MEDIA_URI_CAPABILITY) - - return { ...options, headers } -} - /** * The server's ranged read of one file, for media elements. They cannot send the Authorization header, and a credential * never goes in a DOM URL, so a server that needs a password has none: its media downloads through the API instead. diff --git a/packages/app/src/runtime/server/client.test.ts b/packages/app/src/runtime/server/client.test.ts index 1089ce7e21c8..6312ba577bc4 100644 --- a/packages/app/src/runtime/server/client.test.ts +++ b/packages/app/src/runtime/server/client.test.ts @@ -86,9 +86,10 @@ describe("server event stream", () => { }) }) -test("rotates HTTP and PTY clients together", async () => { +test("rotates the HTTP client with the endpoint", async () => { const requests: Array<{ url: string; authorization: string | null }> = [] + // SAFETY: the transport only calls fetch with a request and init; `preconnect` and other statics are never read. const fetch = (async (input: string | URL | Request, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init) requests.push({ url: request.url, authorization: request.headers.get("authorization") }) @@ -106,8 +107,6 @@ test("rotates HTTP and PTY clients together", async () => { fetch, }) - const initialPty = transport.pty - await transport.api.server.info() const replacement = transport.update({ @@ -118,7 +117,6 @@ test("rotates HTTP and PTY clients together", async () => { await transport.api.server.info() expect(replacement).toBe(transport.api) - expect(transport.pty).not.toBe(initialPty) expect(transport.url).toBe("http://127.0.0.1:4200") expect(requests).toEqual([ { diff --git a/packages/app/src/runtime/server/client.tsx b/packages/app/src/runtime/server/client.tsx index a54a947f2a28..aa3a29fdaec9 100644 --- a/packages/app/src/runtime/server/client.tsx +++ b/packages/app/src/runtime/server/client.tsx @@ -1,5 +1,5 @@ import type { OpenCodeEvent } from "@opencode/client/promise" -import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode/client/solid" +import { createClientConnection, type ClientConnectionStatus } from "@opencode/client/solid" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { type Accessor, createEffect, on, onCleanup } from "solid-js" import { createApiForServer, type ServerApi } from "@/runtime/server/api" @@ -68,7 +68,6 @@ type ServerSDKBase = { http: ServerConnection.HttpBase api: ServerApi media: MediaCache - pty: ReturnType connection: { status: Accessor attempt: Accessor @@ -136,9 +135,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS return transport.api }, media, - get pty() { - return transport.pty - }, connection, event: events.event, } @@ -163,9 +159,9 @@ function sameEndpoint(a: ServerConnection.HttpBase, b: ServerConnection.HttpBase return a.url === b.url && a.password === b.password } -/** A server's HTTP and PTY clients, rebuilt when its endpoint changes. */ +/** A server's HTTP client, rebuilt when its endpoint changes. */ export interface ServerTransport { - /** Rebuilds the clients for a new endpoint and returns the new API client. */ + /** Rebuilds the client for a new endpoint and returns it. */ update(http: ServerConnection.HttpBase): ServerApi /** The current base URL. */ readonly url: string @@ -173,8 +169,6 @@ export interface ServerTransport { readonly http: ServerConnection.HttpBase /** The current API client. */ readonly api: ServerApi - /** The current PTY client. */ - readonly pty: ReturnType } export function createServerTransport(input: { @@ -184,12 +178,11 @@ export function createServerTransport(input: { const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch }) // Keeps the endpoint as plain values: a plugin's endpoint can be a store proxy that already reads the next password, - // and the clients built here keep the one they started with. + // and the client built here keeps the one it started with. const build = (input: ServerConnection.HttpBase) => { const http = { url: input.url, password: input.password } - const api = createApiForServer({ server: http, fetch: queue.fetch }) - return { http, api, pty: createPtyClient(api, { url: http.url }) } + return { http, api: createApiForServer({ server: http, fetch: queue.fetch }) } } const state = { current: build(input.http) } @@ -210,9 +203,6 @@ export function createServerTransport(input: { get api() { return state.current.api }, - get pty() { - return state.current.pty - }, } } diff --git a/packages/app/src/runtime/server/current.tsx b/packages/app/src/runtime/server/current.tsx index 93a9ca805463..18c254b9e28d 100644 --- a/packages/app/src/runtime/server/current.tsx +++ b/packages/app/src/runtime/server/current.tsx @@ -14,7 +14,9 @@ const context = createSimpleContext({ return { conn, key, - isLocal: ServerConnection.local(props.conn), + get isLocal() { + return props.ctx.isLocal + }, ctx: props.ctx, get health() { return global.servers.health[key] diff --git a/packages/app/src/runtime/server/data.test.ts b/packages/app/src/runtime/server/data.test.ts index 48e6a47a8e57..47b8d07998dd 100644 --- a/packages/app/src/runtime/server/data.test.ts +++ b/packages/app/src/runtime/server/data.test.ts @@ -2,7 +2,14 @@ import { expect, test } from "bun:test" import type { SessionInfo } from "@opencode/client/promise" import { createSessionMutations } from "./data" -const session = { id: "ses_test" } as SessionInfo +const session: SessionInfo = { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + location: { directory: "/project" }, +} test("keeps a removed session hidden after its deletion event, for lists cached before it", async () => { const release = Promise.withResolvers() @@ -38,3 +45,12 @@ test("rolls back a failed removal", async () => { await expect(request).rejects.toThrow("offline") expect(mutation.apply([session])).toEqual([session]) }) + +test("treats SessionNotFoundError on removal as already deleted", async () => { + const mutation = createSessionMutations(async () => { + throw JSON.parse('{"_tag":"SessionNotFoundError","sessionID":"ses_test","message":"Session not found: ses_test"}') + }) + + await expect(mutation.remove(session.id)).resolves.toBeUndefined() + expect(mutation.apply([session])).toEqual([]) +}) diff --git a/packages/app/src/runtime/server/data.ts b/packages/app/src/runtime/server/data.ts index 5c9739a9eb37..e7a62be24bd5 100644 --- a/packages/app/src/runtime/server/data.ts +++ b/packages/app/src/runtime/server/data.ts @@ -1,11 +1,13 @@ import type { Data } from "@opencode/client/solid" -import type { OpenCodeEvent, SessionInfo } from "@opencode/client/promise" +import { isSessionNotFoundError, type OpenCodeEvent, type SessionInfo } from "@opencode/client/promise" import { onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string } +const REMOVED_SESSION_LIMIT = 512 + // The events on which the server advances a session's `time.updated` to the event's time. const SESSION_ACTIVITY = [ "session.inbox.enqueued", @@ -27,8 +29,13 @@ const SESSION_ACTIVITY = [ export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise }) { const mutation = createSessionMutations(input.remove) - const [activity, setActivity] = createStore>({}) - onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID))) + const [activity, setActivity] = createStore>({}) + onCleanup( + input.data.on("session.deleted", (event) => { + setActivity(event.data.sessionID, undefined) + mutation.deleted(event.data.sessionID) + }), + ) // Lists loaded earlier (the Home index, the live store, search results) learn each session's newer // `time.updated` from its events, so they rank sessions in the server's order without reloading. @@ -53,7 +60,11 @@ export function createDesktopData(input: { data: Data; remove: (sessionID: strin ...input.data.session, list: () => apply(input.data.session.list()), apply, - remove: mutation.remove, + remove: (sessionID: string) => + mutation.remove(sessionID).then(() => { + setActivity(sessionID, undefined) + input.data.session.forget(sessionID) + }), }, } } @@ -65,6 +76,9 @@ export function createSessionMutations(remove: (sessionID: string) => Promise current.filter((mutation) => mutation.id !== id)) } + const append = (current: readonly SessionMutation[], next: SessionMutation) => + [...current.filter((mutation) => mutation.sessionID !== next.sessionID), next].slice(-REMOVED_SESSION_LIMIT) + return { apply(sessions: readonly SessionInfo[]) { const removed = new Set( @@ -75,21 +89,20 @@ export function createSessionMutations(remove: (sessionID: string) => Promise [...current, mutation]) + setStore("session", (current) => append(current, mutation)) return Promise.resolve() .then(() => remove(sessionID)) .catch((error) => { + if (isSessionNotFoundError(error)) return + clear(mutation.id) throw error }) }, // Keep the deleted session hidden: cached lists such as the Home index still hold it until they refetch. deleted(sessionID: string) { - setStore("session", (current) => [ - ...current.filter((mutation) => mutation.sessionID !== sessionID), - { id: uuid(), type: "remove", sessionID }, - ]) + setStore("session", (current) => append(current, { id: uuid(), type: "remove", sessionID })) }, } } diff --git a/packages/app/src/runtime/server/global-sync/utils.test.ts b/packages/app/src/runtime/server/global-sync/utils.test.ts index 2a39fce84f0c..7849a3de9608 100644 --- a/packages/app/src/runtime/server/global-sync/utils.test.ts +++ b/packages/app/src/runtime/server/global-sync/utils.test.ts @@ -4,7 +4,7 @@ import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProvid describe("normalizeAgentList", () => { test("adapts current agents to the app agent shape", () => { - const result = normalizeAgentList([ + const agents: AgentListOutput["data"] = [ { id: "build", name: "Build", @@ -16,7 +16,9 @@ describe("normalizeAgentList", () => { system: "Build software", permissions: [{ action: "read", resource: "*", effect: "allow" }], }, - ] as AgentListOutput["data"]) + ] + + const result = normalizeAgentList(agents) expect(result).toEqual([ { @@ -39,38 +41,44 @@ describe("normalizeAgentList", () => { }) describe("normalizeProviderList", () => { - test("groups current models into the app provider catalog", () => { - const result = normalizeProviderList( - [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], - [ - { - id: "gpt-5", - modelID: "gpt-5", - providerID: "openai", - name: "GPT-5", - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - variants: [{ id: "high" }], - time: { released: 1 }, - cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], - status: "active", - enabled: true, - limit: { context: 128_000, output: 8_192 }, - }, - { - id: "gpt-old", - modelID: "gpt-old", - providerID: "openai", - name: "GPT Old", - capabilities: { tools: false, input: ["text"], output: ["text"] }, - variants: [], - time: { released: 0 }, - cost: [], - status: "deprecated", - enabled: true, - limit: { context: 1, output: 1 }, - }, - ] as ModelListOutput["data"], - ) + test.each([ + [0, ""], + [Date.UTC(2026, 8, 1), "2026-09-01"], + ])("groups models with release time %p into the app catalog", (released, release_date) => { + const providers: ProviderListOutput["data"] = [ + { id: "openai", name: "OpenAI", activation: "auto", package: "@ai-sdk/openai" }, + ] + + const models: ModelListOutput["data"] = [ + { + id: "gpt-5", + modelID: "gpt-5", + providerID: "openai", + name: "GPT-5", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + { + id: "gpt-old", + modelID: "gpt-old", + providerID: "openai", + name: "GPT Old", + capabilities: { tools: false, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "deprecated", + enabled: true, + limit: { context: 1, output: 1 }, + }, + ] + + const result = normalizeProviderList(providers, models) expect(result.connected).toEqual(["openai"]) expect(result.default).toEqual({ openai: "gpt-5" }) @@ -78,19 +86,59 @@ describe("normalizeProviderList", () => { expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ id: "gpt-5", providerID: "openai", - capabilities: { toolcall: true, attachment: true }, + release_date, + capabilities: { toolcall: true, attachment: true, reasoning: true }, cost: { input: 1, output: 2 }, variants: { high: {} }, }) }) + + test.each([ + ["effort variant", [{ id: "low" }], undefined, true], + ["toggle variant", [{ id: "none" }, { id: "thinking" }], undefined, true], + ["reasoningField without variants", [], { reasoningField: "reasoning_content" }, true], + ["no variants or reasoningField", [], undefined, false], + ["disabled-only none variant", [{ id: "none" }], undefined, false], + ] as const)("derives reasoning capability from %s", (_, variants, compatibility, expected) => { + const providers: ProviderListOutput["data"] = [ + { id: "opencode", name: "OpenCode", activation: "auto", package: "@opencode/ai/providers/openai-compatible" }, + ] + + const models: ModelListOutput["data"] = [ + { + id: "model", + modelID: "model", + providerID: "opencode", + name: "Model", + compatibility, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [...variants], + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + ] + + const result = normalizeProviderList(providers, models) + + expect(result.all.get("opencode")?.models.model?.capabilities.reasoning).toBe(expected) + }) }) describe("normalizeProjectInfo", () => { test("keeps the project VCS backend", () => { - const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] } - expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git") - expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg") - expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined() + const project: Project = { + id: "prj", + canonical: "/repo", + time: { created: 1, updated: 1, active: 1 }, + sandboxes: [], + } + + expect(normalizeProjectInfo({ ...project, vcs: "git" }).vcs).toBe("git") + expect(normalizeProjectInfo({ ...project, vcs: "hg" }).vcs).toBe("hg") + expect(normalizeProjectInfo(project).vcs).toBeUndefined() }) }) diff --git a/packages/app/src/runtime/server/global-sync/utils.ts b/packages/app/src/runtime/server/global-sync/utils.ts index af36458c609d..b6ad6d66a2cf 100644 --- a/packages/app/src/runtime/server/global-sync/utils.ts +++ b/packages/app/src/runtime/server/global-sync/utils.ts @@ -10,17 +10,22 @@ const providerCatalogs = new WeakMap< WeakMap >() +function isLegacyAgentList(input: AgentListOutput["data"] | Agent[]): input is Agent[] { + return input.every((agent) => !("request" in agent)) +} + export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { - if (input.every((agent) => !("request" in agent))) return input as Agent[] + if (isLegacyAgentList(input)) return input - return (input as AgentListOutput["data"]).map((agent) => ({ + return input.map((agent) => ({ name: agent.id, description: agent.description, mode: agent.mode, hidden: agent.hidden, - temperature: - typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, - topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + temperature: Number.isFinite(agent.request.settings.temperature) + ? Number(agent.request.settings.temperature) + : undefined, + topP: Number.isFinite(agent.request.settings.topP) ? Number(agent.request.settings.topP) : undefined, color: agent.color, permission: agent.permissions.map((rule) => ({ permission: rule.action, @@ -79,7 +84,11 @@ export function normalizeProviderList( family: model.family, capabilities: { temperature: false, - reasoning: false, + // Always-on reasoning models may expose no selectable variants yet still stream reasoning via reasoningField; + // a disabled-only `none` variant does not count as reasoning support. + reasoning: + model.compatibility?.reasoningField !== undefined || + model.variants.some((variant) => variant.id !== "none"), attachment: model.capabilities.input.some((item) => item !== "text"), toolcall: model.capabilities.tools, input: { @@ -110,7 +119,7 @@ export function normalizeProviderList( status: model.status, options: model.settings ?? {}, headers: model.headers ?? {}, - release_date: new Date(model.time.released).toISOString().slice(0, 10), + release_date: model.time.released === 0 ? "" : new Date(model.time.released).toISOString().slice(0, 10), variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), } } diff --git a/packages/app/src/runtime/server/media.ts b/packages/app/src/runtime/server/media.ts index 7dddc1de67fd..e654003fa3fd 100644 --- a/packages/app/src/runtime/server/media.ts +++ b/packages/app/src/runtime/server/media.ts @@ -1,13 +1,6 @@ -import type { OpenCodeClient } from "@opencode/client/promise" +import { mediaHashFromUri, type OpenCodeClient } from "@opencode/client/promise" -const MEDIA_REF = /^media:([0-9a-f]{64})$/ - -/** The blob hash in a `media:` ref, the only form the server sends stored media in. */ -export function mediaHashFromUri(uri: string | undefined): string | undefined { - if (!uri) return undefined - - return MEDIA_REF.exec(uri)?.[1] -} +export { mediaHashFromUri } export type MediaCache = { peek(uri: string, mime?: string): string | undefined diff --git a/packages/app/src/runtime/server/registry.test.ts b/packages/app/src/runtime/server/registry.test.ts index 5f42989b7268..00d11271dc86 100644 --- a/packages/app/src/runtime/server/registry.test.ts +++ b/packages/app/src/runtime/server/registry.test.ts @@ -1,5 +1,12 @@ import { expect, test } from "bun:test" -import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection, serverLabel } from "./registry" +import { + canRemoveServer, + createServerProjects, + resolveServerList, + resolveVisibleServers, + ServerConnection, + serverLabel, +} from "./registry" import { Schema } from "effect" import { ServerState } from "./persistence" import { createStore } from "solid-js/store" @@ -52,24 +59,36 @@ test("startup auth_token credentials override a persisted same-url server; witho expect(kept[0]?.type === "http" ? kept[0].authToken : true).toBeUndefined() }) -test("treats WSL sidecars as remote server connections", () => { +test("treats WSL and mismatched-port loopback servers as remote connections", () => { + const windowsSidecar = "http://127.0.0.1:28894" + expect( - ServerConnection.local({ - type: "plugin", - key: "wsl:Debian", - plugin: "wsl", - state: "ready", - connecting: false, - authenticationRequired: false, - managed: false, - http: { url: "http://127.0.0.1:4097" }, - }), + ServerConnection.local( + { + type: "plugin", + key: "wsl:Debian", + plugin: "wsl", + state: "ready", + connecting: false, + authenticationRequired: false, + managed: false, + http: { url: "http://127.0.0.1:4097" }, + }, + windowsSidecar, + ), ).toBe(false) - expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe( - true, + expect( + ServerConnection.local({ type: "sidecar", variant: "base", http: { url: windowsSidecar } }), + ).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:28894" } }, windowsSidecar)).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "http://[::1]:28894" } }, windowsSidecar)).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } }, windowsSidecar)).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "http://127.0.0.1:4096" } }, windowsSidecar)).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:49374" } }, windowsSidecar)).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } }, windowsSidecar)).toBe( + false, ) - expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true) - expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false) }) test("keeps exact persisted server identities and prevents removing provided servers", () => { @@ -87,6 +106,21 @@ test("keeps exact persisted server identities and prevents removing provided ser expect(canRemoveServer({ key, stored, provided: [{ type: "http", http: { url: key } }] })).toBe(false) }) +test("hidden servers are left out, but never every server: the built-in one, otherwise the first, stays shown", () => { + const sidecar: ServerConnection.Sidecar = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } + const remote: ServerConnection.Http = { type: "http", http: { url: "https://apollo.example.test" } } + const other: ServerConnection.Http = { type: "http", http: { url: "https://zeus.example.test" } } + + const hide = (...servers: ServerConnection.Any[]) => + Object.fromEntries(servers.map((server) => [ServerConnection.key(server), true])) + + expect(resolveVisibleServers({ servers: [sidecar, remote], hidden: {} })).toEqual([sidecar, remote]) + expect(resolveVisibleServers({ servers: [sidecar, remote], hidden: hide(sidecar) })).toEqual([remote]) + expect(resolveVisibleServers({ servers: [remote, sidecar], hidden: hide(remote, sidecar) })).toEqual([sidecar]) + expect(resolveVisibleServers({ servers: [remote, other], hidden: hide(remote, other) })).toEqual([remote]) + expect(resolveVisibleServers({ servers: [], hidden: hide(remote) })).toEqual([]) +}) + /** The server props a project store follows, which the test changes. */ type ProjectScopeProps = { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index 8bbd24e45d49..1255b36a9d0e 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -38,10 +38,13 @@ export function serverLabel(conn: ServerConnection.Any, current?: { hostname?: s return serverName(conn) } -function isLocalHost(url: string) { - const host = url.replace(/^https?:\/\//, "").split(":")[0] +function loopbackOrigin(url: string) { + if (!URL.canParse(url)) return + const parsed = new URL(url) - if (host === "localhost" || host === "127.0.0.1") return "local" + if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") return + + return `${parsed.protocol}//127.0.0.1:${parsed.port || (parsed.protocol === "https:" ? "443" : "80")}` } // Project data comes from the server; this keeps which projects this client has used and the last one. @@ -130,6 +133,21 @@ export function canRemoveServer(input: { return input.stored.some((server) => server.http.url === input.key) } +/** + * The servers Home lists. Home needs at least one, so when every server is flagged hidden (stored by an older + * build, or edited by hand) the built-in server, otherwise the first, is shown anyway. + */ +export function resolveVisibleServers(input: { + servers: Array + hidden: StoredState["hidden"] +}): Array { + const visible = input.servers.filter((conn) => !input.hidden[ServerConnection.key(conn)]) + + if (visible.length > 0 || input.servers.length === 0) return visible + + return [input.servers.find(ServerConnection.builtin) ?? input.servers[0]] +} + export namespace ServerConnection { type Base = { displayName?: string; label?: string } @@ -192,8 +210,18 @@ export namespace ServerConnection { return true } - export const local = (conn?: Any) => - !!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local")) + // The desktop sidecar, or a loopback http connection on the same port as the app's own server. Other loopback + // ports may belong to a WSL server, tunnel, or container with a separate filesystem. + export const local = (conn?: Any, ownUrl?: string) => { + if (!conn) return false + + if (builtin(conn)) return true + + if (conn.type !== "http" || !ownUrl) return false + const origin = loopbackOrigin(conn.http.url) + + return !!origin && origin === loopbackOrigin(ownUrl) + } } export const { use: useServers, provider: ServersProvider } = createSimpleContext({ @@ -216,7 +244,16 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex return resolveServerList({ stored: store.list, props: props.servers }) }) - const visibleServers = createMemo(() => allServers().filter((conn) => !store.hidden[ServerConnection.key(conn)])) + const visibleServers = createMemo(() => resolveVisibleServers({ servers: allServers(), hidden: store.hidden })) + + // Clears the hidden flag of any server shown despite it, so the stored flags say what Home shows. + function unhideShown(servers: Array) { + for (const conn of resolveVisibleServers({ servers, hidden: store.hidden })) { + const key = ServerConnection.key(conn) + + if (store.hidden[key]) setStore("hidden", key, false) + } + } function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) @@ -241,9 +278,18 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex const list = store.list.filter((x) => x.http.url !== key) batch(() => { setStore("list", list) + // Removing the only shown server must not leave the rest all hidden. + unhideShown(resolveServerList({ stored: list, props: props.servers })) }) } + function setHidden(key: ServerConnection.Key, hidden: boolean) { + // Home always lists at least one server, so hiding the only shown one does nothing. + if (hidden && visibleServers().every((conn) => ServerConnection.key(conn) === key)) return + + setStore("hidden", key, hidden) + } + function canRemove(key: ServerConnection.Key) { return canRemoveServer({ key, provided: props.servers, stored: store.list }) } @@ -265,6 +311,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex const current = (conn: ServerConnection.Any) => ServerConnection.builtin(conn) || ServerConnection.key(conn) === props.canonicalLocalServer + const ownUrl = () => props.servers?.find(ServerConnection.builtin)?.http.url ?? props.canonicalLocalServer + const local = (conn?: ServerConnection.Any) => ServerConnection.local(conn, ownUrl()) + const label = (conn: ServerConnection.Any) => serverLabel( conn, @@ -284,16 +333,16 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex // state wait on this, but the provider must not block first render on storage. hydrated, isHidden(key: ServerConnection.Key) { - return store.hidden[key] ?? false - }, - setHidden(key: ServerConnection.Key, hidden: boolean) { - setStore("hidden", key, hidden) + return !!store.hidden[key] && !visibleServers().some((conn) => ServerConnection.key(conn) === key) }, + setHidden, add, remove, canRemove, scope, current, + ownUrl, + local, /** The name switchers, menus and titles show. */ label, /** The name a list row shows next to its item: nothing for the current server or when it is the only one. */ diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 4624a878374b..aab15bff34f6 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -70,7 +70,13 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( const serverCtx = createRoot((dispose) => { serverCtxDisposers.set(key, dispose) - return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator) + return createServerController( + conn, + server.scope(key), + () => server.local(conn), + server.projects.forServer(key), + notificationCoordinator, + ) }, owner) serverCtxs.set(key, serverCtx) @@ -185,6 +191,7 @@ function createGlobalModels() { function createServerController( conn: ServerConnection.Any, scope: ServerScope, + isLocal: Accessor, projects: ReturnType, notificationCoordinator: ReturnType, ) { @@ -321,14 +328,13 @@ function createServerController( }) } - const isLocal = - (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) - return { data, sdk, sync, - isLocal, + get isLocal() { + return isLocal() + }, projects: { list, unique, @@ -367,12 +373,6 @@ export function useServerCtx(server: Accessor) export type ServerCtx = ReturnType -function isLocalHost(url: string) { - const host = url.replace(/^https?:\/\//, "").split(":")[0] - - if (host === "localhost" || host === "127.0.0.1") return "local" -} - /** What an HTTP server's controller authenticates with; other servers keep one controller per id. */ function credentials(conn: ServerConnection.Any) { return conn.type === "http" ? `${conn.http.url}\n${conn.http.password ?? ""}` : "" diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 680cb6d67035..2d953398c3f3 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -83,8 +83,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const inSide = actions.region.focus // ⌘T and ⌘W inside the side region open and close its tabs, unless the focused panel binds the key itself there. const sideKey = (event: KeyboardEvent) => inSide(event) && !pluginClaims(event) - // ⌘W on the side panel's last tab closes the panel and moves focus out of it, where one press too many would close - // the session tab. Until the user moves on, by clicking, focusing, another key, or another session, ⌘W closes nothing. + // ⌘W on the side panel's last tab, or on a panel with nothing left to close, closes the panel and moves focus out of + // it, where one press too many would close the session tab. Until the user moves on, by clicking, focusing, another + // key, or another session, ⌘W closes nothing. // The close moves focus synchronously, before this is set, so its own focusin does not clear it. const [emptied, setEmptied] = createSignal(false) const resetEmptied = () => setEmptied(false) @@ -235,11 +236,23 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return pane === "side" || pane === "split" ? pane : undefined } + // With nothing left to close, ⌘W closes the side panel itself, as an editor closes the focused pane before the window. const closeTab = () => { - if (actions.region.closeNewTab(focusedPane())) return + if (actions.region.closeNewTab(focusedPane())) { + const panel = document.getElementById("review-panel") + + // A launcher row that held focus is gone; focus stays in the panel so closing it next returns focus. Closing the + // strip's last New tab closes the panel too, which already gave focus back. + if (panel && actions.session.layout.view().side.opened() && !panel.contains(document.activeElement)) + panel.focus({ preventScroll: true, focusVisible: false }) + + return + } + const tab = closableTab() - if (tab) actions.region.close(tab) + if (tab) return actions.region.close(tab) + actions.region.toggle() } const openSideTab = () => actions.region.openNewTab(focusedPane()) diff --git a/packages/app/src/session/composer/controller.ts b/packages/app/src/session/composer/controller.ts index 3160b8ee3a20..8b6a460a1238 100644 --- a/packages/app/src/session/composer/controller.ts +++ b/packages/app/src/session/composer/controller.ts @@ -62,8 +62,14 @@ export function createSessionComposerController(input: { if (!editor?.isConnected || !region.showComposer() || region.child() || dialog.active) return const active = document.activeElement - // A control chosen while the session was loading owns focus now. - if (active && active !== document.body && active !== editor && !active.matches("[data-titlebar-tab-link]")) + // A control chosen while the session was loading owns focus now. The tab link that opened the session + // passes focus on, unless it sits in a dialog, such as the mobile tabs drawer reopened before the composer. + if ( + active && + active !== document.body && + active !== editor && + (!active.matches("[data-titlebar-tab-link]") || active.closest('[role="dialog"]')) + ) return editor.focus({ preventScroll: true }) setCursorPosition(editor, adapter.state.cursor() ?? promptLength(adapter.state.current())) diff --git a/packages/app/src/session/composer/queue-panel.tsx b/packages/app/src/session/composer/queue-panel.tsx index 70471efc504d..7ab76db8d2c2 100644 --- a/packages/app/src/session/composer/queue-panel.tsx +++ b/packages/app/src/session/composer/queue-panel.tsx @@ -197,7 +197,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu data-component={leaving() ? undefined : "session-queue-row"} data-leaving={leaving() || undefined} aria-busy={leaving() ? undefined : entry().pending} - class="group/queue-row mb-px flex h-8 shrink-0 items-center justify-between gap-2 rounded-md py-1 ps-0.5 pe-2" + class="group/queue-row mb-px flex h-8 shrink-0 items-center justify-between gap-2 rounded-md py-1 ps-0.5 pe-1" classList={{ "bg-v2-overlay-simple-overlay-hover": editing(), "opacity-60": sortable.isDragSource(), @@ -241,7 +241,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
void props.queue.steer(props.id)} > {props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")} @@ -274,7 +274,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu } disabled={props.queue.busy()} @@ -288,7 +288,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu } disabled={props.queue.busy()} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index b4ea7cb5e9f5..a9302e32fc14 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -20,7 +20,7 @@ import { showToast } from "@/shell/notifications/toast" import { errorMessage } from "@/shell/layout/helpers" import { useLocal } from "@/providers/models/selection" import { usePlatform } from "@/runtime/platform/platform" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" import { contextItemKey } from "@/composer/schema" import type { QuoteSource } from "@/composer/quote-source" import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" @@ -173,6 +173,24 @@ export function createActiveSessionRegion(input: { const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) const active = activeElement() + // As in chat apps, page keys in the composer page the conversation, even when the draft itself overflows. An open + // suggestion popup has already claimed them, and modified page keys keep their native meaning. + if ( + promptRef && + active === promptRef && + (event.key === "PageUp" || event.key === "PageDown") && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.isComposing + ) { + event.preventDefault() + input.timeline.actions.page(event.key === "PageUp" ? -1 : 1) + + return + } + if ( path.some((item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null) || editable(target) || diff --git a/packages/app/src/session/header/fit.ts b/packages/app/src/session/header/fit.ts index c14cec878291..e8b9fc0beaa6 100644 --- a/packages/app/src/session/header/fit.ts +++ b/packages/app/src/session/header/fit.ts @@ -1,16 +1,19 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" -/** Fits the location names before allowing the session title to truncate. */ +/** Fits the location names, then the actions' details, before allowing the session title to truncate. */ export function createSessionHeaderFit() { - const [store, setStore] = createStore({ collapse: 0 }) + const [store, setStore] = createStore({ collapse: 0, compact: false }) let root!: HTMLDivElement + // Compact actions drop their details, so their last expanded width is what expanding them again takes back. + let expandedActions = 0 onMount(() => { const fit = () => { const location = root.querySelector(".session-header-location") const title = root.querySelector(".session-header-title") const measure = root.querySelector(".session-header-title-measure > span") + const actions = root.querySelector(".session-header-actions") const trigger = root.querySelector('[data-slot="session-location-trigger"]') const project = trigger?.querySelector('[data-slot="session-location-project"]') const worktree = trigger?.querySelector('[data-slot="session-location-worktree"]') @@ -56,16 +59,25 @@ export function createSessionHeaderFit() { Math.max(0, children.length - 1) * Number.parseFloat(getComputedStyle(title).columnGap), ) - // Everything outside these two sections is reserved for actions, separators and their spacing. - const available = location.getBoundingClientRect().width + title.getBoundingClientRect().width - titleWidth - setStore( - "collapse", - Math.min(expanded, limit) <= available - ? 0 - : worktree && Math.min(expanded - worktreeWidth, limit) <= available - ? 1 - : 2, - ) + const actionsWidth = actions?.getBoundingClientRect().width ?? 0 + + if (!store.compact) expandedActions = actionsWidth + + // Everything outside these two sections is reserved for actions, separators and their spacing. Candidates are + // fitted against expanded actions, so compact actions expand again once the title fits beside them. + const available = + location.getBoundingClientRect().width + + title.getBoundingClientRect().width - + titleWidth - + (store.compact ? Math.max(0, expandedActions - actionsWidth) : 0) + + const fits = (width: number) => Math.min(width, limit) <= available + + const collapse = fits(expanded) ? 0 : worktree && fits(expanded - worktreeWidth) ? 1 : 2 + setStore({ + collapse, + compact: !!actions && collapse === 2 && !fits(expanded - worktreeWidth - projectWidth), + }) } const resize = new ResizeObserver(fit) @@ -96,5 +108,9 @@ export function createSessionHeaderFit() { get collapse() { return store.collapse }, + /** The title does not fit even with the location collapsed, so the actions show without their details. */ + get compact() { + return store.compact + }, } } diff --git a/packages/app/src/session/question-switch.stories.tsx b/packages/app/src/session/question-switch.stories.tsx index 760523a33f6a..4fd21095177f 100644 --- a/packages/app/src/session/question-switch.stories.tsx +++ b/packages/app/src/session/question-switch.stories.tsx @@ -167,3 +167,161 @@ export const LongList = { /> ), } + +const longMobileOptions = [ + { + value: "opt-1", + label: "1. Incremental schema migration with dual-read verification (Recommended)", + description: + "Backfill every existing session row in bounded batches while reading from both tables and logging any mismatch before cutover.", + }, + { + value: "opt-2", + label: "2. Blue-green SQLite snapshot swap on daemon restart", + description: + "Build a fresh projected database beside the live file during idle windows and atomically swap file descriptors once parity holds.", + }, + { + value: "opt-3", + label: "3. Lazy per-session hydration on first transcript open", + description: + "Leave historical sessions untouched at startup and upgrade each transcript inside a single write transaction when opened.", + }, + { + value: "opt-4", + label: "4. Shadow journal replay from durable event log", + description: + "Replay ordered session aggregate events into a new read-model table in the background without blocking active prompt drains.", + }, + { + value: "opt-5", + label: "5. Read-only archive boundary for legacy schema versions", + description: + "Keep older transcripts viewable through a frozen decoder while writing all newly admitted prompts exclusively to the v4 tables.", + }, + { + value: "opt-6", + label: "6. Checkpoint compaction before running structural DDL", + description: + "Truncate the write-ahead log, vacuum dead pages, and verify foreign-key integrity prior to adding the new composite indexes.", + }, + { + value: "opt-7", + label: "7. Partitioned attachment metadata with external blob refs", + description: + "Move inline base64 tool outputs into content-addressed files on disk so mobile queries never scan oversized row payloads.", + }, + { + value: "opt-8", + label: "8. Staged rollout gated by workspace recovery marker", + description: + "Enable the new projection only after all claimed top-level sessions finish draining cleanly without pending inbox items.", + }, + { + value: "opt-9", + label: "9. Deterministic fixture verification before live migration", + description: + "Clone the workspace header and sample fifty recent conversations into a temporary file to benchmark query plans first.", + }, + { + value: "opt-10", + label: "10. Reversible trigger mirror between v3 and v4 columns", + description: + "Install temporary SQLite triggers that keep old and new columns synchronized so an older binary can still open the workspace.", + }, + { + value: "opt-11", + label: "11. Offline CLI migration subcommand with progress bar", + description: + "Require explicit operator confirmation via opencode db migrate and refuse automatic background writes on startup.", + }, + { + value: "opt-12", + label: "12. Clean slate rebuild of derived read projections only", + description: + "Drop disposable summary caches and rebuild every read projection directly from the canonical event history on boot.", + }, +] + +const tallMobilePrompt = [ + "Before I run the workspace storage migration on this repository, please review the full rollout plan and confirm how failure recovery should behave if the connection drops mid-run.", + "Step 1 inspects the active SQLite write-ahead log, verifies that no other local coordinator currently holds a busy execution claim, and records the starting event sequence number in a durable checkpoint row.", + "Step 2 scans session_inbox for any unconsumed user or synthetic items so steered prompts admitted right before shutdown remain preserved in exact enqueue order without duplicate delivery.", + "Step 3 creates the new normalized message part tables beside the existing transcript projection, keeping every original row intact until row counts, byte checksums, and foreign-key constraints match across all sessions.", + "Step 4 streams large diff and terminal output blobs out of inline JSON columns into content-addressed storage files, preventing multi-megabyte rows from stalling mobile timeline queries over remote tunnels.", + "Step 5 rebuilds the full-text search index and session summary projections in slices of twenty-five conversations per transaction, yielding between slices so interactive HTTP requests and live SSE updates stay responsive.", + "Step 6 validates cursor pagination from both ends of the longest session timeline, checking that sticky tool headers, collapsed reasoning blocks, and pending permission or question docks restore to identical state.", + "Step 7 writes a reversible compatibility view so older CLI processes inspecting the same directory fail gracefully with a clear upgrade message rather than corrupting partially migrated state.", + "Step 8 runs PRAGMA integrity_check and compares aggregate token, cost, and tool-call totals before and after the projection swap to catch any silent truncation in historical metadata.", + "Step 9 retains the pre-migration backup snapshot in the cache directory for seven days so you can roll back with a single command if any downstream script or plugin depends on the legacy layout.", + "Step 10 revalidates workspace permissions, active MCP server tool bindings, and pending form or elicitation state so an in-flight agent session resumes cleanly without re-prompting for completed answers.", + "Step 11 checks disk free space on both the database volume and temporary directory before committing the final rename, ensuring low-storage mobile hosts and containers never wedge mid-checkpoint.", + "Step 12 emits a structured migration summary event with elapsed wall time, migrated row counts, reclaimed page bytes, and fallback instructions for operators monitoring remote headless servers.", + "If a power loss, SIGKILL, or mobile network timeout interrupts any of these steps before the final checkpoint commits, how should the server handle the unfinished migration when it starts back up?", +].join(" ") + +// Twelve verbose options followed by a question prompt taller than a phone viewport. +export const LongPrompt = { + render: () => ( + + ), +} diff --git a/packages/app/src/session/requests/session-question-dock-classic.tsx b/packages/app/src/session/requests/session-question-dock-classic.tsx index f7379ad3deff..4d90c980c5e3 100644 --- a/packages/app/src/session/requests/session-question-dock-classic.tsx +++ b/packages/app/src/session/requests/session-question-dock-classic.tsx @@ -47,22 +47,57 @@ function Option(props: { description?: string number: number disabled: boolean - ref?: (el: HTMLButtonElement) => void + ref?: (el: HTMLDivElement) => void onFocus?: VoidFunction onClick: VoidFunction }) { + let pointer: { x: number; y: number } | undefined + return ( - +
) } @@ -122,10 +157,11 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit onCleanup(observeReducedMotion(() => setStore("reducedMotion", prefersReducedMotion()))) let root: HTMLDivElement | undefined + let detailsRef: HTMLDivElement | undefined let optionsRef: HTMLDivElement | undefined let customRef: HTMLButtonElement | undefined let customInput: HTMLInputElement | undefined - let optsRef: HTMLButtonElement[] = [] + let optsRef: HTMLDivElement[] = [] let replied = false let focusFrame: number | undefined @@ -197,7 +233,10 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit const dock = root.closest('[data-component="session-composer-dock"]') // In the app this row can have zero height and overflow upward; the containing panel bounds the question. const column = bounds(dock?.parentElement) - const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight) + const parent = dock?.parentElement?.getBoundingClientRect() + const edge = parent && parent.width > 0 ? parent.bottom : Infinity + const view = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : Infinity + const bottom = Math.min(window.innerHeight, view, column?.bottom ?? Infinity, edge) const around = dock instanceof HTMLElement @@ -232,7 +271,14 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit focusFrame = requestAnimationFrame(() => { focusFrame = undefined const el = next === options().length ? customRef : optsRef[next] - el?.focus() + el?.focus({ preventScroll: true }) + const row = el?.closest('[data-slot="question-option"]') + + if (!row || !optionsRef) return + optionsRef.scrollTop = Math.min( + row.offsetTop, + Math.max(optionsRef.scrollTop, row.offsetTop + row.offsetHeight - optionsRef.clientHeight), + ) }) } @@ -247,9 +293,14 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit }) } - update() + measure() makeEventListener(window, "resize", update) + if (window.visualViewport) { + makeEventListener(window.visualViewport, "resize", update) + makeEventListener(window.visualViewport, "scroll", update) + } + const dock = root?.closest('[data-component="session-composer-dock"]') const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') createResizeObserver([dock, scroller], update) @@ -527,7 +578,12 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit setStore("tab", tab) setStore("editing", false) - if (optionsRef) setStore("optionsHeight", optionsRef.scrollHeight) + if (detailsRef) detailsRef.scrollTop = 0 + + if (optionsRef) { + optionsRef.scrollTop = 0 + setStore("optionsHeight", optionsRef.scrollHeight) + } if (!store.minimized) focus(pickFocus(tab)) } @@ -547,6 +603,8 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit const minimize = () => { if (sending()) return + + if (optionsRef) setStore("optionsHeight", optionsRef.offsetHeight) setStore("editing", false) setStore("minimized", true) } @@ -606,7 +664,11 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit />
-
+
(detailsRef = el)} + >
0.1 }} style={{ - "max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`, + "max-height": hidden() > 0 ? `${Math.max(0, store.optionsHeight * (1 - hidden()))}px` : undefined, opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`, visibility: optionsOff() ? "hidden" : "visible", }} diff --git a/packages/app/src/session/requests/session-question-dock-experimental.tsx b/packages/app/src/session/requests/session-question-dock-experimental.tsx index 31118f2374ea..7949c6873a92 100644 --- a/packages/app/src/session/requests/session-question-dock-experimental.tsx +++ b/packages/app/src/session/requests/session-question-dock-experimental.tsx @@ -194,23 +194,47 @@ function Option(props: { shortcut?: string disabled: boolean tabbable: boolean - ref?: (el: HTMLButtonElement) => void + ref?: (el: HTMLDivElement) => void onFocus?: VoidFunction onClick: VoidFunction }) { + let pointer: { x: number; y: number } | undefined + return ( - +
) } @@ -270,7 +294,7 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS let customRow: HTMLDivElement | undefined let highlightRef: HTMLSpanElement | undefined let customInput: HTMLInputElement | undefined - let optsRef: HTMLButtonElement[] = [] + let optsRef: HTMLDivElement[] = [] let replied = false const question = createMemo(() => questions()[store.tab]) @@ -348,10 +372,12 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS // grows past it and springs back. const limit = { max: Infinity, chrome: 0 } const cap = () => limit.max - limit.chrome + const detailsCap = () => Math.max(48, (limit.max - 120) * 0.45) + const shownDetails = (details = content.details) => Math.min(details, detailsCap()) // The room the answers box has now, while the question text springs to its height, and once it is there. - const room = () => cap() - boxes.details.value - const settled = (details = content.details) => cap() - details - const scroll = { top: 0, view: 0 } + const room = () => Math.max(0, cap() - boxes.details.value) + const settled = (details = content.details) => Math.max(0, cap() - shownDetails(details)) + const scroll = { details: 0, top: 0, view: 0 } const measure = () => { if (!root) return @@ -371,7 +397,10 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS // The dock sits at the foot of the column it shares with the timeline, in the app inside a zero-height row that it // overflows upward. Grown past the column it spills out of it, so the column bounds it, not the dock's own edge. const column = bounds(dock?.parentElement) - const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight) + const parent = dock?.parentElement?.getBoundingClientRect() + const edge = parent && parent.width > 0 ? parent.bottom : Infinity + const view = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : Infinity + const bottom = Math.min(window.innerHeight, view, column?.bottom ?? Infinity, edge) // Whatever the dock holds besides the question, above it or below, takes from the same height. const around = @@ -388,6 +417,8 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS limit.chrome = root.offsetHeight - optionsRef.offsetHeight - detailsFrame.offsetHeight root.style.setProperty("--question-prompt-max-height", `${limit.max}px`) + if (content.details > 0) boxes.details.snap(shownDetails()) + if (content.list > 0) boxes.list.snap(Math.min(content.list, settled())) paint() } @@ -452,6 +483,11 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS makeEventListener(window, "resize", requestMeasure) + if (window.visualViewport) { + makeEventListener(window.visualViewport, "resize", requestMeasure) + makeEventListener(window.visualViewport, "scroll", requestMeasure) + } + const dock = root?.closest('[data-component="session-composer-dock"]') const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') createResizeObserver([dock, scroller], requestMeasure) @@ -485,12 +521,17 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS // The answers box opens only as far as the room the new question text leaves it at rest. ;(["details", "list"] as const).forEach((key) => { const spring = boxes[key] - const size = key === "list" ? Math.min(content.list, settled()) : content.details + const size = key === "list" ? Math.min(content.list, settled()) : shownDetails() if (spring.target === size) return - if (fresh[key] || !questionMotion()) spring.snap(size) - else tune(spring, resize()).set(size) + if (fresh[key] || !questionMotion()) { + spring.snap(size) + + return + } + + tune(spring, resize()).set(size) }) wake() }) @@ -795,7 +836,7 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS ...track(live, -dir * reach, moving, options, v.launch()), tab: store.tab, // The answers box scrolls back to the top for the new question, so the copy keeps its place on screen. - els: [copy(detailsRef, detailsFrame), copy(listRef, optionsTrack, -scroll.top)], + els: [copy(detailsRef, detailsFrame, -scroll.details), copy(listRef, optionsTrack, -scroll.top)], details: content.details, list: content.list, scroll: scroll.top, @@ -828,9 +869,10 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS const sized = questionMotion() if (detailsFrame && content.details > 0) { - const shown = boxes.details.value + const shown = Math.min(boxes.details.value, detailsCap()) detailsFrame.style.height = sized ? `${shown.toFixed(2)}px` : "" - mask(detailsFrame, 0, Math.max(content.details, ghost?.details ?? 0) - shown, shown, sliding) + const above = store.minimized ? 0 : scroll.details + mask(detailsFrame, above, Math.max(content.details, ghost?.details ?? 0) - above - shown, shown, sliding) } if (optionsRef && content.list > 0) { @@ -981,6 +1023,8 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS }) optsRef.length = options().length + if (detailsFrame) detailsFrame.scrollTop = scroll.details = 0 + if (optionsRef) optionsRef.scrollTop = scroll.top = 0 const reduced = !questionMotion() @@ -1137,7 +1181,24 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS />
-
(detailsFrame = el)}> +
(detailsFrame = el)} + onScroll={(event) => { + scroll.details = event.currentTarget.scrollTop + + if (!detailsFrame) return + const shown = Math.min(boxes.details.value, detailsCap()) + mask( + detailsFrame, + scroll.details, + Math.max(content.details, ghost?.details ?? 0) - scroll.details - shown, + shown, + !!(into || ghost), + ) + }} + >
(detailsRef = el)}>
(optionsRef = el)} data-slot="question-options" + data-scrollable role={multi() ? "group" : "radiogroup"} aria-label={question()?.question} inert={store.minimized} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index d21d60f4f873..f32fb2421f2b 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -314,7 +314,7 @@ function SessionScreenContent(props: { pinned={timeline.view.pinned()} centered={screen.centered()} reserveReviewToggle={!sideVisible()} - setContentRef={timeline.view.setContentRef} + onContentResize={timeline.view.contentResized} bottomInset={timeline.inset()} setVisibleRef={timeline.view.setVisibleRef} anchor={timeline.view.anchor} @@ -322,6 +322,7 @@ function SessionScreenContent(props: { setRevealPart={timeline.view.setRevealPart} reveal={timeline.view.reveal} setScrollToEnd={timeline.view.setScrollToEnd} + setPage={timeline.view.setPage} search={ diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css index 6ee17d8b1e3a..bad966fdb0f5 100644 --- a/packages/app/src/session/session-identity-header.css +++ b/packages/app/src/session/session-identity-header.css @@ -2,6 +2,29 @@ padding-inline-end: 30px; } +/* The location card takes the browser site popover's spacing. Its submenus render elsewhere and keep the menu's. */ +[data-component="menu-v2-content"][data-slot="session-location-card"] { + gap: 2px; + padding: 2px; + + [data-component="menu-v2-item"] { + padding-inline: 8px; + } + + /* A row whose submenu is open keeps the hover background while the pointer is in the submenu. */ + [data-component="menu-v2-item"][data-expanded] { + background: var(--menu-v2-hover); + } + + /* Spans the card edge to edge over its padding; a 1px line scaled to a hairline. */ + [data-slot="menu-v2-separator"] { + width: calc(100% + 4px); + margin: 0 -2px; + background: var(--v2-border-border-base); + transform: scaleY(0.5); + } +} + .session-header-layout { display: flex; align-items: center; diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 13395c0fadea..19b649b6379b 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -67,7 +67,12 @@ export function SessionTitleHeader(props: ParentProps) { } export function SessionHeaderLayout( - props: ParentProps<{ title: string | undefined; location: JSX.Element; actions?: JSX.Element }>, + props: ParentProps<{ + title: string | undefined + location: JSX.Element + /** Receives the fit: `compact` while the title needs the room the actions' details take. */ + actions?: (fit: { readonly compact: boolean }) => JSX.Element + }>, ) { const fit = createSessionHeaderFit() @@ -81,8 +86,7 @@ export function SessionHeaderLayout( {props.children}
- {/* Each read of a JSX prop renders it again, so the actions render once, from the condition's value. */} - {(actions) =>
{actions()}
}
+ {(actions) =>
{actions()(fit)}
}
) @@ -201,7 +205,8 @@ export function SessionLocation(props: { return ( <> - + {/* The shift lines the card's icons and labels up with the trigger's: 2px card + 8px row padding vs the trigger's 6px. */} + {label()}} @@ -316,7 +321,7 @@ export function SessionLocation(props: { - + void copyText(name())}> {language.t("session.location.copyBranch")} @@ -364,23 +369,27 @@ export function SessionLocation(props: { )} - - - + {/* The only server, on this computer, needs no name. */} + 1 || !servers.current(server.conn)}> + + + + {(directory) => ( - - - - {directory()} - - + + {/* Truncates in the middle: the parent folders shrink, the last folder stays whole. */} + + {directory().slice(0, lastSeparator(directory()))} + {directory().slice(lastSeparator(directory()))} + @@ -434,13 +443,17 @@ export function SessionLocation(props: { function SessionLocationDetail(props: ParentProps<{ icon: ComponentProps["name"] }>) { return ( -
+
{props.children}
) } +function lastSeparator(path: string) { + return Math.max(0, path.lastIndexOf("/"), path.lastIndexOf("\\")) +} + export function SessionAncestorTrail(props: { sessionID: string parentID: string diff --git a/packages/app/src/session/session-panel-width.test.ts b/packages/app/src/session/session-panel-width.test.ts index 7510e426258e..938a9064651b 100644 --- a/packages/app/src/session/session-panel-width.test.ts +++ b/packages/app/src/session/session-panel-width.test.ts @@ -11,8 +11,9 @@ test.each([ ["never drops below the chat panel minimum on small windows", 1600, 0, false, false, 450], ["skips clamping before the layout is measured", 1600, undefined, false, false, 1600], ["lets a dragged width leave the side pane its hard minimum, split diffs included", 1600, 1700, true, true, 1380], - ["keeps a dragged width below the default minimum", 320, 700, true, true, 320], - ["never drops a dragged width below the hard chat minimum", 1600, 0, false, true, 320], + ["keeps a dragged width below the default minimum", 420, 900, true, true, 420], + ["never drops a dragged width below the hard chat minimum", 1600, 0, false, true, 400], + ["raises a width dragged below the hard chat minimum before it rose", 320, 1700, false, true, 400], ])("%s", (_name, width, available, split, resized, expected) => { expect(clampSessionPanelWidth({ width, available, split, resized })).toBe(expected) }) diff --git a/packages/app/src/session/session-panel-width.ts b/packages/app/src/session/session-panel-width.ts index 9afe75e9994f..e4c5f3c0ec48 100644 --- a/packages/app/src/session/session-panel-width.ts +++ b/packages/app/src/session/session-panel-width.ts @@ -4,8 +4,9 @@ // minimum for the review pane and let the chat panel take everything else. // Until the user drags the divider, the default width keeps roomier minimums // (wider still for split diffs); a drag can take either side down to the hard -// minimum. -export const SESSION_PANEL_WIDTH_MIN = 320 +// minimum. The chat minimum leaves the session header room for its title beside +// its controls, macOS traffic lights included. +export const SESSION_PANEL_WIDTH_MIN = 400 const SIDE_PANE_WIDTH_MIN = 320 @@ -28,7 +29,8 @@ export function sessionPanelWidthCss(input: { width: number; split: boolean; res const minimum = input.resized ? SESSION_PANEL_WIDTH_MIN : SESSION_PANEL_WIDTH_FIT const pane = input.resized ? SIDE_PANE_WIDTH_MIN : input.split ? REVIEW_PANE_WIDTH_FIT_SPLIT : REVIEW_PANE_WIDTH_FIT - return `min(${input.width}px, max(${minimum}px, 100% - ${pane + 8}px))` + // The outer floor raises a width stored below the minimum, e.g. dragged before it rose. + return `max(${SESSION_PANEL_WIDTH_MIN}px, min(${input.width}px, max(${minimum}px, 100% - ${pane + 8}px)))` } // `available` is undefined until the layout row is first measured; render the @@ -39,7 +41,9 @@ export function clampSessionPanelWidth(input: { split: boolean resized: boolean }) { - if (input.available === undefined) return input.width + const width = Math.max(SESSION_PANEL_WIDTH_MIN, input.width) - return Math.min(input.width, sessionPanelWidthMax({ ...input, available: input.available })) + if (input.available === undefined) return width + + return Math.min(width, sessionPanelWidthMax({ ...input, available: input.available })) } diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index ecda24665300..d1c5d928d5f7 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -23,7 +23,6 @@ export function createSessionTimelineInteraction(session: SessionModel) { inset: number refs: { scroller: HTMLDivElement | undefined - content: HTMLDivElement | undefined dock: HTMLDivElement | undefined visible: HTMLDivElement | undefined } @@ -41,7 +40,6 @@ export function createSessionTimelineInteraction(session: SessionModel) { inset: 0, refs: { scroller: undefined, - content: undefined, dock: undefined, visible: undefined, }, @@ -68,6 +66,8 @@ export function createSessionTimelineInteraction(session: SessionModel) { let scrollToEnd = (_behavior?: ScrollBehavior) => {} + let page = (_direction: 1 | -1) => {} + let scrollMark = 0 let messageMark = 0 let scrollStateFrame: (() => void) | undefined @@ -359,13 +359,6 @@ export function createSessionTimelineInteraction(session: SessionModel) { { defer: true }, ), ) - createResizeObserver( - () => state.refs.content, - () => { - if (scroller) scheduleScrollState(scroller) - fill() - }, - ) createResizeObserver( () => state.refs.dock, (_rect, _element, entry) => { @@ -402,6 +395,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { return { actions: { navigateMessage, + page: (direction: 1 | -1) => page(direction), revealMessage: (id: string, partID?: string) => revealMessage(id, partID), revealPart: (target: string) => revealPart(target), resume, @@ -423,10 +417,9 @@ export function createSessionTimelineInteraction(session: SessionModel) { pinned, selectionInteraction, scheduleScrollState, - setContentRef: (element: HTMLDivElement | undefined) => { - setState("refs", "content", element) - + contentResized: () => { if (scroller) scheduleScrollState(scroller) + fill() }, setDockRef: (element: HTMLDivElement | undefined) => { setState("refs", "dock", element) @@ -451,6 +444,9 @@ export function createSessionTimelineInteraction(session: SessionModel) { setScrollToEnd: (scroll: (behavior?: ScrollBehavior) => void) => { scrollToEnd = scroll }, + setPage: (scroll: (direction: 1 | -1) => void) => { + page = scroll + }, unpin, }, } diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 71e0dc311a73..122e28620a44 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -91,13 +91,14 @@ type MessageTimelineProps = { pinned: boolean centered: boolean reserveReviewToggle: boolean - setContentRef: (el: HTMLDivElement) => void + onContentResize: () => void bottomInset?: number setVisibleRef?: (el: HTMLDivElement | undefined) => void anchor: (id: string) => string setRevealMessage?: (fn: (id: string, partID?: string) => void) => void setRevealPart?: (fn: (target: string) => boolean) => void setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void + setPage?: (fn: (direction: 1 | -1) => void) => void reveal?: { target: () => string | undefined; done: () => void } search?: JSX.Element } @@ -192,7 +193,7 @@ function MessageTimelineView( scroll: () => props.scroll, onResumeScroll: props.onResumeScroll, setScrollRef: props.setScrollRef, - setContentRef: props.setContentRef, + onContentResize: props.onContentResize, bottomInset: () => props.bottomInset ?? 0, setVisibleRef: props.setVisibleRef, onScheduleScrollState: props.onScheduleScrollState, @@ -252,6 +253,7 @@ function MessageTimelineView( setRevealMessage: props.setRevealMessage, setRevealPart: props.setRevealPart, setScrollToEnd: props.setScrollToEnd, + setPage: props.setPage, reveal: props.reveal, }) @@ -546,7 +548,7 @@ function MessageTimelineView( } - actions={ + actions={(fit) => ( {(_id) => ( <> @@ -563,13 +565,16 @@ function MessageTimelineView( get active() { return props.active !== false }, + get compact() { + return fit.compact + }, }} /> )} - } + )} > {(value) => ( diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 4574b78a1f76..201caf3fe4cf 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -75,7 +75,8 @@ type Input = { scroll: Accessor<{ overflow: boolean; jump: boolean }> onResumeScroll: (behavior?: ScrollBehavior) => void setScrollRef: (element: HTMLDivElement | undefined) => void - setContentRef: (element: HTMLDivElement) => void + /** The rows' content changed height. */ + onContentResize: () => void /** Height of the composer region floating over the timeline's bottom edge, where the timeline fades out. */ bottomInset?: Accessor setVisibleRef?: (element: HTMLDivElement | undefined) => void @@ -92,6 +93,8 @@ type Input = { setRevealMessage?: (fn: (id: string, partID?: string) => void) => void setRevealPart?: (fn: (target: string) => boolean) => void setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void + /** Pages the timeline for keys pressed outside it, such as PageUp in the composer. */ + setPage?: (fn: (direction: 1 | -1) => void) => void /** A tool call the route asks this timeline to open on; `done` clears it once revealed. */ reveal?: { target: () => string | undefined; done: () => void } } @@ -552,8 +555,6 @@ export function createTimelineVirtualizer(input: Input) { const root = listRoot() if (root) input.setScrollRef(root) - - if (virtualContent) input.setContentRef(virtualContent) input.setVisibleRef?.(visibleArea()) queueMicrotask(() => { if (!active() || !root?.isConnected) return @@ -602,6 +603,18 @@ export function createTimelineVirtualizer(input: Input) { virtualizer.scrollToOffset(Math.max(root.scrollTop, root.scrollHeight - root.clientHeight * 2)) virtualizer.scrollToEnd({ behavior }) }) + input.setPage?.((direction) => { + const root = listRoot() + + if (!active() || !root?.isConnected) return + prepareNavigation() + input.onUserScroll(root) + + if (direction < 0) input.onUnpin() + // Like the timeline's own PageUp: 80% of a page, measured above the composer that covers its bottom. The scroll + // event then pins at the end or loads older history near the top, as a wheel scroll does. + virtualizer.scrollToOffset(root.scrollTop + direction * (visibleArea()?.clientHeight || root.clientHeight) * 0.8) + }) }) createEffect( @@ -616,6 +629,15 @@ export function createTimelineVirtualizer(input: Input) { ), ) + // The content is as tall as the rows the virtualizer sizes, so it reports that height rather than the session + // observing the element. A ResizeObserver there sees the height a row's own observer commits during its delivery, and + // Chrome reports every such frame as "ResizeObserver loop completed with undelivered notifications." + createEffect( + on([active, () => virtualizer.getTotalSize() - rendering.scrollAdjustment], () => { + if (active()) input.onContentResize() + }), + ) + // Only the viewport carries the inset as a variable, for its fade and scroll padding. The variable does not inherit // (see index.css): the inset changes on every frame while the composer region grows, and an inherited variable would // restyle every rendered row each time. @@ -1016,11 +1038,7 @@ export function createTimelineVirtualizer(input: Input) {
{ - virtualContent = element - - if (active()) input.setContentRef(element) - }} + ref={virtualContent} style={{ height: `${virtualizer.getTotalSize() - rendering.scrollAdjustment}px`, position: "relative", @@ -1082,6 +1100,7 @@ export function createTimelineVirtualizer(input: Input) { input.setRevealMessage?.(() => {}) input.setRevealPart?.(() => false) input.setScrollToEnd?.(() => {}) + input.setPage?.(() => {}) } }) diff --git a/packages/app/src/settings/about/about.tsx b/packages/app/src/settings/about/about.tsx index 01ce931d7640..65b05282de9b 100644 --- a/packages/app/src/settings/about/about.tsx +++ b/packages/app/src/settings/about/about.tsx @@ -1,5 +1,7 @@ -import { createResource } from "solid-js" +import { createEffect, createResource, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { useDialog } from "@opencode/ui/context/dialog" +import { Tooltip } from "@opencode/ui/tooltip" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { ExternalLink } from "@/runtime/platform/external-link" @@ -34,6 +36,9 @@ export function SettingsAbout(props: { active: boolean }) { const language = useLanguage() const platform = usePlatform() const dialog = useDialog() + const [copy, setCopy] = createStore({ pending: false, copied: false, forceCopied: false }) + const version = () => platform.version ?? language.t("settings.about.devVersion") + const versionLabel = () => language.t("settings.about.version", { version: version() }) const [otherContributors] = createResource( () => props.active || undefined, @@ -41,6 +46,34 @@ export function SettingsAbout(props: { active: boolean }) { { initialValue: FALLBACK_OTHER_CONTRIBUTORS }, ) + createEffect(() => { + if (!copy.copied) return + + const close = setTimeout(() => setCopy("forceCopied", false), 2000) + const reset = setTimeout(() => setCopy("copied", false), 2120) + + onCleanup(() => { + clearTimeout(close) + clearTimeout(reset) + }) + }) + + const copyVersion = async () => { + if (copy.pending) return + + setCopy({ pending: true, copied: false, forceCopied: false }) + + await (platform.writeClipboardText?.(versionLabel()) ?? navigator.clipboard.writeText(versionLabel())) + .then(() => setCopy({ copied: true, forceCopied: true })) + .catch(() => + showToast({ + variant: "error", + title: language.t("settings.about.copyVersionFailed"), + }), + ) + .finally(() => setCopy("pending", false)) + } + const credit = (name: string) => ( {name} @@ -72,11 +105,27 @@ export function SettingsAbout(props: { active: boolean }) { return (
-

- {language.t("settings.about.version", { - version: platform.version ?? language.t("settings.about.devVersion"), - })} -

+
+ + + +

{language.t("settings.about.license")}

diff --git a/packages/app/src/settings/general/controllers.ts b/packages/app/src/settings/general/controllers.ts index ccc0cd0960fa..6a27226f47bf 100644 --- a/packages/app/src/settings/general/controllers.ts +++ b/packages/app/src/settings/general/controllers.ts @@ -177,6 +177,12 @@ export function createSoundSettingsController() { (value) => settings.sounds.setPermissionsEnabled(value), (id) => settings.sounds.setPermissions(id), ), + questions: channel( + settings.sounds.questionsEnabled, + settings.sounds.questions, + (value) => settings.sounds.setQuestionsEnabled(value), + (id) => settings.sounds.setQuestions(id), + ), errors: channel( settings.sounds.errorsEnabled, settings.sounds.errors, diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 7d35c9098364..b60849019d30 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -249,6 +249,17 @@ export const SettingsGeneral: Component = () => { void update.catch(() => setPinchZoom(!checked)) } + const [spellcheck, { mutate: setSpellcheck }] = createResource( + () => desktop() && "getSpellcheckEnabled" in platform, + () => platform.getSpellcheckEnabled?.().catch(() => true) ?? true, + { initialValue: true }, + ) + + const onSpellcheckChange = (checked: boolean) => { + setSpellcheck(checked) + void platform.setSpellcheckEnabled?.(checked).catch(() => setSpellcheck(!checked)) + } + const GeneralSection = () => (

{language.t("settings.general.section.general")}

@@ -282,6 +293,14 @@ export const SettingsGeneral: Component = () => {
+ +
+ +
+
diff --git a/packages/app/src/settings/model.test.ts b/packages/app/src/settings/model.test.ts index e1041cdbf790..2d0899926e88 100644 --- a/packages/app/src/settings/model.test.ts +++ b/packages/app/src/settings/model.test.ts @@ -112,12 +112,14 @@ describe("settings schema", () => { keybinds: {}, permissions: { autoApprove: false, autoApproveProjects: {} }, workspaces: { defaultDestination: "last-used", lastUsed: {} }, - notifications: { agent: false, permissions: true, errors: false }, + notifications: { agent: false, permissions: true, questions: true, errors: false }, sounds: { agentEnabled: true, agent: "staplebops-01", permissionsEnabled: true, permissions: "staplebops-02", + questionsEnabled: true, + questions: "staplebops-05", errorsEnabled: true, errors: "nope-03", interfaceEnabled: true, @@ -165,7 +167,7 @@ describe("settings schema", () => { expect(settings.permissions).toEqual({ autoApprove: true, autoApproveProjects: { on: true, off: false } }) expect(settings.workspaces).toEqual({ defaultDestination: "new", lastUsed: { good: "workspace" } }) expect(settings.keybinds).toEqual({ good: "ctrl+k" }) - expect(settings.notifications).toEqual({ agent: false, permissions: true, errors: true }) + expect(settings.notifications).toEqual({ agent: false, permissions: true, questions: true, errors: true }) expect(settings.sounds).toMatchObject({ agent: "custom", agentEnabled: false, permissions: "staplebops-02" }) expect(decode(encode(settings))).toEqual(settings) }) diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index eaac96dbdd64..5e8f8f164c59 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -169,6 +169,7 @@ const workspacesSchema = Persistence.struct({ const notificationsSchema = Persistence.struct({ agent: Schema.Boolean, permissions: Schema.Boolean, + questions: Schema.Boolean, errors: Schema.Boolean, }) @@ -177,6 +178,8 @@ const soundsSchema = Persistence.struct({ agent: Schema.String, permissionsEnabled: Schema.Boolean, permissions: Schema.String, + questionsEnabled: Schema.Boolean, + questions: Schema.String, errorsEnabled: Schema.Boolean, errors: Schema.String, interfaceEnabled: Schema.optional(Schema.Boolean), @@ -310,12 +313,14 @@ export const defaultSettings: Settings = { keybinds: {}, permissions: { autoApprove: false, autoApproveProjects: {} }, workspaces: { defaultDestination: "last-used", lastUsed: {} }, - notifications: { agent: false, permissions: true, errors: false }, + notifications: { agent: false, permissions: true, questions: true, errors: false }, sounds: { agentEnabled: true, agent: "staplebops-01", permissionsEnabled: true, permissions: "staplebops-02", + questionsEnabled: true, + questions: "staplebops-05", errorsEnabled: true, errors: "nope-03", interfaceEnabled: true, @@ -540,6 +545,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setPermissions(value: boolean) { setStore("notifications", "permissions", value) }, + questions: withFallback(() => store.notifications?.questions, defaultSettings.notifications.questions), + setQuestions(value: boolean) { + setStore("notifications", "questions", value) + }, errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors), setErrors(value: boolean) { setStore("notifications", "errors", value) @@ -565,6 +574,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setPermissions(value: string) { setStore("sounds", "permissions", value) }, + questionsEnabled: withFallback(() => store.sounds?.questionsEnabled, defaultSettings.sounds.questionsEnabled), + setQuestionsEnabled(value: boolean) { + setStore("sounds", "questionsEnabled", value) + }, + questions: withFallback(() => store.sounds?.questions, defaultSettings.sounds.questions), + setQuestions(value: string) { + setStore("sounds", "questions", value) + }, errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), setErrorsEnabled(value: boolean) { setStore("sounds", "errorsEnabled", value) diff --git a/packages/app/src/settings/navigation.tsx b/packages/app/src/settings/navigation.tsx index e16d716f8dd9..0f2c32bdaebd 100644 --- a/packages/app/src/settings/navigation.tsx +++ b/packages/app/src/settings/navigation.tsx @@ -116,7 +116,7 @@ export function SettingsNavigation(props: { - + {(group) => (
diff --git a/packages/app/src/settings/notifications/notifications.tsx b/packages/app/src/settings/notifications/notifications.tsx index 66ef8a6a6d33..e0a658bae6bf 100644 --- a/packages/app/src/settings/notifications/notifications.tsx +++ b/packages/app/src/settings/notifications/notifications.tsx @@ -23,6 +23,11 @@ const soundSettings = { title: "settings.general.sounds.permissions.title", description: "settings.general.sounds.permissions.description", }, + questions: { + action: "settings-sounds-questions", + title: "settings.general.sounds.questions.title", + description: "settings.general.sounds.questions.description", + }, errors: { action: "settings-sounds-errors", title: "settings.general.sounds.errors.title", @@ -31,7 +36,7 @@ const soundSettings = { } as const const SoundSetting: Component<{ - kind: "agent" | "permissions" | "errors" + kind: "agent" | "permissions" | "questions" | "errors" channel: SoundSettingsController["agent"] }> = (props) => { const language = useLanguage() @@ -100,6 +105,21 @@ export const SettingsNotifications: Component = () => {
+ +
+ settings.notifications.setQuestions(checked)} + > + {language.t("settings.general.notifications.questions.title")} + +
+
+ { + id === "opencode", key: "dialog.provider.opencode.note" }, { match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" }, { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, + { match: (id: string) => id === "claude-agent", key: "dialog.provider.claudeAgent.note" }, { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, @@ -175,13 +176,13 @@ export const SettingsProviders: Component<{ }) // Connection state comes from the integration list like the TUI: credential - // connections mean an API key or OAuth grant, env connections mean detected + // connections mean an API key or an account login, env connections mean detected // environment variables, and a connectionless integration is config-provided. const source = (item: ProviderItem): ProviderSource | undefined => { const current = integration(item) const credential = current?.connections.find((connection) => connection.type === "credential") - if (credential) return credential.method === "oauth" ? "account" : "api" + if (credential) return credential.method === "key" ? "api" : "account" if (current?.connections.some((connection) => connection.type === "env")) return "env" @@ -451,6 +452,19 @@ export const SettingsProviders: Component<{
} > + + {(status) => ( + + )} +
diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index ae061ee87de0..9f5d783d5694 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -55,6 +55,13 @@ export const clientSettings: Entry[] = [ target: "settings-pinch-zoom", available: "desktop", }, + { + tab: "general", + label: "settings.general.row.spellcheck.title", + target: "settings-spellcheck", + keywords: "spelling spell check dictionary", + available: "desktop", + }, { tab: "general", label: "settings.general.row.mobileTitlebarBottom.title", @@ -172,6 +179,14 @@ export const clientSettings: Entry[] = [ description: "settings.general.notifications.permissions.description", keywords: "desktop notifications permissions", }, + { + tab: "notifications", + label: "settings.general.notifications.questions.title", + target: "settings-notifications-questions", + section: "settings.general.section.notifications", + description: "settings.general.notifications.questions.description", + keywords: "desktop notifications questions mcp elicitation input", + }, { tab: "notifications", label: "settings.general.notifications.errors.title", @@ -196,6 +211,14 @@ export const clientSettings: Entry[] = [ description: "settings.general.sounds.permissions.description", keywords: "sound audio permissions", }, + { + tab: "notifications", + label: "settings.general.sounds.questions.title", + target: "settings-sounds-questions", + section: "settings.general.section.sounds", + description: "settings.general.sounds.questions.description", + keywords: "sound audio questions mcp elicitation input", + }, { tab: "notifications", label: "settings.general.sounds.errors.title", diff --git a/packages/app/src/settings/search.css b/packages/app/src/settings/search.css index bbfae1035aeb..efa563b35e97 100644 --- a/packages/app/src/settings/search.css +++ b/packages/app/src/settings/search.css @@ -4,6 +4,11 @@ min-height: 0; } +/* Only results scroll inside the search; with the nav list below it, the list absorbs a short sidebar. */ +.settings-search[data-empty="true"] { + flex-shrink: 0; +} + .settings-search[data-empty="true"] [data-slot="text-input-v2-icon-button"] { visibility: hidden; } diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 8fffd4c8f5eb..9b3c9633410d 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -85,9 +85,54 @@ flex-direction: column; gap: 16px; overflow-y: auto; + overscroll-behavior: contain; min-height: 0; } +@property --settings-nav-fade-start { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --settings-nav-fade-end { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +/* Each edge fades in once content is hidden behind it. A list that does not overflow has no scroll timeline, so it keeps both lengths at 0. */ +@supports (animation-timeline: scroll()) { + .settings-nav-groups { + mask-image: linear-gradient( + to bottom, + transparent, + #000 var(--settings-nav-fade-start), + #000 calc(100% - var(--settings-nav-fade-end)), + transparent + ); + animation: + settings-nav-fade-start linear both, + settings-nav-fade-end linear both; + animation-timeline: scroll(self), scroll(self); + animation-range: + 0 24px, + calc(100% - 24px) 100%; + } +} + +@keyframes settings-nav-fade-start { + to { + --settings-nav-fade-start: 24px; + } +} + +@keyframes settings-nav-fade-end { + from { + --settings-nav-fade-end: 24px; + } +} + .settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"] { width: 100%; height: 28px; @@ -232,6 +277,55 @@ gap: 14px; } +.settings-about-version-line { + display: flex; + align-items: center; + justify-content: center; +} + +.settings-about-version { + position: relative; + isolation: isolate; + display: inline-flex; + min-height: 24px; + align-items: center; + margin-block: -2px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: color 120ms ease; +} + +.settings-about-version::before { + position: absolute; + z-index: -1; + inset-block: 0; + inset-inline: -6px; + border-radius: 4px; + background: transparent; + content: ""; + transition: background-color 120ms ease; +} + +.settings-about-version:hover, +.settings-about-version:focus-visible { + color: var(--v2-text-text-base); + outline: none; +} + +.settings-about-version:hover::before, +.settings-about-version:focus-visible::before { + background: var(--v2-background-bg-layer-02); +} + +.settings-about-version:disabled { + cursor: default; +} + .settings-about-credits, .settings-about-publication, .settings-about-details { @@ -752,16 +846,22 @@ justify-content: flex-end; } -[data-slot="settings-row-control"] > div:has([data-component="switch"]), +/* The :has() belongs on the control, not on its div. Chromium checks a div:has() subject against every div in the + document and flags each one, then restyles a flagged div whenever an element is inserted anywhere beneath it, which + is every ancestor of a streaming answer. */ +[data-slot="settings-row-control"]:has(> div [data-component="switch"]) > div, [data-slot="settings-row-control"] > [data-component="switch"] { display: inline-flex; align-items: center; padding: 4px; } +[data-component="settings-list"][data-variant="catalog"] + [data-slot="settings-row-control"]:has(> div [data-component="switch"]) + > div, [data-component="settings-list"][data-variant="catalog"] [data-slot="settings-row-control"] - > :is(div:has([data-component="switch"]), [data-component="switch"]) { + > [data-component="switch"] { padding: 0; } diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index c7f68832bc9c..659bc4067a56 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -179,7 +179,7 @@ export function SettingsScreen() { if (!root || event.ctrlKey || !event.deltaY || !(event.target instanceof Element)) return // The page scrolls inside its panel, so a wheel over the gutters or sidebar would otherwise do nothing. - if (event.target.closest(".settings-content") || scrollsNatively(event.target, root, event.deltaY)) return + if (event.target.closest(".settings-content") || insideScroller(event.target, root)) return const panel = root.querySelector(".settings-content > .settings-panel") if (!panel) return @@ -210,11 +210,12 @@ export function SettingsScreen() { ) } -function scrollsNatively(target: Element, root: Element, deltaY: number) { +// An overflowing scroller owns the wheel even at its edges, so reaching the end of the nav list does not scroll the page. +function insideScroller(target: Element, root: Element) { for (let node: Element | null = target; node && node !== root; node = node.parentElement) { if (!/auto|scroll/.test(getComputedStyle(node).overflowY)) continue - if (deltaY < 0 ? node.scrollTop > 0 : node.scrollHeight - node.clientHeight - node.scrollTop > 1) return true + if (node.scrollHeight - node.clientHeight > 1) return true } return false diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index 15da98cb1fd5..e893a21d0926 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -4,7 +4,7 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode/ui/context" import { useLayout, type LayoutRoute } from "@/shell/state/layout" import { useTabs } from "@/shell/tabs/tabs" -import { ServerConnection } from "@/runtime/server/registry" +import { useServers } from "@/runtime/server/registry" import { useCommand } from "@/shell/commands/command" import { createSettingsPages } from "@/runtime/plugin/settings-pages" import { useSettingsServers } from "./servers/inventory" @@ -38,6 +38,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr const layout = useLayout() const command = useCommand() const servers = useSettingsServers() + const registry = useServers() const tabs = useTabs() const location = useLocation<{ @@ -76,7 +77,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr return ( list.find((item) => item.key === key) ?? - list.find((item) => !!item.connection && ServerConnection.local(item.connection)) ?? + list.find((item) => !!item.connection && registry.local(item.connection)) ?? list[0] ) }) diff --git a/packages/app/src/settings/workspaces/project-options.tsx b/packages/app/src/settings/workspaces/project-options.tsx index 6e38025a79b5..a131d8f73ae9 100644 --- a/packages/app/src/settings/workspaces/project-options.tsx +++ b/packages/app/src/settings/workspaces/project-options.tsx @@ -9,6 +9,7 @@ import { ServerConnection } from "@/runtime/server/registry" import type { LocalProject } from "@/shell/state/layout" import { fileManagerApp } from "@/home/projects/file-manager" import { useRevealProject } from "@/home/projects/reveal" +import { pathKey } from "@/workspaces/path-key" export const ProjectOptions: Component<{ server: ServerConnection.Any @@ -17,6 +18,7 @@ export const ProjectOptions: Component<{ onOpenChange?: (open: boolean) => void onEdit?: () => void onRename?: () => void + onAdd?: () => void class?: string size?: "small" | "normal" | "large" }> = (props) => { @@ -28,6 +30,11 @@ export const ProjectOptions: Component<{ const directories = () => [props.project.worktree, ...(props.project.sandboxes ?? [])] + const added = () => + context() + .projects.used() + .some((project) => pathKey(project.worktree) === pathKey(props.project.worktree)) + const hasUnread = () => directories().some((directory) => context().notification.project.hasUnread(directory)) const clearNotifications = () => { @@ -35,14 +42,13 @@ export const ProjectOptions: Component<{ directories().forEach((directory) => notification.project.markViewed(directory)) } + const addProject = () => { + props.onAdd?.() + context().projects.touch(props.project.worktree) + } + return ( - + + + {language.t("home.project.add")} + {(edit) => {language.t("common.edit")}} @@ -60,11 +69,15 @@ export const ProjectOptions: Component<{ {(rename) => {language.t("common.rename")}} - revealProject.reveal(props.server, props.project)}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)} + revealProject.reveal(props.server, props.project)}> + {language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)} + + + + + {language.t("sidebar.project.clearNotifications")} + - - {language.t("sidebar.project.clearNotifications")} - diff --git a/packages/app/src/settings/workspaces/project-row.tsx b/packages/app/src/settings/workspaces/project-row.tsx index e53d58604871..cadffa622dc3 100644 --- a/packages/app/src/settings/workspaces/project-row.tsx +++ b/packages/app/src/settings/workspaces/project-row.tsx @@ -1,4 +1,4 @@ -import { Show } from "solid-js" +import { Show, onMount } from "solid-js" import { createStore } from "solid-js/store" import { InlineInput } from "@opencode/ui/inline-input" import { getFilename } from "@opencode/util/path" @@ -16,18 +16,26 @@ export function SettingsProjectRow(props: { project: LocalProject server: ServerConnection.Any onOpen: (project: LocalProject) => void + onAdd?: () => void + onMount?: (button: HTMLButtonElement) => void }) { const language = useLanguage() const global = useGlobal() - const [store, setStore] = createStore({ + const [store, setStore] = createStore<{ + menu: boolean + editor?: { draft: string; saving: boolean } + }>({ menu: false, - editor: undefined as { draft: string; saving: boolean } | undefined, }) let button: HTMLButtonElement | undefined let input: HTMLInputElement | undefined + onMount(() => { + if (button) props.onMount?.(button) + }) + const openEditor = () => { setStore("editor", { draft: displayName(props.project), saving: false }) requestAnimationFrame(() => { @@ -147,6 +155,7 @@ export function SettingsProjectRow(props: { onOpenChange={(open) => setStore("menu", open)} onEdit={() => props.onOpen(props.project)} onRename={openEditor} + onAdd={props.onAdd} />
diff --git a/packages/app/src/settings/workspaces/projects.tsx b/packages/app/src/settings/workspaces/projects.tsx index 8935345f1e7b..e887da6125c7 100644 --- a/packages/app/src/settings/workspaces/projects.tsx +++ b/packages/app/src/settings/workspaces/projects.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, type Component, type JSX } from "solid-js" +import { Show, createEffect, createMemo, on, type Component, type JSX } from "solid-js" import { Key } from "@solid-primitives/keyed" import { createStore } from "solid-js/store" import { Button } from "@opencode/ui/button" @@ -16,6 +16,7 @@ import { SettingsSearchField } from "../search-field" import { SettingsProjectRow } from "./project-row" import "@/settings/search.css" import "@/settings/settings.css" +import "./project.css" export const SettingsProjects: Component<{ server: ServerConnection.Any @@ -26,21 +27,42 @@ export const SettingsProjects: Component<{ const language = useLanguage() const global = useGlobal() const pickDirectory = useDirectoryPicker() - const [store, setStore] = createStore({ filter: "" }) + const [store, setStore] = createStore({ filter: "", others: false }) const context = createMemo(() => global.ensureServerCtx(props.server)) - const projects = createMemo(() => context().projects.list()) - const searchable = createMemo(() => projects().length > 7) - const filtered = createMemo(() => { - const query = searchable() ? store.filter.trim().toLowerCase() : "" + let focus: string | undefined - return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects() - }) + const projects = createMemo(() => context().projects.unique()) + + const used = createMemo( + () => + new Set( + context() + .projects.used() + .map((project) => project.id), + ), + ) + + const others = createMemo(() => projects().filter((project) => !used().has(project.id))) + const searchable = createMemo(() => projects().length > 7 || others().length > 0) + const query = createMemo(() => (searchable() ? store.filter.trim().toLowerCase() : "")) + + const filtered = createMemo(() => + projects().filter( + (project) => + !query() || [displayName(project), project.worktree].some((value) => value.toLowerCase().includes(query())), + ), + ) + + const added = createMemo(() => filtered().filter((project) => used().has(project.id))) + const otherResults = createMemo(() => filtered().filter((project) => !used().has(project.id))) createEffect(() => { if (!searchable()) setStore("filter", "") }) + createEffect(on(context, () => setStore("others", false))) + const addProject = () => pickDirectory({ server: props.server, @@ -108,9 +130,7 @@ export const SettingsProjects: Component<{ class="settings-project-empty-card flex flex-col items-center gap-2 py-24 text-center" > -
- {language.t("settings.projects.empty.title")} -
+
{language.t("settings.projects.empty.title")}
{language.t("settings.projects.empty.description")}
@@ -121,12 +141,67 @@ export const SettingsProjects: Component<{ } > -
- - {(project) => ( - - )} - +
+ 0}> +
+ + {(project) => ( + { + if (focus !== project().worktree) return + focus = undefined + requestAnimationFrame(() => button.focus()) + }} + /> + )} + +
+
+ 0}> + {language.t("settings.projects.other")} + } + > + + + +
+ + {(project) => ( + (focus = project().worktree)} + /> + )} + +
+
+
diff --git a/packages/app/src/shell/commands/command.test.ts b/packages/app/src/shell/commands/command.test.ts index 5a3dac7fa0a6..72ddf29c6e15 100644 --- a/packages/app/src/shell/commands/command.test.ts +++ b/packages/app/src/shell/commands/command.test.ts @@ -120,6 +120,11 @@ describe("command keybinds", () => { { config: "mod+shift+[", event: { key: "Х", code: "BracketLeft", ctrlKey: true, shiftKey: true }, match: true }, // macOS Option turns L into ¬; the physical key still matches. { config: "meta+alt+l", event: { key: "¬", code: "KeyL", metaKey: true, altKey: true }, match: true }, + // macOS Option turns . into ≥; preserve ASCII layout mappings. + { config: "meta+alt+.", event: { key: "≥", code: "Period", metaKey: true, altKey: true }, match: true }, + { config: "mod+alt+.", event: { key: ".", code: "Period", ctrlKey: true, altKey: true }, match: true }, + { config: "mod+alt+.", event: { key: "ю", code: "Period", ctrlKey: true, altKey: true }, match: true }, + { config: "mod+alt+.", event: { key: "v", code: "Period", ctrlKey: true, altKey: true }, match: false }, ])("matchKeybind $config with $event.key is $match", (row) => { expect(matchKeybind(parseKeybind(row.config), new KeyboardEvent("keydown", row.event))).toBe(row.match) }) diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index 8e4bf2c911a9..0742f4955c92 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -60,7 +60,7 @@ function normalizeKey(key: string) { } export function keyFromKeyboardEvent(event: KeyboardEvent) { - const key = normalizeKey(bracketKey(event)) + const key = normalizeKey(physicalKey(event)) if (!event.altKey || /^[a-z0-9]$/.test(key)) return key @@ -69,13 +69,19 @@ export function keyFromKeyboardEvent(event: KeyboardEvent) { return event.code.slice(3).toLowerCase() } -// ⇧[ types { on US layouts, so ⇧⌘[ binds as written; a layout that types no ASCII there falls back to the physical key. -function bracketKey(event: Pick) { +// ⇧[ types { and ⌥. types ≥ on US layouts. Keep ASCII layout mappings; non-ASCII falls back to the physical key. +function physicalKey(event: Pick) { if (event.key === "{" || event.key === "}") return event.key === "{" ? "[" : "]" - if (!/^[^\x00-\x7f]$/u.test(event.key) || !/^Bracket(Left|Right)$/.test(event.code)) return event.key + if (!/^[^\x00-\x7f]$/u.test(event.key)) return event.key - return event.code === "BracketLeft" ? "[" : "]" + if (event.code === "BracketLeft") return "[" + + if (event.code === "BracketRight") return "]" + + if (event.code === "Period") return "." + + return event.key } function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) { diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index fed41df6dc03..4c4da75ad500 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -8,7 +8,10 @@ import { createStore } from "solid-js/store" import { formatKeybindParts } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useTabs } from "@/shell/tabs/tabs" -import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar" +import { ActivityRing } from "@opencode/ui/activity-ring" +import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" +import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state" +import type { ServerConnection } from "@/runtime/server/registry" import { getRelativeTime } from "@/shell/time" import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette" import { createCommandPaletteSearch, matchesCommandPaletteEntry } from "./search" @@ -157,55 +160,90 @@ function PaletteRow(props: { - -
-
- - - - {(session) => ( - - )} - -
-
- - - - - {(description) => ( - - - - )} - -
-
- - - {getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)} - - + + {(session) => ( + + )} ) } + +function PaletteSessionRow(props: { + item: CommandPaletteEntry + server: ServerConnection.Key + directory: string + sessionID: string + language: ReturnType + sessionOpen: boolean +}) { + const status = useSessionTabAvatarState( + () => props.server, + () => props.sessionID, + () => true, + ) + + return ( + <> +
+
+ + + {/* A busy session shows its activity ring instead of the unread dot, as session tabs do. */} + +
+
+ + + + + {(description) => ( + + + + )} + +
+
+ {/* The project avatar stays put; a working session shows its activity ring where its age would be. */} + + {(updated) => ( + + {getRelativeTime(new Date(updated()).toISOString(), props.language.t)} + + )} + + } + > + + {props.language.t("session.timeline.working")} + + + + + ) +} diff --git a/packages/app/src/shell/commands/search.ts b/packages/app/src/shell/commands/search.ts index a3766b3082c3..c1c31909b6ff 100644 --- a/packages/app/src/shell/commands/search.ts +++ b/packages/app/src/shell/commands/search.ts @@ -28,9 +28,9 @@ export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: st ) } -const typeOrder = { session: 0, command: 1, setting: 2, file: 3 } +const typeOrder = { command: 0, session: 1, setting: 2, file: 3 } -/** Search results: sessions lead, those open as tabs first and then the newest, then commands, settings and files. */ +/** Search results: commands lead, then sessions (those open as tabs first, then the newest), settings and files. */ export function rankCommandPaletteEntries( entries: CommandPaletteEntry[], open: (entry: CommandPaletteEntry) => boolean, diff --git a/packages/app/src/shell/layout/project-avatar-state.ts b/packages/app/src/shell/layout/project-avatar-state.ts index 52d2b017e863..64fb792bedf9 100644 --- a/packages/app/src/shell/layout/project-avatar-state.ts +++ b/packages/app/src/shell/layout/project-avatar-state.ts @@ -7,8 +7,9 @@ import { projectSettingsKey, useSettings } from "@/settings/model" type ServerData = ReturnType["ensureServerCtx"]>["data"] +// Without a server (no session in view) the session reads as idle and read. export function useSessionTabAvatarState( - server: Accessor, + server: Accessor, sessionId: Accessor, root?: Accessor, snapshot?: Accessor, diff --git a/packages/app/src/shell/layout/session-tab-avatar.tsx b/packages/app/src/shell/layout/session-tab-avatar.tsx index 8e00136a4c95..1178ed8d6265 100644 --- a/packages/app/src/shell/layout/session-tab-avatar.tsx +++ b/packages/app/src/shell/layout/session-tab-avatar.tsx @@ -1,6 +1,4 @@ import type { LocalProject } from "@/shell/state/layout" -import type { ServerConnection } from "@/runtime/server/registry" -import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state" import { displayName, getProjectAvatarGenerated, @@ -8,51 +6,17 @@ import { getProjectAvatarVariant, ProjectAvatar, } from "@opencode/ui/project-avatar" -import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" -import { Show } from "solid-js" - -export function SessionTabAvatar(props: { - project?: LocalProject - directory: string - sessionId: string - server: ServerConnection.Key -}) { - const state = useSessionTabAvatarState( - () => props.server, - () => props.sessionId, - () => true, - ) +// The project avatar never changes while a session works: callers show `ActivityRing` at the row's trailing edge, as +// session tabs do. +export function SessionTabAvatarView(props: { project?: LocalProject; directory: string; unread: boolean }) { return ( - ) } - -export function SessionTabAvatarView(props: { - project?: LocalProject - directory: string - unread: boolean - loading?: boolean -}) { - return ( - - } - > - - - ) -} diff --git a/packages/app/src/shell/notifications/coordinator.ts b/packages/app/src/shell/notifications/coordinator.ts index 24e6d42df56b..5449356c0e9d 100644 --- a/packages/app/src/shell/notifications/coordinator.ts +++ b/packages/app/src/shell/notifications/coordinator.ts @@ -1,13 +1,20 @@ import { onCleanup } from "solid-js" +import { Predicate } from "effect" + +type FocusLock = { pending: boolean; release?: VoidFunction } const FOCUS_LOCK = "opencode:notification-focus" +const DELIVERY_LOCK = "opencode:notification-delivery" + const MAX_CLAIMED = 500 export function createNotificationCoordinator() { const locks = typeof navigator === "undefined" ? undefined : navigator.locks + const claimed = new Set() - const focus = { pending: false, release: undefined as (() => void) | undefined } + + const focus: FocusLock = { pending: false } const updateFocus = () => { if (typeof document === "undefined" || !document.hasFocus()) { @@ -47,7 +54,7 @@ export function createNotificationCoordinator() { }) } - const once = async (kind: "sound" | "system", eventID: string, run: () => Promise | void) => { + const once = async (kind: "sound" | "system", eventID: string, run: () => Promise | void) => { const key = `${kind}:${eventID}` const execute = async () => { @@ -60,18 +67,21 @@ export function createNotificationCoordinator() { } return { - sound(eventID: string, run: () => Promise | void) { + sound(eventID: string, run: () => Promise | void) { return once("sound", eventID, run) }, - system(eventID: string, run: () => Promise | void) { + system(eventID: string, run: () => Promise | void) { return once("system", eventID, async () => { if (typeof document !== "undefined" && document.hasFocus()) return if (!locks) return run() - await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => { - if (!lock) return - await run() - }) + // Another alert holding the focus lock must not look like a focused app window. + await locks.request(DELIVERY_LOCK, () => + locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => { + if (!lock) return + await run() + }), + ) }) }, } @@ -84,7 +94,7 @@ function claim(kind: "sound" | "system", eventID: string, claimed: Set) try { const storageKey = `opencode:notification-${kind}` const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]") - const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] + const events = Array.isArray(value) ? value.filter(Predicate.isString) : [] if (events.includes(eventID)) { claimed.add(eventID) diff --git a/packages/app/src/shell/notifications/notification.tsx b/packages/app/src/shell/notifications/notification.tsx index 78a4f3e6829f..7804b3ed9cf6 100644 --- a/packages/app/src/shell/notifications/notification.tsx +++ b/packages/app/src/shell/notifications/notification.tsx @@ -1,12 +1,12 @@ import { createEffect, createMemo, onCleanup, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Schema } from "effect" -import type { SessionInfo } from "@opencode/client/promise" +import { isSessionNotFoundError, type SessionInfo } from "@opencode/client/promise" import type { ServerSDK } from "@/runtime/server/client" import type { Data } from "@opencode/client/solid" import { usePlatform } from "@/runtime/platform/platform" import { useLanguage } from "@/runtime/i18n/language" -import { useSettings } from "@/settings/model" +import { projectSettingsKey, useSettings } from "@/settings/model" import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" import { playSoundById } from "@/shell/notifications/sound" @@ -17,6 +17,13 @@ import { useServer } from "@/runtime/server/current" type NotificationTabs = Pick, "addSessionTab" | "rememberSessionRoute" | "select"> +const requestTitles = { + permission: "notification.permission.title", + question: "notification.question.title", + "mcp-elicitation": "notification.elicitation.title", + "websearch.provider": "notification.permission.title", +} as const + export function openNotificationSession(tabs: NotificationTabs, server: ServerConnection.Key, sessionID: string) { const tab = tabs.addSessionTab({ server, sessionId: sessionID }) @@ -201,7 +208,14 @@ export function createServerNotificationState(input: { viewRetryTimer = undefined viewRetryAttempt = 0 }, - () => { + (error) => { + if (isSessionNotFoundError(error)) { + acknowledged.delete(root) + input.data.session.forget(root) + + return + } + if (acknowledged.get(root) !== idle) return acknowledged.delete(root) @@ -280,7 +294,67 @@ export function createServerNotificationState(input: { }) } + const handleRequest = async (kind: keyof typeof requestTitles, sessionID: string, requestID: string) => { + const session = await lookup(sessionID) + + if (!session || disposed.value) return + + const target = sessionIDHasOpenTab(tabs.store, input.key, sessionID) + ? sessionID + : input.data.session.root(sessionID) + + if (!sessionIDHasOpenTab(tabs.store, input.key, target)) return + + const category = kind === "question" || kind === "mcp-elicitation" ? "questions" : "permissions" + + const pending = () => { + if (disposed.value) return false + + if (kind !== "permission") return input.data.session.form.list(sessionID)?.some((form) => form.id === requestID) + + if (settings.permissions.autoApproves(projectSettingsKey(input.sdk.scope, session.projectID))) return false + + return input.data.session.permission.list(sessionID)?.some((request) => request.id === requestID) + } + + if (!pending()) return + + const key = `${input.key}\0${kind}:${requestID}` + + if (settings.sounds[`${category}Enabled`]()) { + void input.coordinator.sound(key, () => { + if (pending()) return playSoundById(settings.sounds[category]()) + }) + } + + if (!settings.notifications[category]()) return + + void input.coordinator.system(key, () => { + if (!pending()) return + + return platform.notify(language.t(requestTitles[kind]), session.title ?? sessionID, () => + openNotificationSession(tabs, input.key, target), + ) + }) + } + const unsub = input.sdk.event.listen((event) => { + if (event.type === "permission.asked") { + void handleRequest("permission", event.data.sessionID, event.data.id) + + return + } + + if (event.type === "form.created") { + const kind = event.data.form.metadata?.kind + + if (kind === "question" || kind === "mcp-elicitation" || kind === "websearch.provider") { + void handleRequest(kind, event.data.form.sessionID, event.data.form.id) + } + + return + } + if (event.type === "session.execution.failed") { handleSessionError(event.data.sessionID, event.id) diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index efb426706de8..3b7f4249edaf 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -8,7 +8,7 @@ import { usePluginServers } from "@/runtime/plugin/servers" import { createEffect, getOwner, onCleanup, startTransition } from "solid-js" import { useLocation, useNavigate } from "@solidjs/router" import { usePlatform } from "@/runtime/platform/platform" -import { uuid } from "@/runtime/persistence/uuid" +import { uuid } from "@opencode/util/uuid" import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events" import { sessionHref } from "@/shell/routes/session" import { createTabMemory } from "./memory" diff --git a/packages/app/src/shell/titlebar/inbox.tsx b/packages/app/src/shell/titlebar/inbox.tsx index a67e6dbf3843..850ec4f5f211 100644 --- a/packages/app/src/shell/titlebar/inbox.tsx +++ b/packages/app/src/shell/titlebar/inbox.tsx @@ -103,8 +103,10 @@ export function SidebarInbox() { ) } - const rows = (list: InboxEntry[]) => ( - entry.key)}> + // Takes an accessor so only the For tracks the section. Reading it here would recreate the For, remounting every + // row on any session change and resetting the list's scroll. + const rows = (list: () => InboxEntry[]) => ( + entry.key)}> {(key) => ( {(entry) => ( @@ -133,7 +135,7 @@ export function SidebarInbox() { 0}>
- {rows(sections().needs)} + {rows(() => sections().needs)}
0}> - {rows(sections().inbox)} + {rows(() => sections().inbox)}
0}>
- {rows(sections().archived)} + {rows(() => sections().archived)}
@@ -176,7 +178,7 @@ export function SidebarInbox() { />
{/* A collapsed shelf still shows the session that is open. */} - {rows(sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))} + {rows(() => sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))}
diff --git a/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx index f80419c59bdf..7a7891424935 100644 --- a/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx +++ b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx @@ -234,7 +234,7 @@ function RecentlyClosedTabItem(props: { entry: Pick; return ( - + -
- {props.data.path} -
+ {(path) => ( +
+ {/* Break after separators so long paths wrap between folder names, not mid-name. */} + + + {(part) => ( + <> + {part} + + + )} + + +
+ )}
diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 4c1e42116689..37f1c2296bd3 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createResource, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js" +import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" @@ -228,6 +229,7 @@ function SessionTabEntry(props: { pending() || !ctx || props.active || + ctx.data.session.missing(id) || (!session() && !persisted()?.title) || ctx.sdk.connection.status() !== "connected" ) @@ -392,6 +394,22 @@ export function TitlebarTabStrip(props: { const command = useCommand() const vertical = () => props.orientation === "vertical" let listRef!: HTMLDivElement + let scrollRef!: HTMLDivElement + // Each vertical edge feathers by as much as there is left to scroll past it, so it appears and leaves without a jump. + const [fade, setFade] = createStore({ top: 0, bottom: 0 }) + + const measureFade = () => + setFade({ + top: Math.min(16, Math.round(scrollRef.scrollTop)), + bottom: Math.min( + 16, + Math.max(0, Math.round(scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop)), + ), + }) + + onMount(() => { + if (vertical()) createResizeObserver([scrollRef, listRef], measureFade) + }) const [visibility, setVisibility] = createStore>({}) const [unread, setUnread] = createStore>({}) const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) @@ -482,12 +500,21 @@ export function TitlebarTabStrip(props: { classList={{ "min-h-0 overflow-hidden": vertical() }} >
{ + if (vertical()) measureFade() + }} > event.target instanceof Element && - !!event.target.closest('[data-slot="tab-close"], [contenteditable="true"], [contenteditable="plaintext-only"]'), + !!event.target.closest( + '[data-slot="tab-close"], [contenteditable="true"], [contenteditable="plaintext-only"]', + ), }), ]} modifiers={[ diff --git a/packages/app/src/shell/titlebar/titlebar.css b/packages/app/src/shell/titlebar/titlebar.css index 2b77904f3b4b..a47026a8d1eb 100644 --- a/packages/app/src/shell/titlebar/titlebar.css +++ b/packages/app/src/shell/titlebar/titlebar.css @@ -10,11 +10,23 @@ [data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs-scroll"] { min-height: 0; - /* Leave enough space to scroll the final tab clear of the fade above Settings. */ padding-block-end: 16px; - scroll-padding-block-end: 16px; - -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); - mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); + /* Keyboard navigation scrolls a tab clear of the feathered edges, which TitlebarTabStrip sizes from the scroll. */ + scroll-padding-block: 16px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--vertical-tabs-fade-top, 0px), + black calc(100% - var(--vertical-tabs-fade-bottom, 0px)), + transparent + ); + mask-image: linear-gradient( + to bottom, + transparent, + black var(--vertical-tabs-fade-top, 0px), + black calc(100% - var(--vertical-tabs-fade-bottom, 0px)), + transparent + ); } [data-slot="mobile-tabs-trigger"][aria-expanded="true"] { diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 7ac8d8b1685a..96995e467edf 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -28,8 +28,9 @@ import type { ComposerState } from "@/composer/persistence" import "./titlebar.css" import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer" import { sessionTabTitle } from "./tab-title" -import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar" -import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" +import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" +import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state" +import { ActivityRing } from "@opencode/ui/activity-ring" import { RecentlyClosedTabsMenu } from "./recently-closed-tabs-menu" import { useSettingsDialog } from "@/settings/command" import { rootSession } from "@/shell/routes/session" @@ -497,7 +498,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { type="button" data-titlebar-tab-action data-action="vertical-tabs-search" - class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base" + class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint" onClick={() => command.trigger("sessions.search")} > @@ -528,6 +529,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { id: "sessions.search", title: language.t("command.session.search"), category: language.t("command.category.session"), + keybind: "mod+shift+f", onSelect: async () => { const { DialogSessionSearch } = await import("@/shell/commands/session-search") void dialog.show(() => ) @@ -583,6 +585,13 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { const [mobileTabs, setMobileTabs] = createStore({ open: false, settings: false }) + // The current tab's project avatar stays put; while it works, its activity ring shows beside the tab count. + const currentStatus = useSessionTabAvatarState( + () => currentTab()?.server, + () => session()?.id ?? "", + () => true, + ) + const currentProject = createMemo(() => { const tab = currentTab() const value = session() @@ -666,26 +675,18 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { tab().type === "draft" ? ( ) : ( - +
    -
  • - - Go -
  • -
  • - - Go Plus -
  • + + {(item) => ( +
  • + + {item.label} +
  • + )} +
@@ -93,45 +134,35 @@ export function GoPlanChart(props: { href: string }) { {(tick) => }
-
- {/* The Go bar is a linear fraction of the Plus bar so the multiplier reads true on the log scale. */} - - -
-
- - -
+ + {(lane) => ( +
+ + +
+ )} +
)} diff --git a/packages/console/app/src/global.d.ts b/packages/console/app/src/global.d.ts index b34e132b6338..dc6f10c226c0 100644 --- a/packages/console/app/src/global.d.ts +++ b/packages/console/app/src/global.d.ts @@ -1,7 +1 @@ /// - -import "@solidjs/start" - -export declare module "@solidjs/start/server" { - export type APIEvent = { request: Request } -} diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index dd24497490a6..336722b9670c 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -112,7 +112,7 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "ويمكنك أيضًا ربط", "home.faq.a3.p4.localLink": "النماذج المحلية", "home.faq.q4": "هل يمكنني استخدام اشتراكاتي الحالية مع OpenCode؟", - "home.faq.a4.p1": "نعم، يمكنك استخدام اشتراكات ChatGPT Plus/Pro أو GitHub Copilot.", + "home.faq.a4.p1": "نعم، يمكنك استخدام اشتراكات ChatGPT Plus/Pro أو GitHub Copilot، أو اشتراك Claude عبر Claude Code.", "home.faq.q5": "هل يمكنني استخدام OpenCode في الطرفية فقط؟", "home.faq.a5.beforeDesktop": "ليس بعد الآن! OpenCode متاح الآن كتطبيق لـ", "home.faq.a5.desktop": "سطح المكتب", @@ -209,15 +209,26 @@ export const dict = { "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", "go.cta.text": "اشترك في Go", + "go.plans.audience": "خطط Go لـ", + "go.plans.individuals": "الأفراد", + "go.plans.teams": "الفرق", "go.plans.month": "شهريًا", + "go.plans.memberMonth": "لكل عضو\nشهريًا", "go.plans.plus.cta": "الاشتراك في Go Plus", "go.plans.plus.description": "تبلغ تكلفة Go Plus ‏$40/شهر مع حدود استخدام أعلى.", + "go.plans.teams.cta": "الاشتراك في Go Teams", + "go.plans.teams.description": "تبلغ تكلفة Go Teams ‏$20/عضو/شهر مع حدود استخدام لكل عضو.", "go.plans.go.feature1": "نماذج مختارة بأسعار معقولة", "go.plans.go.feature2": "مختبرة للبرمجة باستخدام الوكلاء", "go.plans.go.feature3": "حدود سخية ووصول موثوق", "go.plans.plus.feature1": "يشمل كل مزايا Go", "go.plans.plus.feature2": "حدود أعلى لجلسات برمجة أطول وأكثر تركيزًا", "go.plans.plus.feature3": "للمشاريع الأكبر والأكثر تطلبًا", + "go.plans.teams.feature1": "يحصل كل عضو على حدود Go الخاصة به", + "go.plans.teams.feature2": "حدود شهرية سخية لكل نموذج", + "go.plans.teams.feature3": "فاتورة واحدة لمساحة العمل بالكامل", + "go.plans.teams.feature4": "استخدام إضافي مع ميزانيات لكل عضو", + "go.plans.teams.feature5": "ضوابط الخصوصية والمناطق للنماذج", "go.plans.limits": "الحدود", "go.plans.description": "الطلبات المقدرة لكل 5 ساعات وحدود الاستخدام الشهرية لكل نموذج", "go.plans.legend": "الخطط", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 4f1598bcbada..f5c0d5d8ae00 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -116,7 +116,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Você pode até conectar seus", "home.faq.a3.p4.localLink": "modelos locais", "home.faq.q4": "Posso usar minhas assinaturas de IA existentes com o OpenCode?", - "home.faq.a4.p1": "Sim, você pode usar suas assinaturas ChatGPT Plus/Pro ou GitHub Copilot.", + "home.faq.a4.p1": + "Sim, você pode usar suas assinaturas ChatGPT Plus/Pro ou GitHub Copilot, ou sua assinatura do Claude pelo Claude Code.", "home.faq.q5": "Posso usar o OpenCode apenas no terminal?", "home.faq.a5.beforeDesktop": "Não mais! OpenCode agora está disponível como um app para o seu", "home.faq.a5.desktop": "desktop", @@ -214,15 +215,26 @@ export const dict = { "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", "go.cta.text": "Assinar o Go", + "go.plans.audience": "Planos Go para", + "go.plans.individuals": "Indivíduos", + "go.plans.teams": "Equipes", "go.plans.month": "por mês", + "go.plans.memberMonth": "por membro\npor mês", "go.plans.plus.cta": "Assinar Go Plus", "go.plans.plus.description": "O Go Plus custa $40/mês e oferece limites maiores.", + "go.plans.teams.cta": "Assinar Go Teams", + "go.plans.teams.description": "O Go Teams custa $20/membro/mês com limites para cada membro.", "go.plans.go.feature1": "Modelos selecionados e acessíveis", "go.plans.go.feature2": "Testados para programação com agentes", "go.plans.go.feature3": "Limites generosos e acesso confiável", "go.plans.plus.feature1": "Tudo o que está incluído no Go", "go.plans.plus.feature2": "Limites maiores para sessões de programação mais longas e focadas", "go.plans.plus.feature3": "Para projetos maiores e exigentes", + "go.plans.teams.feature1": "Cada membro recebe seus próprios limites do Go", + "go.plans.teams.feature2": "Limites mensais generosos para cada modelo", + "go.plans.teams.feature3": "Uma única fatura para todo o workspace", + "go.plans.teams.feature4": "Uso extra com orçamentos por membro", + "go.plans.teams.feature5": "Controles de privacidade e região de modelos", "go.plans.limits": "Limites", "go.plans.description": "Solicitações estimadas a cada 5 horas e limites mensais por modelo", "go.plans.legend": "Planos", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 261b4ffc1940..14c4ea971c70 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -115,7 +115,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Du kan endda forbinde dine", "home.faq.a3.p4.localLink": "lokale modeller", "home.faq.q4": "Kan jeg bruge mine eksisterende AI-abonnementer med OpenCode?", - "home.faq.a4.p1": "Ja, du kan bruge dine ChatGPT Plus/Pro- eller GitHub Copilot-abonnementer.", + "home.faq.a4.p1": + "Ja, du kan bruge dine ChatGPT Plus/Pro- eller GitHub Copilot-abonnementer eller dit Claude-abonnement via Claude Code.", "home.faq.q5": "Kan jeg kun bruge OpenCode i terminalen?", "home.faq.a5.beforeDesktop": "Ikke længere! OpenCode er nu tilgængelig som en app til", "home.faq.a5.desktop": "desktop", @@ -213,15 +214,26 @@ export const dict = { "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", "go.cta.text": "Abonner på Go", + "go.plans.audience": "Go-abonnementer til", + "go.plans.individuals": "Enkeltpersoner", + "go.plans.teams": "Teams", "go.plans.month": "om måneden", + "go.plans.memberMonth": "pr. medlem\nom måneden", "go.plans.plus.cta": "Abonnér på Go Plus", "go.plans.plus.description": "Go Plus koster $40/måned og giver højere brugsgrænser.", + "go.plans.teams.cta": "Abonnér på Go Teams", + "go.plans.teams.description": "Go Teams koster $20/medlem/måned med brugsgrænser for hvert medlem.", "go.plans.go.feature1": "Udvalgte, prisvenlige modeller", "go.plans.go.feature2": "Testet til agentbaseret kodning", "go.plans.go.feature3": "Generøse grænser og pålidelig adgang", "go.plans.plus.feature1": "Alt i Go er inkluderet", "go.plans.plus.feature2": "Højere grænser til længere, fokuserede kodningsforløb", "go.plans.plus.feature3": "Til større, krævende projekter", + "go.plans.teams.feature1": "Hvert medlem får sine egne Go-grænser", + "go.plans.teams.feature2": "Generøse månedlige grænser for hver model", + "go.plans.teams.feature3": "Én regning for hele arbejdsområdet", + "go.plans.teams.feature4": "Ekstra forbrug med budgetter pr. medlem", + "go.plans.teams.feature5": "Styring af privatliv og regioner for modeller", "go.plans.limits": "Grænser", "go.plans.description": "Anslåede anmodninger pr. 5 timer og månedlige grænser pr. model", "go.plans.legend": "Abonnementer", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 4d2c1ac8c945..71e02fa796c7 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -115,7 +115,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Du kannst sogar deine", "home.faq.a3.p4.localLink": "lokalen Modelle verbinden", "home.faq.q4": "Kann ich meine bestehenden AI-Abos mit OpenCode nutzen?", - "home.faq.a4.p1": "Ja, du kannst deine ChatGPT Plus/Pro- oder GitHub Copilot-Abos nutzen.", + "home.faq.a4.p1": + "Ja, du kannst deine ChatGPT Plus/Pro- oder GitHub Copilot-Abos sowie dein Claude-Abo über Claude Code nutzen.", "home.faq.q5": "Kann ich OpenCode nur im Terminal nutzen?", "home.faq.a5.beforeDesktop": "Nicht mehr! OpenCode ist jetzt als App für", "home.faq.a5.desktop": "Desktop", @@ -215,15 +216,26 @@ export const dict = { "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", "go.cta.text": "Go abonnieren", + "go.plans.audience": "Go-Tarife für", + "go.plans.individuals": "Einzelpersonen", + "go.plans.teams": "Teams", "go.plans.month": "pro Monat", + "go.plans.memberMonth": "pro Mitglied\nund Monat", "go.plans.plus.cta": "Go Plus abonnieren", "go.plans.plus.description": "Go Plus kostet $40/Monat und bietet höhere Nutzungslimits.", + "go.plans.teams.cta": "Go Teams abonnieren", + "go.plans.teams.description": "Go Teams kostet $20/Mitglied/Monat mit Nutzungslimits für jedes Mitglied.", "go.plans.go.feature1": "Ausgewählte, erschwingliche Modelle", "go.plans.go.feature2": "Für agentisches Programmieren getestet", "go.plans.go.feature3": "Großzügige Limits und zuverlässiger Zugriff", "go.plans.plus.feature1": "Alle Vorteile von Go", "go.plans.plus.feature2": "Höhere Limits für längere, konzentrierte Coding-Sessions", "go.plans.plus.feature3": "Für größere, anspruchsvolle Projekte", + "go.plans.teams.feature1": "Jedes Mitglied erhält eigene Go-Limits", + "go.plans.teams.feature2": "Großzügige monatliche Limits für jedes Modell", + "go.plans.teams.feature3": "Eine Rechnung für den gesamten Workspace", + "go.plans.teams.feature4": "Zusätzliche Nutzung mit Budgets pro Mitglied", + "go.plans.teams.feature5": "Datenschutz- und Regionskontrollen für Modelle", "go.plans.limits": "Limits", "go.plans.description": "Geschätzte Anfragen pro 5 Stunden und monatliche Nutzungslimits je Modell", "go.plans.legend": "Tarife", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index d38b824810b4..03d84bfb4056 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -115,7 +115,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "You can even connect your", "home.faq.a3.p4.localLink": "local models", "home.faq.q4": "Can I use my existing AI subscriptions with OpenCode?", - "home.faq.a4.p1": "Yes, you can use your ChatGPT Plus/Pro or GitHub Copilot subscriptions.", + "home.faq.a4.p1": + "Yes, you can use your ChatGPT Plus/Pro or GitHub Copilot subscriptions, or your Claude subscription through Claude Code.", "home.faq.q5": "Can I only use OpenCode in the terminal?", "home.faq.a5.beforeDesktop": "Not anymore! OpenCode is now available as an app for your", "home.faq.a5.desktop": "desktop", @@ -211,15 +212,26 @@ export const dict = { "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable coding models, so you can build with powerful agents without worrying about cost or availability.", "go.cta.text": "Subscribe to Go", + "go.plans.audience": "Go plans for", + "go.plans.individuals": "Individuals", + "go.plans.teams": "Teams", "go.plans.month": "per month", + "go.plans.memberMonth": "per member\nper month", "go.plans.plus.cta": "Subscribe to Go Plus", "go.plans.plus.description": "Go Plus costs $40/month with higher limits.", + "go.plans.teams.cta": "Subscribe to Go Teams", + "go.plans.teams.description": "Go Teams costs $20/member/month with limits for every member.", "go.plans.go.feature1": "Curated, affordable models", "go.plans.go.feature2": "Tested for agentic coding", "go.plans.go.feature3": "Generous limits and reliable access", "go.plans.plus.feature1": "Everything included in Go", "go.plans.plus.feature2": "Higher limits and fewer interruptions", "go.plans.plus.feature3": "Built for larger, demanding projects", + "go.plans.teams.feature1": "Every member gets their own Go limits", + "go.plans.teams.feature2": "Generous monthly limits for each model", + "go.plans.teams.feature3": "One bill for the whole workspace", + "go.plans.teams.feature4": "Extra usage with per-member budgets", + "go.plans.teams.feature5": "Model privacy and region controls", "go.plans.limits": "Limits", "go.plans.description": "Estimated requests per 5 hours and monthly usage limits by model", "go.plans.legend": "Plans", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 10c77a469e66..6db37ea70ac6 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -116,7 +116,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Incluso puedes conectar tus", "home.faq.a3.p4.localLink": "modelos locales", "home.faq.q4": "¿Puedo usar mis suscripciones de IA existentes con OpenCode?", - "home.faq.a4.p1": "Sí, puedes usar tus suscripciones de ChatGPT Plus/Pro o GitHub Copilot.", + "home.faq.a4.p1": + "Sí, puedes usar tus suscripciones de ChatGPT Plus/Pro o GitHub Copilot, o tu suscripción de Claude a través de Claude Code.", "home.faq.q5": "¿Solo puedo usar OpenCode en la terminal?", "home.faq.a5.beforeDesktop": "¡Ya no! OpenCode ahora está disponible como una aplicación para tu", "home.faq.a5.desktop": "escritorio", @@ -215,15 +216,26 @@ export const dict = { "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", "go.cta.text": "Suscribirse a Go", + "go.plans.audience": "Planes de Go para", + "go.plans.individuals": "Individual", + "go.plans.teams": "Equipos", "go.plans.month": "al mes", + "go.plans.memberMonth": "por miembro\nal mes", "go.plans.plus.cta": "Suscribirse a Go Plus", "go.plans.plus.description": "Go Plus cuesta 40 $/mes y ofrece límites más altos.", + "go.plans.teams.cta": "Suscribirse a Go Teams", + "go.plans.teams.description": "Go Teams cuesta 20 $/miembro/mes con límites para cada miembro.", "go.plans.go.feature1": "Modelos seleccionados y asequibles", "go.plans.go.feature2": "Probados para programación con agentes", "go.plans.go.feature3": "Límites generosos y acceso fiable", "go.plans.plus.feature1": "Todo lo incluido en Go", "go.plans.plus.feature2": "Límites más altos para programar durante más tiempo con concentración", "go.plans.plus.feature3": "Para proyectos más grandes y exigentes", + "go.plans.teams.feature1": "Cada miembro tiene sus propios límites de Go", + "go.plans.teams.feature2": "Límites mensuales generosos para cada modelo", + "go.plans.teams.feature3": "Una sola factura para todo el espacio de trabajo", + "go.plans.teams.feature4": "Uso adicional con presupuestos por miembro", + "go.plans.teams.feature5": "Controles de privacidad y región de modelos", "go.plans.limits": "Límites", "go.plans.description": "Solicitudes estimadas cada 5 horas y límites mensuales por modelo", "go.plans.legend": "Planes", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 10373d3f3133..c21dae9553fd 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -115,7 +115,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Vous pouvez même connecter vos", "home.faq.a3.p4.localLink": "modèles locaux", "home.faq.q4": "Puis-je utiliser mes abonnements IA existants avec OpenCode ?", - "home.faq.a4.p1": "Oui, vous pouvez utiliser vos abonnements ChatGPT Plus/Pro ou GitHub Copilot.", + "home.faq.a4.p1": + "Oui, vous pouvez utiliser vos abonnements ChatGPT Plus/Pro ou GitHub Copilot, ou votre abonnement Claude via Claude Code.", "home.faq.q5": "Puis-je utiliser OpenCode uniquement dans le terminal ?", "home.faq.a5.beforeDesktop": "Plus maintenant ! OpenCode est désormais disponible en application pour", "home.faq.a5.desktop": "desktop", @@ -218,15 +219,26 @@ export const dict = { "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", "go.cta.text": "S'abonner à Go", + "go.plans.audience": "Offres Go pour", + "go.plans.individuals": "Individuels", + "go.plans.teams": "Équipes", "go.plans.month": "par mois", + "go.plans.memberMonth": "par membre\net par mois", "go.plans.plus.cta": "S'abonner à Go Plus", "go.plans.plus.description": "Go Plus coûte 40 $/mois et offre des limites plus élevées.", + "go.plans.teams.cta": "S'abonner à Go Teams", + "go.plans.teams.description": "Go Teams coûte 20 $/membre/mois avec des limites pour chaque membre.", "go.plans.go.feature1": "Modèles sélectionnés et abordables", "go.plans.go.feature2": "Testés pour le codage agentique", "go.plans.go.feature3": "Limites généreuses et accès fiable", "go.plans.plus.feature1": "Tous les avantages de Go", "go.plans.plus.feature2": "Des limites plus élevées pour coder plus longtemps sans perdre le fil", "go.plans.plus.feature3": "Pour les projets plus vastes et exigeants", + "go.plans.teams.feature1": "Chaque membre dispose de ses propres limites Go", + "go.plans.teams.feature2": "Limites mensuelles généreuses pour chaque modèle", + "go.plans.teams.feature3": "Une seule facture pour tout l'espace de travail", + "go.plans.teams.feature4": "Utilisation supplémentaire avec budgets par membre", + "go.plans.teams.feature5": "Contrôles de confidentialité et de région des modèles", "go.plans.limits": "Limites", "go.plans.description": "Requêtes estimées par 5 heures et limites mensuelles par modèle", "go.plans.legend": "Offres", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index d63579eaa65c..0ebc81ef9756 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -114,7 +114,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Puoi anche collegare i tuoi", "home.faq.a3.p4.localLink": "modelli locali", "home.faq.q4": "Posso usare i miei abbonamenti IA esistenti con OpenCode?", - "home.faq.a4.p1": "Sì, puoi usare i tuoi abbonamenti ChatGPT Plus/Pro o GitHub Copilot.", + "home.faq.a4.p1": + "Sì, puoi usare i tuoi abbonamenti ChatGPT Plus/Pro o GitHub Copilot, oppure il tuo abbonamento Claude tramite Claude Code.", "home.faq.q5": "Posso usare OpenCode solo nel terminale?", "home.faq.a5.beforeDesktop": "Non più! OpenCode ora è disponibile come app per", "home.faq.a5.desktop": "desktop", @@ -213,15 +214,26 @@ export const dict = { "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", "go.cta.text": "Abbonati a Go", + "go.plans.audience": "Piani Go per", + "go.plans.individuals": "Singoli", + "go.plans.teams": "Team", "go.plans.month": "al mese", + "go.plans.memberMonth": "per membro\nal mese", "go.plans.plus.cta": "Abbonati a Go Plus", "go.plans.plus.description": "Go Plus costa $40/mese e offre limiti più alti.", + "go.plans.teams.cta": "Abbonati a Go Teams", + "go.plans.teams.description": "Go Teams costa $20/membro/mese con limiti per ogni membro.", "go.plans.go.feature1": "Modelli selezionati e convenienti", "go.plans.go.feature2": "Testati per la programmazione agentica", "go.plans.go.feature3": "Limiti generosi e accesso affidabile", "go.plans.plus.feature1": "Tutto ciò che include Go", "go.plans.plus.feature2": "Limiti più alti per sessioni di coding più lunghe e concentrate", "go.plans.plus.feature3": "Per progetti più grandi e impegnativi", + "go.plans.teams.feature1": "Ogni membro ha i propri limiti Go", + "go.plans.teams.feature2": "Limiti mensili generosi per ogni modello", + "go.plans.teams.feature3": "Un'unica fattura per l'intero workspace", + "go.plans.teams.feature4": "Utilizzo extra con budget per singolo membro", + "go.plans.teams.feature5": "Controlli di privacy e regione per i modelli", "go.plans.limits": "Limiti", "go.plans.description": "Richieste stimate ogni 5 ore e limiti mensili per modello", "go.plans.legend": "Piani", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 2102661d1750..2e9641d84e7f 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -114,7 +114,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "さらに、", "home.faq.a3.p4.localLink": "ローカルモデル", "home.faq.q4": "既存のAIサブスクリプションをOpenCodeで使えますか?", - "home.faq.a4.p1": "はい、ChatGPT Plus/ProまたはGitHub Copilotのサブスクリプションを利用できます。", + "home.faq.a4.p1": + "はい、ChatGPT Plus/ProまたはGitHub Copilotのサブスクリプション、またはClaude Code経由でClaudeのサブスクリプションを利用できます。", "home.faq.q5": "ターミナルだけで使えますか?", "home.faq.a5.beforeDesktop": "もう違います!OpenCodeは今は", "home.faq.a5.desktop": "デスクトップ", @@ -212,15 +213,26 @@ export const dict = { "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", "go.cta.text": "Goを購読する", + "go.plans.audience": "Go プランの対象", + "go.plans.individuals": "個人", + "go.plans.teams": "チーム", "go.plans.month": "/月", + "go.plans.memberMonth": "/メンバー\n/月", "go.plans.plus.cta": "Go Plus に登録", "go.plans.plus.description": "Go Plus は月額$40で、より高い利用枠を提供します。", + "go.plans.teams.cta": "Go Teams に登録", + "go.plans.teams.description": "Go Teams はメンバーあたり月額$20で、各メンバーに利用枠が付与されます。", "go.plans.go.feature1": "厳選された手頃なモデル", "go.plans.go.feature2": "エージェント型コーディングで検証済み", "go.plans.go.feature3": "十分な利用枠と安定したアクセス", "go.plans.plus.feature1": "Go のすべての特典を含む", "go.plans.plus.feature2": "より高い利用枠で、長時間のコーディングに集中", "go.plans.plus.feature3": "大規模で高度なプロジェクト向け", + "go.plans.teams.feature1": "各メンバーに専用の Go 利用枠を付与", + "go.plans.teams.feature2": "すべてのモデルでゆとりある月間利用枠", + "go.plans.teams.feature3": "ワークスペース全体で請求を一本化", + "go.plans.teams.feature4": "メンバーごとの予算上限付きで追加利用に対応", + "go.plans.teams.feature5": "モデルのプライバシーとリージョンの管理", "go.plans.limits": "利用枠", "go.plans.description": "モデル別の5時間あたりの推定リクエスト数と月間利用枠", "go.plans.legend": "プラン", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 59a985fac7f4..a20b954d543a 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -113,7 +113,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "또한", "home.faq.a3.p4.localLink": "로컬 모델", "home.faq.q4": "기존 AI 구독을 OpenCode에서 사용할 수 있나요?", - "home.faq.a4.p1": "네, ChatGPT Plus/Pro 또는 GitHub Copilot 구독을 사용할 수 있습니다.", + "home.faq.a4.p1": + "네, ChatGPT Plus/Pro 또는 GitHub Copilot 구독을 사용할 수 있으며, Claude Code를 통해 Claude 구독도 사용할 수 있습니다.", "home.faq.q5": "OpenCode는 터미널에서만 사용할 수 있나요?", "home.faq.a5.beforeDesktop": "이제 아닙니다! OpenCode는 이제", "home.faq.a5.desktop": "데스크톱", @@ -208,15 +209,26 @@ export const dict = { "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", "go.cta.text": "Go 구독하기", + "go.plans.audience": "Go 요금제 대상", + "go.plans.individuals": "개인", + "go.plans.teams": "팀", "go.plans.month": "/월", + "go.plans.memberMonth": "/멤버\n/월", "go.plans.plus.cta": "Go Plus 구독하기", "go.plans.plus.description": "Go Plus는 월 $40로 더 높은 사용 한도를 제공합니다.", + "go.plans.teams.cta": "Go Teams 구독하기", + "go.plans.teams.description": "Go Teams는 멤버당 월 $20로 모든 멤버에게 개별 사용 한도를 제공합니다.", "go.plans.go.feature1": "엄선된 합리적인 가격의 모델", "go.plans.go.feature2": "에이전트 코딩에 최적화된 테스트", "go.plans.go.feature3": "넉넉한 한도와 안정적인 접근", "go.plans.plus.feature1": "Go의 모든 혜택 포함", "go.plans.plus.feature2": "더 높은 한도로 장시간 코딩에 집중", "go.plans.plus.feature3": "더 크고 복잡한 프로젝트에 적합", + "go.plans.teams.feature1": "모든 멤버에게 개별 Go 한도 제공", + "go.plans.teams.feature2": "모든 모델에 넉넉한 월간 한도 제공", + "go.plans.teams.feature3": "전체 워크스페이스 통합 청구", + "go.plans.teams.feature4": "멤버별 예산 설정이 가능한 추가 사용량", + "go.plans.teams.feature5": "모델 개인정보 보호 및 리전 제어", "go.plans.limits": "사용 한도", "go.plans.description": "모델별 5시간당 예상 요청 횟수 및 월간 사용 한도", "go.plans.legend": "요금제", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 2ebc2f3510a3..f55d62426c28 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -115,7 +115,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Du kan til og med koble til dine", "home.faq.a3.p4.localLink": "lokale modeller", "home.faq.q4": "Kan jeg bruke mine eksisterende AI-abonnementer med OpenCode?", - "home.faq.a4.p1": "Ja, du kan bruke ChatGPT Plus/Pro- eller GitHub Copilot-abonnementene dine.", + "home.faq.a4.p1": + "Ja, du kan bruke ChatGPT Plus/Pro- eller GitHub Copilot-abonnementene dine, eller Claude-abonnementet ditt via Claude Code.", "home.faq.q5": "Kan jeg bare bruke OpenCode i terminalen?", "home.faq.a5.beforeDesktop": "Ikke lenger! OpenCode er nå tilgjengelig som en app for", "home.faq.a5.desktop": "desktop", @@ -213,15 +214,26 @@ export const dict = { "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", "go.cta.text": "Abonner på Go", + "go.plans.audience": "Go-abonnementer for", + "go.plans.individuals": "Enkeltpersoner", + "go.plans.teams": "Team", "go.plans.month": "per måned", + "go.plans.memberMonth": "per medlem\nper måned", "go.plans.plus.cta": "Abonner på Go Plus", "go.plans.plus.description": "Go Plus koster $40/måned og gir høyere bruksgrenser.", + "go.plans.teams.cta": "Abonner på Go Teams", + "go.plans.teams.description": "Go Teams koster $20/medlem/måned med bruksgrenser for hvert medlem.", "go.plans.go.feature1": "Utvalgte, rimelige modeller", "go.plans.go.feature2": "Testet for agentbasert koding", "go.plans.go.feature3": "Romslige grenser og pålitelig tilgang", "go.plans.plus.feature1": "Alt i Go er inkludert", "go.plans.plus.feature2": "Høyere grenser for lengre, fokuserte kodeøkter", "go.plans.plus.feature3": "For større, krevende prosjekter", + "go.plans.teams.feature1": "Hvert medlem får sine egne Go-grenser", + "go.plans.teams.feature2": "Romslige månedlige grenser for hver modell", + "go.plans.teams.feature3": "Én regning for hele arbeidsområdet", + "go.plans.teams.feature4": "Ekstra bruk med budsjetter per medlem", + "go.plans.teams.feature5": "Personvern- og regionkontroller for modeller", "go.plans.limits": "Grenser", "go.plans.description": "Anslåtte forespørsler per 5 timer og månedlige grenser per modell", "go.plans.legend": "Abonnementer", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 3e6e00270e6a..2b585d006000 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -114,7 +114,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Możesz nawet podłączyć swoje", "home.faq.a3.p4.localLink": "lokalne modele", "home.faq.q4": "Czy mogę używać moich istniejących subskrypcji AI z OpenCode?", - "home.faq.a4.p1": "Tak, możesz używać swoich subskrypcji ChatGPT Plus/Pro lub GitHub Copilot.", + "home.faq.a4.p1": + "Tak, możesz używać swoich subskrypcji ChatGPT Plus/Pro lub GitHub Copilot albo subskrypcji Claude przez Claude Code.", "home.faq.q5": "Czy mogę używać OpenCode tylko w terminalu?", "home.faq.a5.beforeDesktop": "Już nie! OpenCode jest teraz dostępny jako aplikacja na", "home.faq.a5.desktop": "pulpit (desktop)", @@ -213,15 +214,26 @@ export const dict = { "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", "go.cta.text": "Zasubskrybuj Go", + "go.plans.audience": "Plany Go dla", + "go.plans.individuals": "Dla osób indywidualnych", + "go.plans.teams": "Dla zespołów", "go.plans.month": "miesięcznie", + "go.plans.memberMonth": "za członka\nmiesięcznie", "go.plans.plus.cta": "Subskrybuj Go Plus", "go.plans.plus.description": "Go Plus kosztuje $40/miesiąc i oferuje wyższe limity.", + "go.plans.teams.cta": "Subskrybuj Go Teams", + "go.plans.teams.description": "Go Teams kosztuje $20/członka/miesiąc i daje limity każdemu członkowi.", "go.plans.go.feature1": "Wyselekcjonowane, przystępne modele", "go.plans.go.feature2": "Przetestowane do kodowania agentowego", "go.plans.go.feature3": "Hojne limity i niezawodny dostęp", "go.plans.plus.feature1": "Wszystko, co obejmuje Go", "go.plans.plus.feature2": "Wyższe limity na dłuższe sesje kodowania w skupieniu", "go.plans.plus.feature3": "Do większych, wymagających projektów", + "go.plans.teams.feature1": "Każdy członek otrzymuje własne limity Go", + "go.plans.teams.feature2": "Hojne miesięczne limity dla każdego modelu", + "go.plans.teams.feature3": "Jeden rachunek dla całego obszaru roboczego", + "go.plans.teams.feature4": "Dodatkowe użycie z budżetami na członka", + "go.plans.teams.feature5": "Kontrola prywatności i regionów modeli", "go.plans.limits": "Limity", "go.plans.description": "Szacowana liczba żądań na 5 godzin i miesięczne limity dla modeli", "go.plans.legend": "Plany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 0647f883582b..025d3fa41202 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -116,7 +116,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Вы даже можете подключить ваши", "home.faq.a3.p4.localLink": "локальные модели", "home.faq.q4": "Могу ли я использовать мои существующие AI-подписки с OpenCode?", - "home.faq.a4.p1": "Да, вы можете использовать ваши подписки ChatGPT Plus/Pro или GitHub Copilot.", + "home.faq.a4.p1": + "Да, вы можете использовать ваши подписки ChatGPT Plus/Pro или GitHub Copilot, а также подписку Claude через Claude Code.", "home.faq.q5": "Можно ли использовать OpenCode только в терминале?", "home.faq.a5.beforeDesktop": "Больше нет! OpenCode теперь доступен как приложение для", "home.faq.a5.desktop": "десктопа", @@ -216,15 +217,26 @@ export const dict = { "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", "go.cta.text": "Подписаться на Go", + "go.plans.audience": "Тарифы Go для", + "go.plans.individuals": "Для одного", + "go.plans.teams": "Для команд", "go.plans.month": "в месяц", + "go.plans.memberMonth": "за участника\nв месяц", "go.plans.plus.cta": "Подписаться на Go Plus", "go.plans.plus.description": "Go Plus стоит $40/месяц и предлагает более высокие лимиты.", + "go.plans.teams.cta": "Подписаться на Go Teams", + "go.plans.teams.description": "Go Teams стоит $20/участника/месяц с лимитами для каждого участника.", "go.plans.go.feature1": "Отобранные доступные модели", "go.plans.go.feature2": "Проверены для агентного программирования", "go.plans.go.feature3": "Щедрые лимиты и надёжный доступ", "go.plans.plus.feature1": "Все возможности Go", "go.plans.plus.feature2": "Повышенные лимиты для длительной сосредоточенной работы над кодом", "go.plans.plus.feature3": "Для крупных и сложных проектов", + "go.plans.teams.feature1": "У каждого участника свои лимиты Go", + "go.plans.teams.feature2": "Щедрые месячные лимиты для каждой модели", + "go.plans.teams.feature3": "Единый счёт на всё рабочее пространство", + "go.plans.teams.feature4": "Дополнительное использование с бюджетами на участника", + "go.plans.teams.feature5": "Управление приватностью и регионами моделей", "go.plans.limits": "Лимиты", "go.plans.description": "Примерное число запросов за 5 часов и месячные лимиты по моделям", "go.plans.legend": "Тарифы", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 5352cf4a67cf..62ea9763e7c2 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -112,7 +112,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "คุณยังสามารถเชื่อมต่อกับ", "home.faq.a3.p4.localLink": "โมเดล Local ของคุณ", "home.faq.q4": "ฉันสามารถใช้การสมัครสมาชิก AI ที่มีอยู่กับ OpenCode ได้หรือไม่?", - "home.faq.a4.p1": "ได้ คุณสามารถใช้การสมัครสมาชิก ChatGPT Plus/Pro หรือ GitHub Copilot ของคุณได้", + "home.faq.a4.p1": + "ได้ คุณสามารถใช้การสมัครสมาชิก ChatGPT Plus/Pro หรือ GitHub Copilot ของคุณได้ รวมถึงการสมัครสมาชิก Claude ผ่าน Claude Code", "home.faq.q5": "ฉันใช้ OpenCode ได้เฉพาะในเทอร์มินัลใช่หรือไม่?", "home.faq.a5.beforeDesktop": "ไม่อีกต่อไป! OpenCode มีแอปสำหรับ", "home.faq.a5.desktop": "เดสก์ท็อป", @@ -211,15 +212,26 @@ export const dict = { "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", "go.cta.text": "สมัครสมาชิก Go", + "go.plans.audience": "แพ็กเกจ Go สำหรับ", + "go.plans.individuals": "บุคคลทั่วไป", + "go.plans.teams": "ทีม", "go.plans.month": "ต่อเดือน", + "go.plans.memberMonth": "ต่อสมาชิก\nต่อเดือน", "go.plans.plus.cta": "สมัคร Go Plus", "go.plans.plus.description": "Go Plus ราคา $40/เดือน พร้อมขีดจำกัดการใช้งานที่สูงขึ้น", + "go.plans.teams.cta": "สมัคร Go Teams", + "go.plans.teams.description": "Go Teams ราคา $20/สมาชิก/เดือน พร้อมขีดจำกัดสำหรับสมาชิกทุกคน", "go.plans.go.feature1": "โมเดลคัดสรรในราคาคุ้มค่า", "go.plans.go.feature2": "ทดสอบสำหรับการเขียนโค้ดด้วยเอเจนต์", "go.plans.go.feature3": "ขีดจำกัดสูงและเข้าถึงได้อย่างเสถียร", "go.plans.plus.feature1": "รวมทุกสิ่งที่มีใน Go", "go.plans.plus.feature2": "ขีดจำกัดสูงขึ้น เพื่อเขียนโค้ดอย่างต่อเนื่องและมีสมาธิได้นานขึ้น", "go.plans.plus.feature3": "เหมาะสำหรับโปรเจกต์ขนาดใหญ่ที่ซับซ้อน", + "go.plans.teams.feature1": "สมาชิกแต่ละคนได้รับขีดจำกัด Go ของตัวเอง", + "go.plans.teams.feature2": "ขีดจำกัดรายเดือนที่คุ้มค่าสำหรับทุกโมเดล", + "go.plans.teams.feature3": "รวมบิลเดียวสำหรับทั้งเวิร์กสเปซ", + "go.plans.teams.feature4": "ใช้งานเพิ่มเติมพร้อมตั้งงบประมาณรายคน", + "go.plans.teams.feature5": "ควบคุมความเป็นส่วนตัวและภูมิภาคของโมเดล", "go.plans.limits": "ขีดจำกัด", "go.plans.description": "จำนวนคำขอโดยประมาณต่อ 5 ชั่วโมงและขีดจำกัดรายเดือนแยกตามโมเดล", "go.plans.legend": "แพ็กเกจ", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 1ed23b268719..b52b66b44950 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -113,7 +113,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Hatta", "home.faq.a3.p4.localLink": "yerel modellerinizi bağlayabilirsiniz", "home.faq.q4": "Mevcut AI aboneliklerimi OpenCode ile kullanabilir miyim?", - "home.faq.a4.p1": "Evet, ChatGPT Plus/Pro veya GitHub Copilot aboneliklerinizi kullanabilirsiniz.", + "home.faq.a4.p1": + "Evet, ChatGPT Plus/Pro veya GitHub Copilot aboneliklerinizi ya da Claude Code üzerinden Claude aboneliğinizi kullanabilirsiniz.", "home.faq.q5": "OpenCode'u sadece terminalde mi kullanabilirim?", "home.faq.a5.beforeDesktop": "Artık hayır! OpenCode artık sizin bu cihazlarınıza", "home.faq.a5.desktop": "masaüstü", @@ -214,15 +215,26 @@ export const dict = { "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", "go.cta.text": "Go'ya abone ol", + "go.plans.audience": "Go planları", + "go.plans.individuals": "Bireysel", + "go.plans.teams": "Ekipler", "go.plans.month": "aylık", + "go.plans.memberMonth": "üye başına\naylık", "go.plans.plus.cta": "Go Plus'a abone ol", "go.plans.plus.description": "Go Plus ayda 40$'dır ve daha yüksek limitler sunar.", + "go.plans.teams.cta": "Go Teams'e abone ol", + "go.plans.teams.description": "Go Teams üye başına ayda 20$'dır ve her üyeye kendi limitlerini sunar.", "go.plans.go.feature1": "Seçilmiş, uygun fiyatlı modeller", "go.plans.go.feature2": "Ajan destekli kodlama için test edildi", "go.plans.go.feature3": "Cömert limitler ve güvenilir erişim", "go.plans.plus.feature1": "Go'daki her şey dahil", "go.plans.plus.feature2": "Daha uzun, odaklı kodlama oturumları için daha yüksek limitler", "go.plans.plus.feature3": "Daha büyük ve zorlu projeler için", + "go.plans.teams.feature1": "Her üyeye kendine ait Go limitleri", + "go.plans.teams.feature2": "Her model için cömert aylık limitler", + "go.plans.teams.feature3": "Tüm çalışma alanı için tek fatura", + "go.plans.teams.feature4": "Üye başına bütçelerle ekstra kullanım", + "go.plans.teams.feature5": "Model gizliliği ve bölge kontrolleri", "go.plans.limits": "Limitler", "go.plans.description": "Model başına 5 saatlik tahmini istek ve aylık kullanım limitleri", "go.plans.legend": "Planlar", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 0a78a3d02d06..77ed565d92e4 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -114,7 +114,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "Ви навіть можете підключити свої", "home.faq.a3.p4.localLink": "локальні моделі", "home.faq.q4": "Чи можу я використовувати свої наявні AI-підписки з OpenCode?", - "home.faq.a4.p1": "Так, ви можете використовувати підписки ChatGPT Plus/Pro або GitHub Copilot.", + "home.faq.a4.p1": + "Так, ви можете використовувати підписки ChatGPT Plus/Pro або GitHub Copilot, а також підписку Claude через Claude Code.", "home.faq.q5": "Чи можна використовувати OpenCode лише в терміналі?", "home.faq.a5.beforeDesktop": "Вже ні! OpenCode тепер доступний як застосунок для", "home.faq.a5.desktop": "десктопа", @@ -212,15 +213,26 @@ export const dict = { "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", "go.cta.text": "Підписатися на Go", + "go.plans.audience": "Плани Go для", + "go.plans.individuals": "Для одного", + "go.plans.teams": "Для команд", "go.plans.month": "на місяць", + "go.plans.memberMonth": "за учасника\nна місяць", "go.plans.plus.cta": "Підписатися на Go Plus", "go.plans.plus.description": "Go Plus коштує $40/місяць і пропонує вищі ліміти.", + "go.plans.teams.cta": "Підписатися на Go Teams", + "go.plans.teams.description": "Go Teams коштує $20/учасника/місяць із лімітами для кожного учасника.", "go.plans.go.feature1": "Відібрані доступні моделі", "go.plans.go.feature2": "Перевірені для агентного програмування", "go.plans.go.feature3": "Щедрі ліміти й надійний доступ", "go.plans.plus.feature1": "Усе, що входить до Go", "go.plans.plus.feature2": "Вищі ліміти для тривалішої зосередженої роботи над кодом", "go.plans.plus.feature3": "Для більших і складніших проєктів", + "go.plans.teams.feature1": "Кожен учасник отримує власні ліміти Go", + "go.plans.teams.feature2": "Щедрі місячні ліміти для кожної моделі", + "go.plans.teams.feature3": "Єдиний рахунок для всього робочого простору", + "go.plans.teams.feature4": "Додаткове використання з бюджетами на учасника", + "go.plans.teams.feature5": "Керування конфіденційністю та регіонами моделей", "go.plans.limits": "Ліміти", "go.plans.description": "Орієнтовні запити за 5 годин і місячні ліміти за моделями", "go.plans.legend": "Плани", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index c3bf3cd7830e..70be85f117e1 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -110,7 +110,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "您甚至可以连接您的", "home.faq.a3.p4.localLink": "本地模型", "home.faq.q4": "我可以使用现有的 AI 订阅吗?", - "home.faq.a4.p1": "可以,您可以使用您的 ChatGPT Plus/Pro 或 GitHub Copilot 订阅。", + "home.faq.a4.p1": + "可以,您可以使用您的 ChatGPT Plus/Pro 或 GitHub Copilot 订阅,也可以通过 Claude Code 使用您的 Claude 订阅。", "home.faq.q5": "OpenCode 只能在终端使用吗?", "home.faq.a5.beforeDesktop": "不再是了!OpenCode 现在也提供", "home.faq.a5.desktop": "桌面端应用", @@ -201,15 +202,26 @@ export const dict = { "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", "go.cta.text": "订阅 Go", + "go.plans.audience": "Go 套餐适用对象", + "go.plans.individuals": "个人", + "go.plans.teams": "团队", "go.plans.month": "每月", + "go.plans.memberMonth": "每位成员\n每月", "go.plans.plus.cta": "订阅 Go Plus", "go.plans.plus.description": "Go Plus 每月 $40,提供更高的使用额度。", + "go.plans.teams.cta": "订阅 Go Teams", + "go.plans.teams.description": "Go Teams 每位成员每月 $20,为每位成员提供各自的使用额度。", "go.plans.go.feature1": "精选实惠的模型", "go.plans.go.feature2": "专为智能体编程测试", "go.plans.go.feature3": "充足额度与稳定访问", "go.plans.plus.feature1": "包含 Go 的全部权益", "go.plans.plus.feature2": "更高额度,支持更长时间的专注编码", "go.plans.plus.feature3": "适合更大型、更复杂的项目", + "go.plans.teams.feature1": "每位成员独享各自的 Go 额度", + "go.plans.teams.feature2": "所有模型均享充裕的月度额度", + "go.plans.teams.feature3": "整个工作区统一账单结算", + "go.plans.teams.feature4": "支持额外用量与成员预算上限", + "go.plans.teams.feature5": "模型隐私与地区访问控制", "go.plans.limits": "使用额度", "go.plans.description": "各模型每 5 小时的预计请求次数和每月使用额度", "go.plans.legend": "套餐", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index f46c606e288e..b9dfa017a174 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -111,7 +111,8 @@ export const dict = { "home.faq.a3.p4.beforeLocal": "你還可以連接你的", "home.faq.a3.p4.localLink": "本地模型", "home.faq.q4": "可以使用我現有的 AI 訂閱嗎?", - "home.faq.a4.p1": "可以,你可以使用 ChatGPT Plus/Pro 或 GitHub Copilot 訂閱。", + "home.faq.a4.p1": + "可以,你可以使用 ChatGPT Plus/Pro 或 GitHub Copilot 訂閱,也可以透過 Claude Code 使用 Claude 訂閱。", "home.faq.q5": "OpenCode 只能在終端中使用嗎?", "home.faq.a5.beforeDesktop": "不再是了!OpenCode 現在也提供", "home.faq.a5.desktop": "桌面端", @@ -202,15 +203,26 @@ export const dict = { "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", "go.cta.text": "訂閱 Go", + "go.plans.audience": "Go 方案適用對象", + "go.plans.individuals": "個人", + "go.plans.teams": "團隊", "go.plans.month": "每月", + "go.plans.memberMonth": "每位成員\n每月", "go.plans.plus.cta": "訂閱 Go Plus", "go.plans.plus.description": "Go Plus 每月 $40,提供更高的使用額度。", + "go.plans.teams.cta": "訂閱 Go Teams", + "go.plans.teams.description": "Go Teams 每位成員每月 $20,為每位成員提供各自的使用額度。", "go.plans.go.feature1": "精選實惠的模型", "go.plans.go.feature2": "專為代理程式編碼測試", "go.plans.go.feature3": "充足額度與穩定存取", "go.plans.plus.feature1": "包含 Go 的所有權益", "go.plans.plus.feature2": "更高額度,支援更長時間的專注編碼", "go.plans.plus.feature3": "適合更大型、更複雜的專案", + "go.plans.teams.feature1": "每位成員獨享各自的 Go 額度", + "go.plans.teams.feature2": "所有模型均享充裕的月度額度", + "go.plans.teams.feature3": "整個工作區統一帳單結算", + "go.plans.teams.feature4": "支援額外用量與成員預算上限", + "go.plans.teams.feature5": "模型隱私與地區存取控制", "go.plans.limits": "使用額度", "go.plans.description": "各模型每 5 小時的預估請求次數及每月使用額度", "go.plans.legend": "方案", diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index c0c1d62f0189..2f468ae3be44 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -1,4 +1,4 @@ -import type { APIEvent } from "@solidjs/start" +import type { APIEvent } from "@solidjs/start/server" import type { DownloadPlatform } from "../types" const prodAssetNames: Record = { diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 171660564023..68cd7569dad1 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -1059,12 +1059,99 @@ body { } } + [data-slot="plans-group"] { + display: flex; + flex-direction: column; + gap: 24px; + } + + [data-slot="audience"] { + display: flex; + align-self: center; + max-width: 100%; + gap: 4px; + padding: 4px; + border: 1px solid var(--color-border-weak); + background: var(--color-background); + + @media (max-width: 30rem) { + width: 100%; + } + + button { + min-width: 136px; + height: 32px; + padding: 0 16px; + border: 0; + background: transparent; + color: var(--color-text); + font: inherit; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + white-space: nowrap; + cursor: pointer; + + @media (max-width: 30rem) { + min-width: 0; + flex: 1; + } + } + + button:is(:hover, :focus-visible) { + color: var(--color-text-strong); + } + + button:focus-visible { + outline: 2px solid var(--color-text-strong); + outline-offset: 2px; + } + + button[aria-pressed="true"] { + background: color-mix(in srgb, var(--color-text-strong) 8%, var(--color-background-weak)); + color: var(--color-text-strong); + } + } + + /* Both audiences share one grid cell, so the cards are as tall as the tallest set and switching never shifts the + page below. */ + [data-slot="plans-stack"] { + display: grid; + + > [data-slot="plans"] { + grid-area: 1 / 1; + } + } + [data-slot="plans"] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; font-size: 16px; line-height: 1.5; + transition: + opacity 200ms cubic-bezier(0.23, 1, 0.32, 1), + transform 200ms cubic-bezier(0.23, 1, 0.32, 1); + + &[aria-hidden="true"] { + opacity: 0; + transform: translateY(8px) scale(0.98); + pointer-events: none; + } + + @media (prefers-reduced-motion: reduce) { + transition: opacity 200ms ease; + + &[aria-hidden="true"] { + transform: none; + } + } + + /* Go Teams is one card as wide as each individual plan, centred. */ + &[data-audience="teams"] { + grid-template-columns: minmax(0, calc((100% - 24px) / 2)); + justify-content: center; + } [data-slot="plan"] { display: flex; @@ -1085,21 +1172,26 @@ body { [data-slot="name"] { font-weight: 500; color: var(--color-text); + text-transform: uppercase; } [data-slot="price"] { display: flex; - align-items: center; + align-items: flex-start; gap: 12px; font-size: 64px; font-weight: 500; line-height: 1.25; color: var(--color-text-strong); + /* Set high beside the figures: the tops of its tall letters level with theirs (12px at 64px, 8px at 48px). + Go Teams breaks it onto two lines where the translation has a line break. */ span { + margin-top: 12px; font-size: 16px; line-height: 1.5; color: var(--color-text); + white-space: pre-line; } } @@ -1112,13 +1204,15 @@ body { color: var(--color-text); } + /* Markers sit on the first line of a feature that wraps: 16px icons centred in its 24px line. */ li { display: flex; - align-items: center; + align-items: flex-start; gap: 16px; svg { flex: none; + margin-top: 4px; color: var(--color-icon); } } @@ -1145,27 +1239,63 @@ body { background: var(--color-background-strong-hover); } + /* Go subscribes with an outline button, its label set like the plan's features. */ + [data-plan="go"] a { + border: 1px solid var(--color-border-weak); + font-weight: inherit; + background: transparent; + color: var(--color-text); + box-shadow: none; + } + + [data-plan="go"] a:hover { + background: transparent; + border-color: var(--color-border); + color: var(--color-text-strong); + } + a:focus-visible { outline: 2px solid var(--color-text-strong); outline-offset: 2px; } + /* Stacked on phones the sets differ a lot in height, so the hidden one leaves the layout and the shown one fades + in on its own. */ @media (max-width: 42rem) { grid-template-columns: 1fr; + &[aria-hidden="true"] { + display: none; + } + + @starting-style { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + + &[data-audience="teams"] { + grid-template-columns: 1fr; + } + [data-slot="price"] { font-size: 48px; + + span { + margin-top: 8px; + } } } } + /* The limits band takes the plan cards' muted background rather than the chart's own lighter one. */ [data-component="comparison"] { + --go-console-bg: var(--color-background-weak); background: var(--go-console-bg); border-top: 0; } [data-component="hero"] { - padding-bottom: 2rem; + padding-bottom: 4rem; } [data-slot="section-title"] { diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 1b7a05f30eef..64ce5cd450f4 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -62,22 +62,64 @@ const models = [ { name: "Hy3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, ] as const +const audiences = [ + { id: "individuals", label: "go.plans.individuals" }, + { id: "teams", label: "go.plans.teams" }, +] as const + +const plans = [ + { + id: "go", + audience: "individuals", + name: "Go", + price: "$10", + period: "go.plans.month", + cta: "go.cta.text", + features: ["go.plans.go.feature1", "go.plans.go.feature2", "go.plans.go.feature3"], + marker: "square", + }, + { + id: "go-plus", + audience: "individuals", + name: "Go Plus", + price: "$40", + period: "go.plans.month", + cta: "go.plans.plus.cta", + features: ["go.plans.plus.feature1", "go.plans.plus.feature2", "go.plans.plus.feature3"], + marker: "plus", + }, + { + id: "go-teams", + audience: "teams", + name: "Go Teams", + price: "$20", + period: "go.plans.memberMonth", + cta: "go.plans.teams.cta", + features: [ + "go.plans.teams.feature1", + "go.plans.teams.feature2", + "go.plans.teams.feature3", + "go.plans.teams.feature4", + "go.plans.teams.feature5", + ], + marker: "plus", + }, +] as const + export default function Home() { const subscribeUrl = "https://opencode.ai/console/go" const i18n = useI18n() - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() + const audience = () => (searchParams.for === "teams" ? "teams" : "individuals") + const description = () => + `${i18n.t("go.meta.description")} ${i18n.t("go.plans.plus.description")} ${i18n.t("go.plans.teams.description")}` return (
{/**/} {i18n.t("go.title")} - + - +
@@ -119,60 +161,74 @@ export default function Home() {

{i18n.t("go.hero.tagline")}

-
- - {(plan) => ( -
-
-

{plan.name}

-

- {plan.price} {i18n.t("go.plans.month")} -

-
    - - {(feature) => ( -
  • - - {i18n.t(feature)} -
  • - )} -
    -
+
- +
@@ -216,7 +272,7 @@ export default function Home() {
{i18n.t("go.how.step2.title")} -{" "} {i18n.t("go.how.step2.link")} {i18n.t("go.how.step2.afterLink")} ·{" "} - {i18n.t("go.plans.plus.description")} + {i18n.t("go.plans.plus.description")} · {i18n.t("go.plans.teams.description")}
  • @@ -249,7 +305,7 @@ export default function Home() {
  • - {i18n.t("go.faq.a3")} {i18n.t("go.plans.plus.description")} + {i18n.t("go.faq.a3")} {i18n.t("go.plans.plus.description")} {i18n.t("go.plans.teams.description")}
  • @@ -257,8 +313,8 @@ export default function Home() { {i18n.t("go.faq.a4.p1.beforePricing")}{" "} {i18n.t("go.faq.a4.p1.pricingLink")}{" "} {i18n.t("go.faq.a4.p1.afterPricing")} {i18n.t("go.plans.plus.description")}{" "} - {i18n.t("go.faq.a4.p2.beforeAccount")} {i18n.t("go.faq.a4.p2.accountLink")} - . {i18n.t("go.faq.a4.p3")} + {i18n.t("go.plans.teams.description")} {i18n.t("go.faq.a4.p2.beforeAccount")}{" "} + {i18n.t("go.faq.a4.p2.accountLink")}. {i18n.t("go.faq.a4.p3")}
  • diff --git a/packages/console/app/test/go-models.test.ts b/packages/console/app/test/go-models.test.ts new file mode 100644 index 000000000000..0a0eb0dea37e --- /dev/null +++ b/packages/console/app/test/go-models.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { goPlanModels } from "../src/component/go-models" +import { i18n } from "../src/i18n" +import { LOCALES } from "../src/lib/language" + +describe("goPlanModels", () => { + test("publishes Go, Go Plus, and Go Teams limits for every model", () => { + expect(goPlanModels.length).toBeGreaterThan(0) + for (const model of goPlanModels) { + if (!Number.isFinite(model.allowance)) { + expect(model.requests).toBe(Infinity) + expect(model.plusRequests).toBe(Infinity) + expect(model.plusAllowance).toBe(Infinity) + expect(model.teamsRequests).toBe(Infinity) + expect(model.teamsAllowance).toBe(Infinity) + continue + } + expect(model.teamsAllowance).toBe(model.allowance + 5) + expect(Number.isFinite(model.teamsRequests)).toBe(true) + expect(model.teamsRequests).toBeGreaterThan(model.requests) + expect(model.plusAllowance).toBeGreaterThan(model.teamsAllowance) + expect(model.plusRequests).toBeGreaterThan(model.teamsRequests) + } + }) + + test.each([...LOCALES])("translates Go Teams plan copy for %s", (locale) => { + const dict = i18n(locale) + for (const key of [ + "go.plans.audience", + "go.plans.individuals", + "go.plans.teams", + "go.plans.memberMonth", + "go.plans.teams.cta", + "go.plans.teams.description", + "go.plans.teams.feature1", + "go.plans.teams.feature2", + "go.plans.teams.feature3", + "go.plans.teams.feature4", + "go.plans.teams.feature5", + ] as const) { + expect(dict[key].length).toBeGreaterThan(0) + } + expect(dict["go.plans.teams.description"]).toContain("20") + }) +}) diff --git a/packages/console/app/vite.config.ts b/packages/console/app/vite.config.ts index 33455acc5edb..98cd38487ba7 100644 --- a/packages/console/app/vite.config.ts +++ b/packages/console/app/vite.config.ts @@ -15,6 +15,11 @@ export default defineConfig({ }, }), ], + environments: { + // Nitro copies public/ with fs.cp, which keeps the symlinks into packages/ui as links. + // Letting Vite copy it first writes real files. + client: { build: { copyPublicDir: true } }, + }, server: { allowedHosts: true, port: 3001, diff --git a/packages/core/package.json b/packages/core/package.json index 448132449c2d..fa9651d91920 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.2", + "version": "2.2.4", "name": "@opencode/core", "type": "module", "license": "MIT", @@ -49,6 +49,10 @@ "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" }, + "#claude-agent-sdk": { + "workerd": "./src/plugin/provider/claude-agent/claude/agent-sdk.workerd.ts", + "default": "./src/plugin/provider/claude-agent/claude/agent-sdk.ts" + }, "#persistent-pty-binary": { "workerd": "./src/persistent-pty/binary.workerd.ts", "bun": "./src/persistent-pty/binary.bun.ts", @@ -67,12 +71,6 @@ "node": "./src/image/photon-wasm.node.ts", "default": "./src/image/photon-wasm.bun.ts" }, - "#shell-parser-wasm": { - "workerd": "./src/shell/parser-wasm.workerd.ts", - "bun": "./src/shell/parser-wasm.bun.ts", - "node": "./src/shell/parser-wasm.node.ts", - "default": "./src/shell/parser-wasm.bun.ts" - }, "#v1-migration": { "types": "./src/database/v1-migration.bun.ts", "bun": "./src/database/v1-migration.bun.ts", @@ -83,9 +81,9 @@ }, "devDependencies": { "@ai-sdk/openai-compatible": "2.0.41", + "@anthropic-ai/claude-agent-sdk": "0.3.301", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@modelcontextprotocol/server": "2.3.1", "@opencode/http-recorder": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", @@ -110,7 +108,8 @@ "@lydell/node-pty": "catalog:", "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/core": "2.3.1", - "@opencode/pty": "0.3.3", + "@modelcontextprotocol/server": "2.3.1", + "@opencode/pty": "0.4.0", "@opencode/ai": "workspace:*", "@opencode/codemode": "workspace:*", "@opencode/plugin": "workspace:*", @@ -138,10 +137,15 @@ "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", - "tree-sitter-bash": "0.25.0", - "tree-sitter-powershell": "0.25.10", - "web-tree-sitter": "0.25.10", "which": "6.0.1", "zod": "catalog:" + }, + "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.301" + }, + "peerDependenciesMeta": { + "@anthropic-ai/claude-agent-sdk": { + "optional": true + } } } diff --git a/packages/core/schema.json b/packages/core/schema.json index 472b1e42f14d..dd9642bbb2c9 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "0ac88673-a402-45a0-b931-e694d59ccefa", - "prevIds": ["e5daa0af-cae7-4057-919a-ebf44dfced0a"], + "id": "4f6e16c6-f070-4545-b9d0-19fcb6e5210c", + "prevIds": ["0ac88673-a402-45a0-b931-e694d59ccefa"], "ddl": [ { "name": "account_state", @@ -28,6 +28,10 @@ "name": "event", "entityType": "tables" }, + { + "name": "job", + "entityType": "tables" + }, { "name": "kv", "entityType": "tables" @@ -466,6 +470,126 @@ "entityType": "columns", "table": "event" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "notification_id", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "child_session_id", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "output", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error", + "entityType": "columns", + "table": "job" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recovery", + "entityType": "columns", + "table": "job" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "job" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "job" + }, { "type": "text", "notNull": false, @@ -2236,6 +2360,13 @@ "table": "event", "entityType": "pks" }, + { + "columns": ["notification_id"], + "nameExplicit": false, + "name": "job_pk", + "table": "job", + "entityType": "pks" + }, { "columns": ["key"], "nameExplicit": false, @@ -2353,6 +2484,48 @@ "entityType": "indexes", "table": "event" }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "job_session_idx", + "entityType": "indexes", + "table": "job" + }, + { + "columns": [ + { + "value": "child_session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "job_child_session_idx", + "entityType": "indexes", + "table": "job" + }, + { + "columns": [ + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "job_id_idx", + "entityType": "indexes", + "table": "job" + }, { "columns": [ { diff --git a/packages/core/script/benchmark-snapshot-session.ts b/packages/core/script/benchmark-snapshot-session.ts index ac5493a9e4c8..42117d5e0c3d 100644 --- a/packages/core/script/benchmark-snapshot-session.ts +++ b/packages/core/script/benchmark-snapshot-session.ts @@ -11,7 +11,7 @@ import { $ } from "bun" import fs from "fs/promises" import os from "os" import path from "path" -import { LanguageModel, LLM, LLMEvent } from "@opencode/ai/effect" +import { ai, LanguageModel, LLMEvent } from "@opencode/ai/effect" import { OpenAIChat } from "@opencode/ai/protocols/openai-chat" import { TestLLM } from "@opencode/ai/testing" import { Global } from "@opencode/util/global" @@ -136,7 +136,7 @@ const run = (snapshots: Layer.Layer) => delayed( kind === "text" ? TestLLM.text("Done.", `text-${call}`) - : TestLLM.tool(call, kind, { path: files[edits % files.length] }), + : TestLLM.toolCall(call, kind, { path: files[edits % files.length] }), ), ) yield* steps.attempt({ @@ -147,7 +147,7 @@ const run = (snapshots: Layer.Layer) => model, prepared: { retry: () => Effect.void, - request: LLM.request({ model: model.model, prompt: "bench" }), + request: ai.request({ model: model.model, prompt: "bench" }), options: {}, executeTool: (input) => Effect.gen(function* () { diff --git a/packages/core/script/build.ts b/packages/core/script/build.ts index c76ad04a8cc2..328214c88df9 100644 --- a/packages/core/script/build.ts +++ b/packages/core/script/build.ts @@ -23,10 +23,10 @@ const result = await Bun.build({ external: [ "#sqlite", "#pty", + "#claude-agent-sdk", "#persistent-pty-binary", "#fff", "#photon-wasm", - "#shell-parser-wasm", "#v1-migration", ], splitting: true, diff --git a/packages/core/src/artifact.ts b/packages/core/src/artifact.ts new file mode 100644 index 000000000000..ed09984ec0cc --- /dev/null +++ b/packages/core/src/artifact.ts @@ -0,0 +1,615 @@ +export * as Artifact from "./artifact.js" + +import { randomUUID } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { lookup } from "mime-types" +import { + Capability, + Content, + CreateInput, + Event, + ID, + Info, + Provider, + ProviderID, + UpdateInput, +} from "@opencode/schema/artifact" +import type { SessionID } from "@opencode/schema/session-id" +import { Global } from "@opencode/util/global" +import { makeLocationNode } from "@opencode/util/effect/app-node" +import { Clock, Context, Effect, Layer, Option, Schema } from "effect" +import { Bus } from "./bus.js" +import { Config } from "./config.js" +import { KV } from "./kv.js" +import { Location } from "./location.js" +import { Mime } from "./mime.js" +import { Session } from "./session.js" +import { State } from "./state.js" + +export { Capability, Content, CreateInput, Event, ID, Info, Provider, ProviderID, UpdateInput } + +export const LocalProviderID = ProviderID.make("local") +const ItemPrefix = "artifact:item:" + +export interface ProviderCreateInput { + readonly id: ID + readonly title: string + readonly entry: string + readonly files: Readonly> + readonly sessionID?: SessionID +} + +export interface ProviderUpdateInput { + readonly title: string + readonly entry: string + readonly files: Readonly> + readonly sessionID?: SessionID +} + +export interface ProviderResult { + readonly url?: string +} + +export interface ProviderImplementation { + readonly id: ProviderID + readonly name: string + readonly capabilities: readonly Capability[] + readonly prompt: string + readonly create: (input: ProviderCreateInput) => Effect.Effect + readonly read: (id: ID) => Effect.Effect>, unknown> + readonly update: (id: ID, input: ProviderUpdateInput) => Effect.Effect + readonly delete: (id: ID) => Effect.Effect +} + +export interface LocalFile { + readonly bytes: Uint8Array + readonly mime: string +} + +export interface ProviderPrompts { + readonly defaultProvider: { + readonly id: ProviderID + readonly name: string + readonly capabilities: readonly Capability[] + readonly prompt: string + } + readonly others: readonly { + readonly id: ProviderID + readonly name: string + readonly capabilities: readonly Capability[] + }[] +} + +export class DisabledError extends Schema.TaggedError()("Artifact.DisabledError", {}) { + override get message() { + return "Artifacts are disabled; set experimental.artifacts in config" + } +} + +export class NotFoundError extends Schema.TaggedError()("Artifact.NotFoundError", { + artifactID: ID, +}) { + override get message() { + return `Artifact not found: ${this.artifactID}` + } +} + +export class FileMissingError extends Schema.TaggedError()("Artifact.FileMissingError", { + artifactID: ID, + path: Schema.String, +}) { + override get message() { + return `Artifact file not found: ${this.path}` + } +} + +export class ProviderNotFoundError extends Schema.TaggedError()( + "Artifact.ProviderNotFoundError", + { + providerID: ProviderID, + }, +) { + override get message() { + return `Artifact provider not found: ${this.providerID}` + } +} + +export class InvalidError extends Schema.TaggedError()("Artifact.InvalidError", { + reason: Schema.String, + field: Schema.optional(Schema.String), +}) { + override get message() { + return this.reason + } +} + +export class ProviderError extends Schema.TaggedError()("Artifact.ProviderError", { + providerID: ProviderID, + cause: Schema.Defect(), +}) { + override get message() { + return this.cause instanceof Error && this.cause.message + ? `Artifact provider ${this.providerID} failed: ${this.cause.message}` + : `Artifact provider ${this.providerID} failed` + } +} + +export type Error = + | DisabledError + | NotFoundError + | FileMissingError + | ProviderNotFoundError + | InvalidError + | ProviderError + | Session.NotFoundError + +export type Editor = { + add: (provider: ProviderImplementation) => void + remove: (id: ProviderID) => void + default: { + get: () => ProviderID | undefined + set: (providerID: ProviderID | undefined) => void + } +} + +type Data = { + readonly providers: Map + selection?: ProviderID +} + +export interface Interface extends State.Transformable { + readonly enabled: () => Effect.Effect + readonly providers: () => Effect.Effect + readonly prompts: () => Effect.Effect + readonly list: (filter?: { + readonly sessionID?: SessionID + readonly provider?: ProviderID + }) => Effect.Effect + readonly create: (input: CreateInput) => Effect.Effect + readonly get: (id: ID) => Effect.Effect + readonly info: (id: ID) => Effect.Effect + readonly update: (id: ID, input: UpdateInput) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly readLocalFile: ( + id: ID, + subpath?: string, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Artifact") {} + +const StoredRecord = Schema.Struct({ + info: Info, + directory: Schema.String, + projectID: Schema.String, +}) +type StoredRecord = typeof StoredRecord.Type + +const decodeStoredRecord = Schema.decodeUnknownOption(StoredRecord) +const encodeStoredRecord = Schema.encodeSync(StoredRecord) +const encoder = new TextEncoder() + +/** Resolves the stored directory of an artifact for HTTP routes without an explicit location query. */ +export const locate = (kv: KV.Interface, id: ID) => + kv.get(ItemPrefix + id).pipe( + Effect.map((stored) => { + if (stored === undefined) return undefined + const decoded = decodeStoredRecord(stored) + return Option.isSome(decoded) ? decoded.value.directory : undefined + }), + ) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + const global = yield* Global.Service + const kv = yield* KV.Service + const location = yield* Location.Service + const sessions = yield* Session.Service + + const rootDir = path.join(global.data, "artifacts") + const itemDir = (id: ID) => path.join(rootDir, id) + + const localProvider: ProviderImplementation = { + id: LocalProviderID, + name: "Local", + capabilities: ["static"], + prompt: + "Artifacts are served as HTML by this OpenCode server. Only people who can reach this server can open them.", + create: (input) => + replaceDirectoryFiles(itemDir(input.id), input.files).pipe( + Effect.as({ url: `/api/experimental/artifacts/${encodeURIComponent(input.id)}/view/` }), + ), + read: (id) => readDirectoryFiles(itemDir(id)), + update: (id, input) => + replaceDirectoryFiles(itemDir(id), input.files).pipe( + Effect.as({ url: `/api/experimental/artifacts/${encodeURIComponent(id)}/view/` }), + ), + delete: (id) => Effect.promise(() => fs.rm(itemDir(id), { recursive: true, force: true })), + } + + const snapshotProviders = (data: Data) => + Array.from(data.providers.values(), (provider) => ({ + id: provider.id, + name: provider.name, + capabilities: provider.capabilities, + selection: data.selection, + })).toSorted((a, b) => a.id.localeCompare(b.id)) + + const state = State.create({ + initial: () => ({ + providers: new Map([[LocalProviderID, localProvider]]), + }), + editor: (draft) => ({ + add: (provider) => { + draft.providers.set(provider.id, provider) + }, + remove: (id) => { + if (id === LocalProviderID) return + draft.providers.delete(id) + if (draft.selection === id) draft.selection = undefined + }, + default: { + get: () => draft.selection, + set: (providerID) => { + draft.selection = providerID + }, + }, + }), + notify: State.changes(snapshotProviders, () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid)), + }) + + const isEnabled = Effect.fn("Artifact.enabled")(function* () { + const entries = yield* config.entries() + return Config.latest(entries, "experimental")?.artifacts === true + }) + + const requireEnabled = () => + isEnabled().pipe(Effect.flatMap((on) => (on ? Effect.void : Effect.fail(new DisabledError())))) + + const activeDefaultID = () => { + const data = state.get() + return data.selection && data.providers.has(data.selection) ? data.selection : LocalProviderID + } + + const resolveProvider = (requested?: ProviderID) => { + const data = state.get() + const target = requested ?? activeDefaultID() + const found = data.providers.get(target) + return found ? Effect.succeed(found) : Effect.fail(new ProviderNotFoundError({ providerID: target })) + } + + const checkSession = (sessionID?: SessionID) => + sessionID === undefined ? Effect.void : sessions.get(sessionID).pipe(Effect.asVoid) + + return Service.of({ + transform: state.transform, + reload: state.reload, + enabled: isEnabled, + providers: Effect.fn("Artifact.providers")(function* () { + yield* requireEnabled() + const defaultID = activeDefaultID() + return Array.from(state.get().providers.values(), (provider) => + Provider.make({ + id: provider.id, + name: provider.name, + capabilities: [...provider.capabilities], + default: provider.id === defaultID, + }), + ).toSorted((a, b) => Number(b.default) - Number(a.default) || a.name.localeCompare(b.name)) + }), + prompts: Effect.fn("Artifact.prompts")(function* () { + const defaultID = activeDefaultID() + const defaultProvider = state.get().providers.get(defaultID) ?? localProvider + const others = Array.from(state.get().providers.values()) + .filter((item) => item.id !== defaultProvider.id) + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((item) => ({ id: item.id, name: item.name, capabilities: item.capabilities })) + return { + defaultProvider: { + id: defaultProvider.id, + name: defaultProvider.name, + capabilities: defaultProvider.capabilities, + prompt: defaultProvider.prompt, + }, + others, + } + }), + list: Effect.fn("Artifact.list")(function* (filter) { + yield* requireEnabled() + if (filter?.sessionID !== undefined) yield* checkSession(filter.sessionID) + const items = yield* scanProjectInfos(kv, location.project.id) + return items + .filter( + (item) => + (filter?.sessionID === undefined || item.sessionID === filter.sessionID) && + (filter?.provider === undefined || item.provider === filter.provider), + ) + .toSorted((a, b) => b.time.updated - a.time.updated || b.id.localeCompare(a.id)) + }), + create: Effect.fn("Artifact.create")(function* (input) { + yield* requireEnabled() + yield* checkSession(input.sessionID) + const title = input.title.trim() + if (!title) return yield* new InvalidError({ reason: "Artifact title must not be empty", field: "title" }) + const files = yield* normalizeFiles(encodeStringFiles(input.files)) + const entry = yield* resolveEntry(files, input.entry) + const provider = yield* resolveProvider(input.provider) + const id = ID.create() + const now = yield* Clock.currentTimeMillis + const created = yield* provider + .create({ id, title, entry, files, sessionID: input.sessionID }) + .pipe(Effect.mapError((cause) => new ProviderError({ providerID: provider.id, cause }))) + const info = Info.make({ + id, + provider: provider.id, + ...(input.sessionID === undefined ? {} : { sessionID: input.sessionID }), + title, + entry, + url: created.url ?? `/api/experimental/artifacts/${encodeURIComponent(id)}/view/`, + time: { created: now, updated: now }, + }) + yield* kv.set( + ItemPrefix + id, + encodeStoredRecord({ + info, + directory: location.directory, + projectID: location.project.id, + }), + ) + return info + }), + info: Effect.fn("Artifact.info")(function* (id) { + yield* requireEnabled() + const existing = yield* loadProjectRecord(kv, id, location.project.id) + if (!existing) return yield* new NotFoundError({ artifactID: id }) + return existing.info + }), + get: Effect.fn("Artifact.get")(function* (id) { + yield* requireEnabled() + const existing = yield* loadProjectRecord(kv, id, location.project.id) + if (!existing) return yield* new NotFoundError({ artifactID: id }) + const provider = yield* resolveProvider(existing.info.provider) + const raw = yield* provider + .read(id) + .pipe(Effect.mapError((cause) => new ProviderError({ providerID: provider.id, cause }))) + return Content.make({ + info: existing.info, + files: decodeReadFiles(raw), + }) + }), + update: Effect.fn("Artifact.update")(function* (id, input) { + yield* requireEnabled() + yield* checkSession(input.sessionID) + const existing = yield* loadProjectRecord(kv, id, location.project.id) + if (!existing) return yield* new NotFoundError({ artifactID: id }) + const title = input.title === undefined ? existing.info.title : input.title.trim() + if (!title) return yield* new InvalidError({ reason: "Artifact title must not be empty", field: "title" }) + const files = yield* normalizeFiles(encodeStringFiles(input.files)) + const entry = yield* resolveEntry( + files, + input.entry ?? (files[existing.info.entry] ? existing.info.entry : undefined), + ) + const provider = yield* resolveProvider(existing.info.provider) + const now = yield* Clock.currentTimeMillis + const sessionID = input.sessionID ?? existing.info.sessionID + const updated = yield* provider + .update(id, { title, entry, files, sessionID }) + .pipe(Effect.mapError((cause) => new ProviderError({ providerID: provider.id, cause }))) + const info = Info.make({ + id, + provider: existing.info.provider, + ...(sessionID === undefined ? {} : { sessionID }), + title, + entry, + url: updated.url ?? existing.info.url, + time: { created: existing.info.time.created, updated: now }, + }) + yield* kv.set( + ItemPrefix + id, + encodeStoredRecord({ + info, + directory: existing.directory, + projectID: existing.projectID, + }), + ) + return info + }), + remove: Effect.fn("Artifact.remove")(function* (id) { + yield* requireEnabled() + const existing = yield* loadProjectRecord(kv, id, location.project.id) + if (!existing) return yield* new NotFoundError({ artifactID: id }) + const provider = state.get().providers.get(existing.info.provider) + if (provider) { + yield* provider + .delete(id) + .pipe(Effect.mapError((cause) => new ProviderError({ providerID: provider.id, cause }))) + } + yield* kv.remove(ItemPrefix + id) + }), + readLocalFile: Effect.fn("Artifact.readLocalFile")(function* (id, subpath) { + yield* requireEnabled() + const existing = yield* loadProjectRecord(kv, id, location.project.id) + if (!existing) return yield* new NotFoundError({ artifactID: id }) + if (existing.info.provider !== LocalProviderID) { + return yield* new InvalidError({ + reason: `Artifact ${id} is hosted by provider "${existing.info.provider}", not the local server`, + field: "artifactID", + }) + } + const requested = subpath?.trim() ? yield* normalizePath(subpath) : existing.info.entry + const target = path.join(itemDir(id), ...requested.split("/")) + const exists = yield* Effect.promise(() => + fs + .stat(target) + .then((stat) => stat.isFile()) + .catch(() => false), + ) + if (!exists) return yield* new FileMissingError({ artifactID: id, path: requested }) + const bytes = yield* Effect.promise(() => fs.readFile(target).then((buf) => new Uint8Array(buf))) + return { bytes, mime: detectFileMime(requested, bytes) } + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Bus.node, Config.node, Global.node, KV.node, Location.node, Session.node], +}) + +function encodeStringFiles(files: Readonly>) { + return Object.fromEntries(Object.entries(files).map(([filePath, content]) => [filePath, encoder.encode(content)])) +} + +function decodeReadFiles(files: Readonly>) { + const decoder = new TextDecoder() + return Object.fromEntries(Object.entries(files).map(([filePath, bytes]) => [filePath, decoder.decode(bytes)])) +} + +function detectFileMime(filePath: string, bytes: Uint8Array) { + const byName = lookup(filePath) + if (typeof byName === "string") { + return byName.startsWith("text/") || byName === "application/javascript" || byName === "application/json" + ? `${byName}; charset=utf-8` + : byName + } + return Mime.detect(bytes) +} + +function normalizePath(raw: string): Effect.Effect { + const trimmed = raw.replaceAll("\\", "/").replace(/^(?:\.\/)+/u, "").replace(/^\/+/u, "") + if (!trimmed || trimmed.includes("\0")) { + return Effect.fail(new InvalidError({ reason: `Invalid artifact file path: ${JSON.stringify(raw)}`, field: "files" })) + } + const segments = trimmed.split("/") + if (segments.some((part) => !part || part === "." || part === "..")) { + return Effect.fail( + new InvalidError({ reason: `Artifact file path must stay relative: ${JSON.stringify(raw)}`, field: "files" }), + ) + } + return Effect.succeed(segments.join("/")) +} + +function normalizeFiles(files: Readonly>) { + return Effect.gen(function* () { + const entries = Object.entries(files) + if (entries.length === 0) { + return yield* new InvalidError({ reason: "Artifact must include at least one file", field: "files" }) + } + const normalized = new Map() + for (const [rawPath, content] of entries) { + const clean = yield* normalizePath(rawPath) + normalized.set(clean, content) + } + return Object.fromEntries(normalized) + }) +} + +function resolveEntry(files: Readonly>, requested?: string) { + if (requested !== undefined) { + return normalizePath(requested).pipe( + Effect.flatMap((clean) => + files[clean] !== undefined + ? Effect.succeed(clean) + : Effect.fail(new InvalidError({ reason: `Entry file not found in files: ${clean}`, field: "entry" })), + ), + ) + } + if (files["index.html"] !== undefined) return Effect.succeed("index.html") + if (files["index.md"] !== undefined) return Effect.succeed("index.md") + const keys = Object.keys(files) + if (keys.length === 1 && keys[0] !== undefined) return Effect.succeed(keys[0]) + return Effect.fail( + new InvalidError({ + reason: 'Include "index.html" or "index.md", or specify "entry" when publishing multiple files', + field: "entry", + }), + ) +} + +function loadProjectRecord(kv: KV.Interface, id: ID, projectID: string) { + return kv.get(ItemPrefix + id).pipe( + Effect.map((stored) => { + if (stored === undefined) return undefined + const decoded = Option.getOrUndefined(decodeStoredRecord(stored)) + return decoded?.projectID === projectID ? decoded : undefined + }), + ) +} + +function scanProjectInfos(kv: KV.Interface, projectID: string) { + return Effect.gen(function* () { + const results: Info[] = [] + let after: string | undefined + while (true) { + const page = yield* kv.scan({ prefix: ItemPrefix, after, limit: 500 }) + results.push( + ...page.entries.flatMap((entry) => { + const decoded = decodeStoredRecord(entry.value) + return Option.isSome(decoded) && decoded.value.projectID === projectID ? [decoded.value.info] : [] + }), + ) + if (!page.next) return results + after = page.next + } + }) +} + +function replaceDirectoryFiles(baseDir: string, files: Readonly>) { + return Effect.tryPromise(async () => { + const parent = path.dirname(baseDir) + await fs.mkdir(parent, { recursive: true }) + const nonce = randomUUID() + const staging = `${baseDir}.staging-${nonce}` + const backup = `${baseDir}.backup-${nonce}` + try { + await fs.mkdir(staging, { recursive: true }) + await Promise.all( + Object.entries(files).map(async ([rel, bytes]) => { + const dest = path.join(staging, ...rel.split("/")) + await fs.mkdir(path.dirname(dest), { recursive: true }) + await fs.writeFile(dest, bytes) + }), + ) + const hadExisting = await fs + .stat(baseDir) + .then(() => true) + .catch(() => false) + if (hadExisting) await fs.rename(baseDir, backup) + try { + await fs.rename(staging, baseDir) + } catch (err) { + if (hadExisting) await fs.rename(backup, baseDir).catch(() => undefined) + throw err + } + if (hadExisting) await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined) + } catch (err) { + await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined) + throw err + } + }) +} + +function readDirectoryFiles(baseDir: string) { + return Effect.promise(async () => { + const out: Record = {} + const walk = async (current: string, prefix: string): Promise => { + const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []) + await Promise.all( + entries.map(async (entry) => { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + const full = path.join(current, entry.name) + if (entry.isDirectory()) return walk(full, rel) + if (entry.isFile()) out[rel] = new Uint8Array(await fs.readFile(full)) + }), + ) + } + await walk(baseDir, "") + return out + }) +} diff --git a/packages/core/src/codemode/execution.ts b/packages/core/src/codemode/execution.ts new file mode 100644 index 000000000000..b41e0de4e85a --- /dev/null +++ b/packages/core/src/codemode/execution.ts @@ -0,0 +1,7 @@ +export * as CodeModeExecution from "./execution.js" + +import type { Context as ToolContext } from "@opencode/schema/tool" +import { Context } from "effect" + +/** The `execute` call a Code Mode program runs under, so extensions can act for its session like a tool does. */ +export class Service extends Context.Service()("@opencode/CodeModeExecution") {} diff --git a/packages/core/src/codemode/fetch.ts b/packages/core/src/codemode/fetch.ts new file mode 100644 index 000000000000..06d163012f8e --- /dev/null +++ b/packages/core/src/codemode/fetch.ts @@ -0,0 +1,78 @@ +export * as CodeModeFetch from "./fetch.js" + +import { Extension } from "@opencode/codemode" +import { isRecord } from "@opencode/util/record" +import { Effect } from "effect" +import { Permission } from "../permission.js" +import { CodeModeExecution } from "./execution.js" + +const TIMEOUT_MS = 30_000 + +type Init = { + readonly method?: string + readonly headers?: Record | Array<[string, string]> + readonly body?: string | Uint8Array | URLSearchParams +} + +const fetch = (input: string | URL, init: Init = {}) => + Effect.gen(function* () { + const url = String(input) + if (URL.parse(url)?.protocol === "file:") + return yield* Effect.fail(new Error("fetch cannot be used for filesystem access.")) + const execution = yield* CodeModeExecution.Service + const permission = yield* Permission.Service + // The same rules as the webfetch tool, so a URL denied there is denied here. + yield* permission + .assert({ + action: "webfetch", + resources: [url], + save: ["*"], + metadata: display([input, init]), + sessionID: execution.sessionID, + agent: execution.agent, + source: { type: "tool", messageID: execution.messageID, id: execution.id }, + }) + .pipe( + // The feedback is the user talking to the model, so it must reach the program as the error's message. + Effect.catchTag("Permission.CorrectedError", (error) => + Effect.fail(new Error(`The user rejected fetching ${url}: ${error.feedback}`)), + ), + ) + return yield* Effect.tryPromise({ try: () => send(input, init), catch: (error) => error }) + }) + +const send = async (input: string | URL, init: Init) => { + const response = await globalThis.fetch(input, { + method: init.method, + headers: init.headers, + body: init.body, + signal: AbortSignal.timeout(TIMEOUT_MS), + }) + const bytes = await response.bytes() + const headers = Object.fromEntries(response.headers) + const text = () => new TextDecoder().decode(bytes) + return { + url: response.url, + status: response.status, + statusText: response.statusText, + ok: response.ok, + redirected: response.redirected, + headers: { + get: (name: string) => headers[name.toLowerCase()] ?? null, + has: (name: string) => name.toLowerCase() in headers, + entries: () => Object.entries(headers), + }, + text: async () => text(), + json: async () => JSON.parse(text()) as unknown, + bytes: async () => bytes, + } +} + +export const extension = Extension.make({ name: "fetch", globals: { fetch: Extension.async(fetch) } }) + +/** What to show for a fetch call: its method and URL. */ +export const display = (args: ReadonlyArray) => { + const [input, init] = args + const method = isRecord(init) && typeof init.method === "string" ? init.method.toUpperCase() : "GET" + return { method, url: String(input) } +} diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 977099911844..1fd67439350a 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -1,5 +1,6 @@ export * as CodeModeTool from "./tool.js" +import { ToolFailure } from "@opencode/ai/effect" import { CodeMode, Namespace, Tool } from "@opencode/codemode" import type { Content, @@ -10,10 +11,13 @@ import type { Namespace as ToolNamespace, Result, } from "@opencode/schema/tool" -import { Context, Effect, Ref, Schema, Semaphore } from "effect" +import { Clock, Context, Effect, Ref, Schema, Semaphore } from "effect" +import { toSessionError } from "../session/to-session-error.js" import { definition, normalizedName } from "../tool/runtime.js" +import { Permission } from "../permission.js" import { CodeModeCatalog } from "./catalog.js" -import { CodeModeWeb } from "./web.js" +import { CodeModeExecution } from "./execution.js" +import { CodeModeFetch } from "./fetch.js" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -80,6 +84,7 @@ const description = [ export const create = ( inventory: Inventory, executeTool: (name: string, tool: Info, input: unknown, context: ToolContext) => Effect.Effect, + permission: Permission.Interface, ) => { return { name: "execute", @@ -92,9 +97,26 @@ export const create = ( const files = yield* Ref.make>([]) const calls = yield* Ref.make>([]) const lock = Semaphore.makeUnsafe(1) + // Each update replaces the last, so send at most one per 100 ms; a trailing one carries the latest rows. + // A success drops a pending one when the scope closes, since the result carries the final rows. + const scope = yield* Effect.scope + const publishedAt = yield* Ref.make(Number.NEGATIVE_INFINITY) + const trailing = yield* Ref.make(false) + const publish = Effect.gen(function* () { + yield* Ref.set(trailing, false) + yield* Ref.set(publishedAt, yield* Clock.currentTimeMillis) + yield* context.progress({ toolCalls: yield* Ref.get(calls) }) + }) const record = (update: (items: Array) => Array) => lock.withPermit( - Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))), + Effect.gen(function* () { + yield* Ref.update(calls, update) + if (yield* Ref.get(trailing)) return + const wait = (yield* Ref.get(publishedAt)) + 100 - (yield* Clock.currentTimeMillis) + if (wait <= 0) return yield* publish + yield* Ref.set(trailing, true) + yield* Effect.sleep(wait).pipe(Effect.andThen(lock.withPermit(publish)), Effect.forkIn(scope)) + }), ) const result = yield* runtime(inventory) .execute(code) @@ -103,20 +125,28 @@ export const create = ( executeTool: (name, tool, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) - const executed = yield* executeTool(name, tool, input, context) - const content = - typeof executed.content === "string" - ? [{ type: "text" as const, text: executed.content }] - : (executed.content ?? []) + // The program sees the same failure text the model gets for a direct call. + const executed = yield* executeTool(name, tool, input, { + ...context, + // Progress replaces the execute call's own, so an inner tool's updates would hide the call rows. + progress: () => Effect.void, + }).pipe( + Effect.mapError((error) => new ToolFailure({ message: toSessionError(error).message, error })), + ) + const content = resultContent(executed) const outputFileParts = outputFiles(content) if (outputFileParts.length > 0) yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) - if (executed.output !== undefined) return executed.output - const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") - return text === "" ? null : text + return resultValue(executed) }), hooks: progressHooks(record), }), + // A failed or interrupted call keeps its last progress, so send pending rows before it settles. + Effect.onError(() => + lock.withPermit(Ref.get(trailing).pipe(Effect.flatMap((pending) => (pending ? publish : Effect.void)))), + ), + Effect.provideService(CodeModeExecution.Service, context), + Effect.provideService(Permission.Service, permission), ) const toolCalls = yield* Ref.get(calls) const collected = (yield* Ref.get(files)) @@ -147,7 +177,7 @@ export const create = ( content, metadata, } - }), + }).pipe(Effect.scoped), } satisfies Info } @@ -178,7 +208,7 @@ function progressHooks(record: (update: (items: Array) => Array { switch (call.name) { case "fetch": - return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) }) + return start(call, { tool: call.name, status: "running", input: CodeModeFetch.display(call.args) }) default: return Effect.void } @@ -232,11 +262,41 @@ function renderCatalog(root: CatalogNode): ReadonlyArray>() +const runtimes = new WeakMap>() function runtime(inventory: Inventory) { const cached = runtimes.get(inventory) if (cached) return cached + const tools = inventoryTools(inventory) + const created = CodeMode.make({ + tools, + extensions: [CodeModeFetch.extension], + hooks: runHooks, + }) + runtimes.set(inventory, created) + return created +} + +/** + * Runs one program over `inventory` alone with no model step and no `fetch`, binding `bindings` as outer globals. + */ +export const check = ( + inventory: Inventory, + code: string, + bindings: Readonly>, + limits: CodeMode.ExecutionLimits, + executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, +) => + CodeMode.make({ tools: inventoryTools(inventory), hooks: runHooks, limits }) + .execute(code, bindings) + .pipe( + Effect.provideService(Run, { + executeTool: (name, tool, input) => executeTool(name, tool, input).pipe(Effect.map(resultValue)), + hooks: {}, + }), + ) + +function inventoryTools(inventory: Inventory) { // A path may carry namespace metadata, a callable tool, child tools, or all three. const root: ToolNode = { children: new Map() } for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace @@ -249,14 +309,21 @@ function runtime(inventory: Inventory) { execute: (input) => Run.use((run) => run.executeTool(name, registration, input)), }) } - const tools = renderTools(root) - const created = CodeMode.make({ - tools, - extensions: [CodeModeWeb.extension], - hooks: runHooks, - }) - runtimes.set(inventory, created) - return created + return renderTools(root) +} + +function resultContent(executed: Result) { + return typeof executed.content === "string" + ? [{ type: "text" as const, text: executed.content }] + : (executed.content ?? []) +} + +function resultValue(executed: Result) { + if (executed.output !== undefined) return executed.output + const text = resultContent(executed) + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + return text === "" ? null : text } const runHooks = { @@ -320,7 +387,7 @@ function flattenTools(node: ToolNode, path: ReadonlyArray, tools: Tools) for (const [name, child] of node.children) flattenTools(child, [...path, name], tools) } -function qualifiedName(registration: Info) { +export function qualifiedName(registration: Info) { const normalized = normalizedName(registration) if (registration.options?.namespace === undefined) return normalized return `${registration.options.namespace}.${normalized}` diff --git a/packages/core/src/codemode/web.ts b/packages/core/src/codemode/web.ts deleted file mode 100644 index 5dba1df38f90..000000000000 --- a/packages/core/src/codemode/web.ts +++ /dev/null @@ -1,50 +0,0 @@ -export * as CodeModeWeb from "./web.js" - -import { Extension } from "@opencode/codemode" -import { isRecord } from "@opencode/util/record" - -const TIMEOUT_MS = 30_000 - -type Init = { - readonly method?: string - readonly headers?: Record | Array<[string, string]> - readonly body?: string | Uint8Array | URLSearchParams -} - -const fetch = async (input: string | URL, init: Init = {}) => { - if (URL.parse(String(input))?.protocol === "file:") - throw new Error("fetch cannot be used for filesystem access.") - const response = await globalThis.fetch(input, { - method: init.method, - headers: init.headers, - body: init.body, - signal: AbortSignal.timeout(TIMEOUT_MS), - }) - const bytes = await response.bytes() - const headers = Object.fromEntries(response.headers) - const text = () => new TextDecoder().decode(bytes) - return { - url: response.url, - status: response.status, - statusText: response.statusText, - ok: response.ok, - redirected: response.redirected, - headers: { - get: (name: string) => headers[name.toLowerCase()] ?? null, - has: (name: string) => name.toLowerCase() in headers, - entries: () => Object.entries(headers), - }, - text: async () => text(), - json: async () => JSON.parse(text()) as unknown, - bytes: async () => bytes, - } -} - -export const extension = Extension.make({ name: "web", globals: { fetch } }) - -/** What to show for a fetch call: its method and URL. */ -export const display = (args: ReadonlyArray) => { - const [input, init] = args - const method = isRecord(init) && typeof init.method === "string" ? init.method.toUpperCase() : "GET" - return { method, url: String(input) } -} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 3219c2c7647c..42be62ed7b2c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,6 +18,9 @@ import { ConfigNormalize } from "./config/normalize.js" import { ConfigDiscovery } from "./config/discovery.js" import { ConfigWatch } from "./config/watch.js" import { ConfigIntegration } from "./config/integration.js" +import { ConfigManaged } from "./config/managed.js" +import { ManagedPolicy } from "./managed-policy.js" +import { Connection } from "@opencode/schema/connection" import { WellKnown } from "./wellknown.js" import { AppProcess } from "@opencode/util/process" import { ChildProcess } from "effect/process" @@ -195,7 +198,15 @@ export const layer = (options?: Options) => env: variables, }).pipe( Effect.flatMap((text) => parseInfo(text, entry.origin)), - Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), + Effect.map((info) => + info + ? new ConfigIntegration.WellKnownDocument({ + type: "document", + info, + integrationID: entry.integrationID, + }) + : undefined, + ), ), ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) }) @@ -208,7 +219,28 @@ export const layer = (options?: Options) => Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)), ), ) - return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat())) + return yield* Effect.forEach(entries, (entry) => + loadWellknownEntry(entry).pipe( + Effect.map((documents) => ({ integrationID: entry.integrationID, documents })), + ), + ) + }) + + // `integration.use` decides whether an integration may supply anything. Only managed policies can withhold the + // documents an integration delivers, so a project file can never strip the Console document carrying org policy. + // A withheld document keeps its policies, so denying an integration never lifts the restrictions it carries. + const supplies = Effect.fnUntraced(function* ( + lockdown: ReturnType, + integrationID: string, + connection?: Connection.Info, + ) { + const stored = + connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined + const resource = ManagedPolicy.integrationResource( + integrationID, + stored ? ManagedPolicy.account(stored.value) : undefined, + ) + return ManagedPolicy.decision(lockdown, "integration.use", resource) !== "deny" }) const loadPreferences = Effect.fn("Config.loadPreferences")(function* (plist: AbsolutePath) { @@ -278,7 +310,7 @@ export const layer = (options?: Options) => .flatMap((entry) => entry.type === "document" && entry.info.policies ? [ - new Document({ + new ConfigManaged.Document({ type: "document", path: entry.path, info: new Info({ policies: entry.info.policies }), @@ -288,8 +320,8 @@ export const layer = (options?: Options) => ) .toReversed() const managedSettings = managed.map((entry) => - entry.type === "document" && entry.info.policies - ? new Document({ ...entry, info: new Info({ ...entry.info, policies: undefined }) }) + entry.type === "document" + ? new ConfigManaged.Document({ ...entry, info: new Info({ ...entry.info, policies: undefined }) }) : entry, ) // Integration documents load right after managed policies: every remote, user, and project layer overrides @@ -303,10 +335,35 @@ export const layer = (options?: Options) => Effect.orDie, Effect.map((entries) => entries.flat()), ) + const lockdown = ManagedPolicy.statements(managedPolicies) + const integrated = yield* Effect.forEach(integrations.values(), (document) => + supplies(lockdown, document.integrationID, document.connection).pipe( + Effect.map((allowed) => + allowed + ? document + : new ConfigIntegration.Document({ ...document, info: new Info({ policies: document.info.policies }) }), + ), + ), + ) + const wellknownDocuments = yield* Effect.forEach(yield* loadWellknown().pipe(Effect.orDie), (entry) => + supplies(lockdown, entry.integrationID).pipe( + Effect.map((allowed) => + allowed + ? entry.documents + : entry.documents.map( + (document) => + new ConfigIntegration.WellKnownDocument({ + ...document, + info: new Info({ policies: document.info.policies }), + }), + ), + ), + ), + ) return [ ...managedPolicies, - ...integrations.values(), - ...(yield* loadWellknown().pipe(Effect.orDie)), + ...integrated, + ...wellknownDocuments.flat(), ...globalSupplementary, ...explicit, ...direct, diff --git a/packages/core/src/config/integration.ts b/packages/core/src/config/integration.ts index 027ce7753460..d651c3b57a29 100644 --- a/packages/core/src/config/integration.ts +++ b/packages/core/src/config/integration.ts @@ -1,7 +1,7 @@ export * as ConfigIntegration from "./integration.js" import { Option, Schema, SchemaGetter } from "effect" -import { Config } from "@opencode/schema/config" +import { Config, type Entry } from "@opencode/schema/config" import { ConfigMCP } from "@opencode/schema/config/mcp" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ConfigProvider } from "@opencode/schema/config/provider" @@ -15,7 +15,7 @@ export const Policies = Schema.Array(Schema.Unknown).pipe( decode: SchemaGetter.transform((policies) => policies.filter((policy) => { const action = Schema.decodeUnknownOption(Schema.Struct({ action: Schema.String }))(policy) - if (Option.isSome(action) && !Schema.is(ConfigPolicy.Info.fields.action)(action.value.action)) return false + if (Option.isSome(action) && !Schema.is(ConfigPolicy.Action)(action.value.action)) return false const effect = Schema.decodeUnknownOption(Schema.Struct({ effect: Schema.String }))(policy) return Option.isNone(effect) || Schema.is(ConfigPolicy.Effect)(effect.value.effect) }), @@ -49,6 +49,23 @@ export class Document extends Config.Document.extend("Config.Integrati organization: Schema.optional(Schema.String), }) {} +/** + * A config document a well-known origin delivers. `integrationID` is the origin, so `integration.use` decides the + * providers it defines by the origin rather than by provider ID. + */ +export class WellKnownDocument extends Config.Document.extend("Config.WellKnownDocument")({ + integrationID: IntegrationID, +}) {} + +/** The integration whose delivered document last defines each provider. */ +export function suppliers(entries: readonly Entry[]) { + return new Map( + entries + .filter((entry): entry is WellKnownDocument => entry instanceof WellKnownDocument) + .flatMap((document) => Object.keys(document.info.providers ?? {}).map((id) => [id, document.integrationID])), + ) +} + /** * Builds an integration document from the allow-listed keys of a remote config: providers, remote MCP servers, * policies, and the web search selection. A remote source can never load plugins, run commands, or change any diff --git a/packages/core/src/config/managed.ts b/packages/core/src/config/managed.ts new file mode 100644 index 000000000000..7efb7820df61 --- /dev/null +++ b/packages/core/src/config/managed.ts @@ -0,0 +1,6 @@ +export * as ConfigManaged from "./managed.js" + +import { Config } from "@opencode/schema/config" + +/** A document from managed configuration: the system config directory or MDM managed preferences. */ +export class Document extends Config.Document.extend("Config.ManagedDocument")({}) {} diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index 48d90463e306..499e1f93fd34 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -513,6 +513,15 @@ function normalizeExperimental( ) if (value !== undefined) result.schedules = value } + if (own(experimental, "artifacts")) { + const value = decodeEncoded( + ConfigExperimental.Info.fields.artifacts, + experimental.artifacts, + ["experimental", "artifacts"], + diagnostics, + ) + if (value !== undefined) result.artifacts = value + } if (own(experimental, "subagent_depth")) { const value = decodeEncoded( ConfigExperimental.Info.fields.subagent_depth, diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index 54bce2e1cebc..27463dbb9bd1 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -1,57 +1,102 @@ export * as ConfigPolicyPlugin from "./policy.js" import { define } from "@opencode/plugin/effect/plugin" -import { Effect } from "effect" +import { Effect, Stream } from "effect" +import { Bus } from "../../bus.js" import { Config } from "../../config.js" +import { Credential } from "../../credential.js" import { ManagedPolicy } from "../../managed-policy.js" import { State } from "../../state.js" import { Wildcard } from "../../util/wildcard.js" +import { WebSearch } from "../../websearch.js" +import { ConfigIntegration } from "../integration.js" import { ConfigEntryObserver } from "./entry-observer.js" export const Plugin = define({ id: "opencode.config.policy", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const credentials = yield* Credential.Service + const websearch = yield* WebSearch.Service + const bus = yield* Bus.Service const reload = State.batch( - Effect.all([ctx.provider.reload(), ctx.mcp.reload(), ctx.skill.reload()], { discard: true }), + Effect.all([ctx.provider.reload(), ctx.mcp.reload(), ctx.skill.reload(), websearch.reload()], { + discard: true, + }), ) const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, reload) // Documents reverse so earlier-loaded policy wins: managed policy, then a Console config document, then user // and project files. const policies = () => ManagedPolicy.statements(loaded.entries) + // `integration.use` names the account behind each integration's active connection. Reading stored metadata + // never refreshes a credential. + const current = { accounts: ManagedPolicy.accounts(yield* credentials.all()) } + yield* bus.subscribe([Credential.Event.Updated, Credential.Event.Switched]).pipe( + Stream.runForEach(() => + Effect.gen(function* () { + const next = ManagedPolicy.accounts(yield* credentials.all()) + if ( + next.size === current.accounts.size && + Array.from(next).every(([id, account]) => current.accounts.get(id) === account) + ) + return + current.accounts = next + yield* State.batch(Effect.all([ctx.provider.reload(), websearch.reload()], { discard: true })) + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const integrationDenied = (statements: ReturnType, integrationID: string) => + ManagedPolicy.decision( + statements, + "integration.use", + ManagedPolicy.integrationResource(integrationID, current.accounts.get(integrationID)), + ) === "deny" const runSync = Effect.runSyncWith(yield* Effect.context()) yield* ctx.provider.transform((providers) => { - const current = policies() + const statements = policies() + // A provider a well-known origin defines belongs to that origin; others to their own integration. + const suppliers = ConfigIntegration.suppliers(loaded.entries) const denied: string[] = [] for (const record of providers.list()) { - const policy = current.findLast( - (policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource), - ) - if (policy?.effect !== "deny") continue + const provider = + ManagedPolicy.decision(statements, "provider.use", record.provider.id) === "deny" || + integrationDenied( + statements, + suppliers.get(record.provider.id) ?? record.provider.integrationID ?? record.provider.id, + ) + if (!provider) continue providers.remove(record.provider.id) // Skip unconfigured catalog entries; deny-all policies would otherwise log the whole catalog. if (record.provider.activation === "enabled") denied.push(record.provider.id) } if (denied.length) runSync(Effect.logDebug("providers denied by policy", { providers: denied })) }) + // Every built-in web search provider shares its integration's ID. + yield* websearch.transform((editor) => { + const statements = policies() + for (const provider of editor.list()) { + if (integrationDenied(statements, provider.id)) editor.remove(provider.id) + } + }) yield* ctx.mcp.transform((servers) => { - const current = policies() + const statements = policies() for (const [name] of servers.list()) { - if (ManagedPolicy.decision(current, "integration.use", `mcp:${name}`) === "deny") servers.remove(name) + if (ManagedPolicy.decision(statements, "mcp.use", name) === "deny") servers.remove(name) } }) yield* ctx.skill.transform((skills) => { - const current = policies() + const statements = policies() for (const skill of skills.list()) { - if (ManagedPolicy.decision(current, "integration.use", `skill:${skill.id}`) === "deny") skills.remove(skill.id) + if (ManagedPolicy.decision(statements, "skill.use", skill.id) === "deny") skills.remove(skill.id) } }) yield* ctx.permission.hook("evaluate", (event) => Effect.sync(() => { - const current = policies() + const statements = policies() const denied = event.resources .map((resource) => - current.findLast( + statements.findLast( (policy) => policy.action === "tool.use" && Wildcard.match(`${event.action}:${resource}`, policy.resource), ), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 1f43f4f81edc..e9d2d6d51a16 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -114,7 +114,9 @@ export const Plugin = define({ const providerID = definition.providerID for (const [id, config] of Object.entries(item.models ?? {})) { const source = definition.models.get(id) - const inherit = source?.inherit || !models.get(providerID, id) + const existing = models.get(providerID, id) + const hadVariants = (existing?.variants.length ?? 0) > 0 + const inherit = source?.inherit || !existing overlay( models, models.provider.get(providerID)?.provider.package, @@ -134,7 +136,8 @@ export const Plugin = define({ if ( config.variants === undefined && config.capabilities?.reasoning === undefined && - (!source?.base || repackaged) + // Custom models start without variants, so non-empty variants here came from an earlier transform. + ((!source?.base && !hadVariants) || repackaged) ) models.update(providerID, id, (model) => { model.variants = [ diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 2c175f6b27d2..81977a0cb619 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -58,6 +58,7 @@ import m55 from "./migration/20261026223005_schedule.js" import m56 from "./migration/20261029002625_session_part.js" import m57 from "./migration/20261029073502_session_time_busy.js" import m58 from "./migration/20261029165540_schedule_checks.js" +import m59 from "./migration/20261030054400_job.js" export const migrations = [ m00, @@ -119,4 +120,5 @@ export const migrations = [ m56, m57, m58, + m59, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20261030054400_job.ts b/packages/core/src/database/migration/20261030054400_job.ts new file mode 100644 index 000000000000..71f977163a2f --- /dev/null +++ b/packages/core/src/database/migration/20261030054400_job.ts @@ -0,0 +1,60 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20261030054400_job", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`job\` ( + \`notification_id\` text PRIMARY KEY, + \`id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`child_session_id\` text, + \`kind\` text NOT NULL, + \`title\` text, + \`status\` text NOT NULL, + \`output\` text, + \`error\` text, + \`recovery\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run(`CREATE INDEX \`job_session_idx\` ON \`job\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`job_child_session_idx\` ON \`job\` (\`child_session_id\`);`) + yield* tx.run(`CREATE INDEX \`job_id_idx\` ON \`job\` (\`id\`);`) + yield* tx.run(` + INSERT OR IGNORE INTO \`job\` ( + \`notification_id\`, \`id\`, \`session_id\`, \`child_session_id\`, \`kind\`, \`title\`, + \`status\`, \`output\`, \`error\`, \`recovery\`, \`time_created\`, \`time_updated\` + ) + SELECT + json_extract(\`value\`, '$.notificationID'), + json_extract(\`value\`, '$.id'), + COALESCE(json_extract(\`value\`, '$.recovery.sessionID'), json_extract(\`value\`, '$.recovery.parentSessionID')), + json_extract(\`value\`, '$.recovery.childSessionID'), + json_extract(\`value\`, '$.recovery.kind'), + COALESCE( + json_extract(\`value\`, '$.recovery.command'), + json_extract(\`value\`, '$.recovery.description'), + json_extract(\`value\`, '$.recovery.path') + ), + json_extract(\`value\`, '$.status'), + json_extract(\`value\`, '$.output'), + json_extract(\`value\`, '$.error'), + json_extract(\`value\`, '$.recovery'), + \`time_created\`, + \`time_updated\` + FROM \`kv\` + WHERE \`key\` LIKE 'job.background/%' + AND json_extract(\`value\`, '$.notificationID') IS NOT NULL + AND json_extract(\`value\`, '$.id') IS NOT NULL + AND COALESCE(json_extract(\`value\`, '$.recovery.sessionID'), json_extract(\`value\`, '$.recovery.parentSessionID')) IS NOT NULL; + `) + yield* tx.run(`DELETE FROM \`kv\` WHERE \`key\` LIKE 'job.background/%';`) + }) + }, +} + +export default migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index f4716c7051ea..97050930fd47 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -68,6 +68,22 @@ const schema: Omit = { CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`job\` ( + \`notification_id\` text PRIMARY KEY, + \`id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`child_session_id\` text, + \`kind\` text NOT NULL, + \`title\` text, + \`status\` text NOT NULL, + \`output\` text, + \`error\` text, + \`recovery\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(` CREATE TABLE \`kv\` ( \`key\` text PRIMARY KEY, @@ -309,6 +325,9 @@ const schema: Omit = { `) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`job_session_idx\` ON \`job\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`job_child_session_idx\` ON \`job\` (\`child_session_id\`);`) + yield* tx.run(`CREATE INDEX \`job_id_idx\` ON \`job\` (\`id\`);`) yield* tx.run( `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, ) diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 2ec73e3e48fa..19e2606dcdfb 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -1,6 +1,6 @@ export * as Generate from "./generate.js" -import { AIError, Decision, DecisionClient, Image, ImageClient, LLM, LLMClient, Media } from "@opencode/ai/effect" +import { ai, AIError, DecisionClient, ImageClient, LLMClient, Media } from "@opencode/ai/effect" import type { DecisionRequest, DecisionResult, ImageRequest, ImageResult } from "@opencode/schema/generate" import { Money } from "@opencode/schema/money" import { SessionID } from "@opencode/schema/session-id" @@ -113,7 +113,7 @@ export const layer = Layer.effect( return yield* new UnavailableError({ message: "No model specified and no supported model is available" }) const response = yield* llm .generate( - LLM.request({ + ai.request({ model: resolved.model, prompt: input.prompt, // Gateways require session attribution even for a stateless call; no Session is stored. @@ -151,8 +151,8 @@ export const layer = Layer.effect( .resolveImage(input.model) .pipe(Effect.mapError((error) => resolutionError(input.model, "image", error))) if (!model) return yield* missing(input.model, "image") - // `Image.generate` turns an invalid request into a typed error; building it with `Image.request` would throw. - const response = yield* Image.generate({ + // `ai.image.generate` turns an invalid request into a typed error; building it with `ai.image.request` would throw. + const response = yield* ai.image.generate({ model, prompt: input.prompt, images: input.images?.map((file) => Media.base64(file.data, file.mime)), @@ -190,7 +190,7 @@ export const layer = Layer.effect( .resolveDecision(input.model) .pipe(Effect.mapError((error) => resolutionError(input.model, "decision", error))) if (!model) return yield* missing(input.model, "decision") - const response = yield* Decision.generate({ + const response = yield* ai.decision.generate({ model, input: input.input.map((part) => part.type === "file" diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 6368c95a64c1..0691d6332153 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -1,5 +1,6 @@ import { Context, Effect, Layer } from "effect" import { Agent } from "./agent.js" +import { Artifact } from "./artifact.js" import { AISDK } from "./aisdk.js" import { Model } from "./model.js" import { Provider } from "./provider.js" @@ -63,6 +64,7 @@ const nodes = [ Environment.node, Config.node, Agent.node, + Artifact.node, Command.node, Reference.node, Rpc.node, diff --git a/packages/core/src/instructions/index.ts b/packages/core/src/instructions/index.ts index 315f8b785067..9c37aade9cd1 100644 --- a/packages/core/src/instructions/index.ts +++ b/packages/core/src/instructions/index.ts @@ -158,14 +158,23 @@ export function diff(observed: ReadResult, previous?: Values): Effect.Effect>) { - return render( - value.flatMap((source) => { - if (!Object.hasOwn(values, source.key)) return [] - const text = source.initial(values[source.key]) - return text === undefined ? [] : [text] - }), - ) +/** One source's rendered text, keyed so consumers can tell the sources apart. */ +export interface Rendered { + readonly key: Key + readonly text: string +} + +export function renderInitial(value: List, values: Readonly>): ReadonlyArray { + return value.flatMap((source) => { + if (!Object.hasOwn(values, source.key)) return [] + const text = source.initial(values[source.key]) + return text === undefined ? [] : [{ key: source.key, text }] + }) +} + +/** The text models see for rendered sources. */ +export function join(rendered: ReadonlyArray) { + return render(rendered.map((part) => part.text)) } export function renderUpdate( diff --git a/packages/core/src/job.ts b/packages/core/src/job.ts index 4eb0ab39d498..764b8eb74222 100644 --- a/packages/core/src/job.ts +++ b/packages/core/src/job.ts @@ -1,8 +1,10 @@ export * as Job from "./job.js" +import { asc, eq, or } from "drizzle-orm" import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect" import { makeGlobalNode } from "@opencode/util/effect/app-node" -import { KV } from "./kv.js" +import { Database } from "./database/database.js" +import { JobTable } from "./job/sql.js" import { SessionMessage } from "./session/message.js" import { SessionSchema } from "./session/schema.js" @@ -40,7 +42,6 @@ export type Recovery = Background["recovery"] export type Status = Background["status"] const decodeBackground = Schema.decodeUnknownResult(Background) -const backgroundPrefix = "job.background/" const COMPLETED_LIMIT = 25 export type Info = { @@ -138,6 +139,7 @@ export interface Interface { readonly background: (id: string) => Effect.Effect readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect readonly cancel: (id: string) => Effect.Effect + readonly cancelSession: (sessionID: SessionSchema.ID) => Effect.Effect readonly pendingBackground: Effect.Effect readonly runningBackground: Effect.Effect readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect @@ -152,6 +154,26 @@ function snapshot(job: Active): Info { } } +function ownerSessionID(recovery: Recovery): SessionSchema.ID { + return recovery.kind === "subagent" ? recovery.parentSessionID : recovery.sessionID +} + +function recoveryTitle(job: Active, recovery: Recovery): string | null { + if (job.info.title !== undefined) return job.info.title + if (recovery.kind === "shell") return recovery.command + if (recovery.kind === "subagent") return recovery.description + return recovery.path +} + +function matchesSession(job: Active, sessionID: SessionSchema.ID) { + if (job.info.metadata?.sessionID === sessionID) return true + if (job.blockingSessions.has(sessionID)) return true + if (!job.recovery) return false + if (job.recovery.kind === "subagent") + return job.recovery.parentSessionID === sessionID || job.recovery.childSessionID === sessionID + return job.recovery.sessionID === sessionID +} + function errorText(cause: Cause.Cause) { const render = (error: Error): string => { const message = error.message || error.name || "Unknown error" @@ -176,14 +198,11 @@ function decrementSession(input: Map, sessionID: Sessi } /** - * Makes one scoped, process-local registry. Explicitly recoverable background - * work also owns a durable notification marker until its notification is admitted. - * Unconsumed results survive the start-to-wait handoff. Foreground block/cancel - * and non-recoverable wait results enter a 25-entry consumed history. Recoverable - * wait results stay available for background registration and acknowledgment. + * Makes one scoped, process-local registry backed by the durable SQLite `job` table for + * backgrounded work until its completion notification is admitted. */ export const make = Effect.gen(function* () { - const kv = yield* KV.Service + const db = (yield* Database.Service).db const state: State = { jobs: yield* SynchronizedRef.make(new Map()), scope: yield* Scope.Scope, @@ -204,14 +223,36 @@ export const make = Effect.gen(function* () { const persistBackground = Effect.fnUntraced(function* (job: Active) { if (!job.recovery || !job.info.notificationID) return - yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, { + const now = yield* Clock.currentTimeMillis + const values = { + notification_id: job.info.notificationID, id: job.info.id, - notificationID: job.info.notificationID, - recovery: job.recovery, + session_id: ownerSessionID(job.recovery), + child_session_id: job.recovery.kind === "subagent" ? job.recovery.childSessionID : null, + kind: job.recovery.kind, + title: recoveryTitle(job, job.recovery), status: job.info.status, - ...(job.info.output !== undefined ? { output: job.info.output } : {}), - ...(job.info.error !== undefined ? { error: job.info.error } : {}), - }) + output: job.info.output ?? null, + error: job.info.error ?? null, + recovery: job.recovery, + time_created: job.info.started_at, + time_updated: now, + } + yield* db + .insert(JobTable) + .values(values) + .onConflictDoUpdate({ + target: JobTable.notification_id, + set: { + status: values.status, + output: values.output, + error: values.error, + recovery: values.recovery, + time_updated: now, + }, + }) + .run() + .pipe(Effect.orDie) }) const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit) { @@ -316,7 +357,7 @@ export const make = Effect.gen(function* () { }).pipe( // Recoverable wait -> background is a supported handoff, even after failure. Effect.tap((result) => - result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope), + result.info?.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope), ), ) }) @@ -415,7 +456,7 @@ export const make = Effect.gen(function* () { return result.map((item) => item.info) }) - const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) { + const cancelInternal = Effect.fnUntraced(function* (id: string, persist = true) { const completed_at = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modifyEffect( state.jobs, @@ -432,7 +473,7 @@ export const make = Effect.gen(function* () { completed_at, }, } - yield* persistBackground(next) + if (persist) yield* persistBackground(next) return [ { info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope }, new Map(jobs).set(id, next), @@ -445,15 +486,45 @@ export const make = Effect.gen(function* () { return result.info }) + const cancel: Interface["cancel"] = Effect.fn("Job.cancel")((id) => cancelInternal(id, true)) + + const cancelSession: Interface["cancelSession"] = Effect.fn("Job.cancelSession")(function* (sessionID) { + const ids = [...(yield* SynchronizedRef.get(state.jobs)).values()] + .filter((job) => job.info.status === "running" && matchesSession(job, sessionID)) + .map((job) => job.info.id) + const cancelled = yield* Effect.forEach(ids, (id) => cancelInternal(id, false)) + yield* db + .delete(JobTable) + .where(or(eq(JobTable.session_id, sessionID), eq(JobTable.child_session_id, sessionID))) + .run() + .pipe(Effect.orDie) + yield* SynchronizedRef.update(state.jobs, (jobs) => { + const next = new Map(jobs) + for (const [id, job] of jobs) { + if (matchesSession(job, sessionID)) next.delete(id) + } + return next + }) + return cancelled.filter((info): info is Info => info !== undefined) + }) + const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () { - const recovered: Background[] = [] - let after: string | undefined - do { - const page = yield* kv.scan({ prefix: backgroundPrefix, after }) - recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value))) - after = page.next - } while (after) - return recovered + const rows = yield* db + .select() + .from(JobTable) + .orderBy(asc(JobTable.time_created), asc(JobTable.notification_id)) + .all() + .pipe(Effect.orDie) + return Array.filterMap(rows, (row) => + decodeBackground({ + id: row.id, + notificationID: row.notification_id, + recovery: row.recovery, + status: row.status, + ...(row.output !== null ? { output: row.output } : {}), + ...(row.error !== null ? { error: row.error } : {}), + }), + ) }).pipe(Effect.withSpan("Job.pendingBackground")) const runningBackground: Interface["runningBackground"] = SynchronizedRef.get(state.jobs).pipe( @@ -468,7 +539,7 @@ export const make = Effect.gen(function* () { const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) => SynchronizedRef.updateEffect(state.jobs, (jobs) => Effect.gen(function* () { - yield* kv.remove(`${backgroundPrefix}${notificationID}`) + yield* db.delete(JobTable).where(eq(JobTable.notification_id, notificationID)).run().pipe(Effect.orDie) const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID) if (!entry || entry[1].info.status === "running") return jobs const next = new Map(jobs) @@ -486,6 +557,7 @@ export const make = Effect.gen(function* () { background, backgroundAll, cancel, + cancelSession, pendingBackground, runningBackground, completeBackground, @@ -494,4 +566,4 @@ export const make = Effect.gen(function* () { const layer = Layer.effect(Service, make) -export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] }) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/job/sql.ts b/packages/core/src/job/sql.ts new file mode 100644 index 000000000000..3116264fdc6e --- /dev/null +++ b/packages/core/src/job/sql.ts @@ -0,0 +1,27 @@ +import { index, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql.js" +import type { Job } from "../job.js" +import type { SessionMessage } from "../session/message.js" +import type { SessionSchema } from "../session/schema.js" + +export const JobTable = sqliteTable( + "job", + { + notification_id: text().$type().primaryKey(), + id: text().notNull(), + session_id: text().$type().notNull(), + child_session_id: text().$type(), + kind: text().notNull(), + title: text(), + status: text().$type().notNull(), + output: text(), + error: text(), + recovery: text({ mode: "json" }).$type().notNull(), + ...Timestamps, + }, + (table) => [ + index("job_session_idx").on(table.session_id), + index("job_child_session_idx").on(table.child_session_id), + index("job_id_idx").on(table.id), + ], +) diff --git a/packages/core/src/managed-policy.ts b/packages/core/src/managed-policy.ts index e7a3b018f9a0..e8bd47e600c6 100644 --- a/packages/core/src/managed-policy.ts +++ b/packages/core/src/managed-policy.ts @@ -4,6 +4,7 @@ import { Document, type Entry } from "@opencode/schema/config" import { ConfigIntegration } from "./config/integration.js" import { Wildcard } from "./util/wildcard.js" import { ConfigPolicy } from "@opencode/schema/config/policy" +import type { Credential } from "./credential.js" /** Every policy statement in config, earliest-loaded first so it wins, each with the message a denial shows. */ export function statements(entries: readonly Entry[]) { @@ -24,13 +25,41 @@ function blocked(organization: string | undefined) { return organization ? `Blocked by ${organization}'s policy` : "Blocked by your organization's policy" } -export function decision( - policies: readonly ConfigPolicy.Info[], - action: ConfigPolicy.Info["action"], +/** The statement deciding `action` on `resource`: the last match, since statements are earliest-loaded last. */ +export function match( + policies: readonly Policy[], + action: ConfigPolicy.Action, resource: string, ) { - return ( - policies.findLast((policy) => policy.action === action && Wildcard.match(resource, policy.resource))?.effect ?? - "allow" + return policies.findLast((policy) => policy.action === action && Wildcard.match(resource, policy.resource)) +} + +export function decision(policies: readonly ConfigPolicy.Info[], action: ConfigPolicy.Action, resource: string) { + return match(policies, action, resource)?.effect ?? "allow" +} + +/** + * The `integration.use` resource for what an integration supplies: `#` when a connection with + * a known account backs it, otherwise the bare integration ID (env keys, local config, the signed-out free tier). + * A bare-ID pattern such as `opencode` therefore matches only connectionless use, never an org account. `#` + * separates the two because integration IDs can be URLs. + */ +export function integrationResource(integrationID: string, account?: string) { + return account ? `${integrationID}#${account}` : integrationID +} + +/** The organization, or failing that the account, a stored credential belongs to. */ +export function account(value: Credential.Value) { + const metadata = value.metadata + if (typeof metadata?.orgID === "string" && metadata.orgID) return metadata.orgID + if (typeof metadata?.accountID === "string" && metadata.accountID) return metadata.accountID + return undefined +} + +/** The account behind each integration's active stored credential. */ +export function accounts(credentials: readonly Credential.Info[]) { + // Credentials list each integration's active credential last. + return new Map( + credentials.map((credential) => [credential.integrationID, account(credential.value)]), ) } diff --git a/packages/core/src/persistent-pty/daemon.ts b/packages/core/src/persistent-pty/daemon.ts index 21bcaab2a37f..50d2284340cb 100644 --- a/packages/core/src/persistent-pty/daemon.ts +++ b/packages/core/src/persistent-pty/daemon.ts @@ -19,6 +19,7 @@ export const WireTerminal = Schema.Struct({ id: Schema.Number, pid: Schema.NullOr(Schema.Number), title: Schema.String, + user_title: Schema.optional(Schema.NullOr(Schema.String)), foreground_process: Schema.NullOr(Schema.String), group_id: Session.ID, command: Schema.Array(Schema.String), @@ -37,6 +38,7 @@ const Registration = Schema.Struct({ protocol: Schema.Number, socket: Schema.String, token: Schema.String, + features: Schema.optional(Schema.Array(Schema.String)), }) type Registration = typeof Registration.Type @@ -46,6 +48,7 @@ export const WireResponse = Schema.Union([ instance_id: Schema.String, pid: Schema.Number, protocol: Schema.Number, + features: Schema.optional(Schema.Array(Schema.String)), }), Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }), Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }), @@ -141,6 +144,7 @@ export class DaemonError extends Data.TaggedError("PersistentPty.DaemonError")<{ export interface DaemonTransport { readonly request: (value: object, start?: boolean) => Effect.Effect readonly requestIfRunning: (value: object) => Effect.Effect + readonly supports: (feature: string) => Effect.Effect readonly shutdown: Effect.Effect readonly handoff: Effect.Effect readonly subscribe: ( @@ -223,7 +227,8 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport" response.protocol !== ProtocolVersion ) return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "opencode-pty registration mismatch" })) - return decoded + // Daemons older than the features list omit it; trust the live daemon's answer over the file. + return { ...decoded, features: response.features ?? [] } }) const spawnDaemon = Effect.fn("PersistentPty.daemon.spawn")(function* () { @@ -338,6 +343,11 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport" Effect.catch((error) => (error.kind === "connect" ? Effect.succeed(undefined) : Effect.fail(error))), ) + const supports = Effect.fn("PersistentPty.daemon.supports")(function* (feature: string) { + const current = yield* connect(false) + return current.features?.includes(feature) ?? false + }) + const shutdown = Effect.gen(function* () { const response = yield* requestIfRunning({ op: "shutdown" }) registration = undefined @@ -428,7 +438,7 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport" ) } - return { request, requestIfRunning, shutdown, handoff, subscribe } satisfies DaemonTransport + return { request, requestIfRunning, supports, shutdown, handoff, subscribe } satisfies DaemonTransport }) // Older releases kept registrations in purgeable temp directories. Renaming keeps the lock file's diff --git a/packages/core/src/persistent-pty/index.ts b/packages/core/src/persistent-pty/index.ts index 90c0014de64a..22f38818ee6f 100644 --- a/packages/core/src/persistent-pty/index.ts +++ b/packages/core/src/persistent-pty/index.ts @@ -2,10 +2,19 @@ export * as PersistentPty from "./index.js" import { stat } from "node:fs/promises" import path from "node:path" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Schema, Stream } from "effect" import { makeGlobalNode } from "@opencode/util/effect/app-node" -import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode/schema/persistent-pty" +import { + Added, + Handoff, + PersistentPty, + ReadLines, + Removed, + Updated, + type ReadResult, +} from "@opencode/schema/persistent-pty" import { Session } from "@opencode/schema/session" +import { SessionEvent } from "@opencode/schema/session-event" import { Bus } from "../bus.js" import { Pty } from "@opencode/schema/pty" import { Global } from "@opencode/util/global" @@ -63,6 +72,10 @@ export class InvalidCwdError extends Schema.TaggedError()("Pers message: Schema.String, }) {} +export class UnsupportedError extends Schema.TaggedError()("PersistentPty.UnsupportedError", { + feature: Schema.String, +}) {} + export interface Interface { readonly list: (sessionID?: Session.ID) => Effect.Effect readonly get: (id: Pty.ID) => Effect.Effect @@ -102,6 +115,10 @@ export interface Interface { rows: number, data: Uint8Array, ) => Effect.Effect + readonly rename: ( + id: Pty.ID, + title: string | null, + ) => Effect.Effect readonly snapshot: (id: Pty.ID) => Effect.Effect readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect readonly remove: (id: Pty.ID) => Effect.Effect @@ -265,6 +282,18 @@ const makeLayer = (options: Options = {}) => return undefined }) + const rename = Effect.fn("PersistentPty.rename")(function* (id: Pty.ID, title: string | null) { + const terminal = yield* get(id) + // Daemons without the feature drop the connection on unknown requests. + if (!(yield* daemon.supports("rename").pipe(Effect.mapError(unavailable)))) + return yield* new UnsupportedError({ feature: "rename" }) + const response = yield* request(daemon, { op: "rename", id: fromID(id), title }) + if (response.type !== "ok") return yield* unexpected(response) + const renamed = yield* get(id) + yield* bus.publish(Updated, { sessionID: terminal.sessionID, terminal: renamed }) + return renamed + }) + const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) { yield* get(id) const response = yield* request(daemon, { op: "snapshot", id: fromID(id) }) @@ -338,6 +367,38 @@ const makeLayer = (options: Options = {}) => ) } + const removeSession = (sessionID: Session.ID) => + list(sessionID).pipe( + Effect.flatMap((terminals) => + Effect.forEach( + terminals, + (terminal) => + remove(terminal.id).pipe( + Effect.catchTags({ + "PersistentPty.NotFoundError": () => Effect.void, + "PersistentPty.UnavailableError": (error) => + Effect.logWarning("failed to remove deleted session's terminal", { + sessionID, + id: terminal.id, + error: error.message, + }), + }), + ), + { discard: true }, + ), + ), + Effect.catchTag("PersistentPty.UnavailableError", (error) => + Effect.logWarning("failed to list deleted session's terminals", { sessionID, error: error.message }), + ), + Effect.ensuring(Effect.sync(() => current.delete(sessionID))), + ) + + // Errors are handled per event so one failed cleanup does not end the subscription. + yield* bus.subscribe(SessionEvent.Deleted).pipe( + Stream.runForEach((event) => removeSession(event.data.sessionID)), + Effect.forkScoped({ startImmediately: true }), + ) + const attach = Effect.fn("PersistentPty.attach")(function* ( id: Pty.ID, input: Parameters[1], @@ -371,6 +432,7 @@ const makeLayer = (options: Options = {}) => resize, control, input, + rename, snapshot, read, remove, @@ -416,6 +478,7 @@ function toInfo(value: WireTerminal): Info { ...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}), }), sessionID: value.group_id, + userTitle: value.user_title ?? null, foregroundProcess: value.foreground_process, size: { cols: value.cols, rows: value.rows }, output: { head: value.output_head, tail: value.output_tail }, diff --git a/packages/core/src/plugin/command/schedule.txt b/packages/core/src/plugin/command/schedule.txt index 42ac4847d084..820a36b33023 100644 --- a/packages/core/src/plugin/command/schedule.txt +++ b/packages/core/src/plugin/command/schedule.txt @@ -4,7 +4,7 @@ Request: $ARGUMENTS Use the schedule tools in the `opencode` namespace. They only see this session's schedules. -- To schedule something, call `schedule_create`. Choose `when` (`{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`, optional with `until` which defaults to every 30s). Add `until: { run: "..." }` to wait once for a shell condition (exit 0, `is`, `matches`, or `changes: true`, defaulting to a 24h timeout), or `if: { run: "..." }` to gate each recurring slot without a model call. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it wakes, with enough context to act on it without this conversation. +- To schedule something, call `schedule_create`. Choose `when` (`{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`, optional with `until` which defaults to every 30s). Add `until` (wake once then stop, 24h default timeout; matches immediately if already true) or `if` (gate each recurring slot with no model call; baselines `state` at creation without waking) using either `{ run: "..." }` for a shell check or `{ code: "..." }` for a Code Mode JavaScript check calling `tools.(...)` statically (no `fetch`, 30s / 20 calls / 32 KB caps) and returning a boolean or `{ wake, state?, message? }`. Code checks run once at creation with `state = null` and fail `schedule_create` if they throw. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it wakes, with enough context to act on it without this conversation. - To pause, resume or delete one, find its id with `schedule_list`, then call `schedule_pause`, `schedule_resume` or `schedule_delete`. - If the request is empty, call `schedule_list` and show this session's schedules: name, when, status and next run. diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 63c5b5c528fc..bcdda2389027 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -9,6 +9,7 @@ import { ServerConfig } from "@opencode/schema/mcp" import { App } from "../app.js" import { Effect, Schema, Stream } from "effect" import { Agent } from "../agent.js" +import { Artifact } from "../artifact.js" import { AISDK } from "../aisdk.js" import { Command } from "../command.js" import { Credential } from "../credential.js" @@ -55,6 +56,7 @@ export const make = Effect.fn("PluginHost.make")(function* ( ) { const app = yield* App.Metadata const agents = yield* Agent.Service + const artifacts = yield* Artifact.Service const aisdk = yield* AISDK.Service const providers = yield* Provider.Service const models = yield* Model.Service @@ -290,6 +292,31 @@ export const make = Effect.fn("PluginHost.make")(function* ( image: generate.image, decisions: generate.decisions, }, + artifact: { + reload: artifacts.reload, + transform: (callback) => + artifacts.transform((editor) => { + callback({ + add: (definition) => + editor.add({ + id: Artifact.ProviderID.make(definition.id), + name: definition.name, + capabilities: definition.capabilities, + prompt: definition.prompt, + create: definition.create, + read: definition.read, + update: definition.update, + delete: definition.delete, + }), + remove: (id) => editor.remove(Artifact.ProviderID.make(id)), + default: { + get: () => editor.default.get(), + set: (providerID) => + editor.default.set(providerID === undefined ? undefined : Artifact.ProviderID.make(providerID)), + }, + }) + }), + }, }, generate: { text: generateText, @@ -633,6 +660,7 @@ export const make = Effect.fn("PluginHost.make")(function* ( export const requirements = LayerNode.group([ App.node, Agent.node, + Artifact.node, AISDK.node, Provider.node, Model.node, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index b539d21935df..af84090eb5b2 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/http" import { Agent } from "../agent.js" +import { Artifact } from "../artifact.js" import { Model } from "../model.js" import { Provider } from "../provider.js" import { Command } from "../command.js" @@ -81,6 +82,7 @@ import { OpenCodeTools } from "../tool/plugin/opencode.js" import { QuestionTool } from "../tool/plugin/question.js" import { ReadToolFileSystem } from "../tool/read-filesystem.js" import { ReadTool } from "../tool/plugin/read.js" +import { ArtifactTools } from "../tool/plugin/artifact.js" import { ScheduleTools } from "../tool/plugin/schedule.js" import { ShellTool } from "../tool/plugin/shell.js" import { SkillTool } from "../tool/plugin/skill.js" @@ -101,6 +103,7 @@ import { GoalPlugin } from "./goal.js" import { ModelsDevPlugin } from "./models-dev.js" import { McpCodeModeDefaultsPlugin } from "./mcp-codemode-defaults.js" import { ProviderPlugins } from "./provider.js" +import { DynamicProviderPlugin } from "./provider/dynamic.js" import { OpencodePlugin } from "./provider/opencode.js" import { WebSearchPlugins } from "./websearch/index.js" import { SkillPlugin } from "./skill.js" @@ -116,6 +119,7 @@ import { WellKnownPlugin } from "../wellknown/plugin.js" const services = [ Agent.Service, + Artifact.Service, AppProcess.Service, Provider.Service, Model.Service, @@ -172,6 +176,7 @@ export type Requirements = Context.Service.Identifier<(typeof services)[number]> export const requirements = LayerNode.group([ Agent.node, + Artifact.node, AppProcess.node, Provider.node, Model.node, @@ -256,6 +261,7 @@ const pre = [ McpResourceTools.Plugin, QuestionTool.Plugin, ReadTool.Plugin, + ArtifactTools.Plugin, ScheduleTools.Plugin, ShellTool.Plugin, SkillTool.Plugin, @@ -284,6 +290,8 @@ const post = [ ConfigCompatibilityPlugin.Plugin, ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, + // Run the dynamic npm SDK loader after SDK, instance, and external plugins register their `sdk` hooks. + DynamicProviderPlugin, ConfigWebSearchPlugin.Plugin, ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 22aae51767fb..b76edff7d772 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -50,13 +50,7 @@ export const make = Effect.fn("PluginModule.make")(function* () { const version = operation.target.lastIndexOf("@") const target = path.isAbsolute(operation.target) || version <= 0 ? operation.target : operation.target.slice(0, version) - if ( - ManagedPolicy.decision( - ManagedPolicy.statements(yield* config.entries()), - "integration.use", - `plugin:${target}`, - ) === "deny" - ) + if (ManagedPolicy.decision(ManagedPolicy.statements(yield* config.entries()), "plugin.use", target) === "deny") return { blocked: true as const } return yield* load(operation, sources, watch, options) }), diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index a205d321964e..59b697bb73e8 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -5,6 +5,7 @@ import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js" import { AzurePlugin } from "./provider/azure.js" import { CerebrasPlugin } from "./provider/cerebras.js" import { ChatGPTPlugin } from "./provider/chatgpt.js" +import { ClaudeAgentPlugin } from "./provider/claude-agent/index.js" import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js" import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js" import { DatabricksPlugin } from "./provider/databricks.js" @@ -68,4 +69,5 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ DynamicProviderPlugin, // Reads the active subscription connections registered above; order it last. QuotaPlugin, + ClaudeAgentPlugin, ] diff --git a/packages/core/src/plugin/provider/claude-agent/aisdk.ts b/packages/core/src/plugin/provider/claude-agent/aisdk.ts new file mode 100644 index 000000000000..1802cb8760bf --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/aisdk.ts @@ -0,0 +1,33 @@ +export * as ClaudeAISDK from "./aisdk.js" + +import type { Context } from "@opencode/plugin/effect/plugin" +import { Effect } from "effect" +import { ClaudeQueryOptions } from "./claude/query-options.js" +import type { ClaudeSessions } from "./claude/sessions.js" +import { ClaudeAgentLanguageModel } from "./language-model/claude-agent-language-model.js" +import { ClaudeProvider } from "./provider.js" + +export const register = Effect.fn("ClaudeAISDK.register")(function* (ctx: Context, sessions: ClaudeSessions.Interface) { + const context = yield* Effect.context() + const run = Effect.runPromiseWith(context) + const dependencies: ClaudeAgentLanguageModel.Dependencies = { + run: (request, signal) => run(sessions.run(request), { signal }), + } + yield* ctx.aisdk.hook( + "sdk", + (evt) => + Effect.sync(() => { + if (evt.package !== ClaudeProvider.PACKAGE) return + // opencode spreads the model's settings into options, next to the configured headers and body + const connection = ClaudeQueryOptions.connectionEnv({ + settings: evt.options, + headers: evt.options.headers, + body: evt.options.body, + }) + evt.sdk = { + languageModel: (modelID: string) => new ClaudeAgentLanguageModel.Model(modelID, dependencies, connection), + } + }), + { providerID: ClaudeProvider.ID }, + ) +}) diff --git a/packages/core/src/plugin/provider/claude-agent/claude/agent-sdk.ts b/packages/core/src/plugin/provider/claude-agent/claude/agent-sdk.ts new file mode 100644 index 000000000000..4c37d1e89f9d --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/agent-sdk.ts @@ -0,0 +1 @@ +export * from "@anthropic-ai/claude-agent-sdk" diff --git a/packages/core/src/plugin/provider/claude-agent/claude/agent-sdk.workerd.ts b/packages/core/src/plugin/provider/claude-agent/claude/agent-sdk.workerd.ts new file mode 100644 index 000000000000..6f83c9da032a --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/agent-sdk.workerd.ts @@ -0,0 +1,4 @@ +export type * from "@anthropic-ai/claude-agent-sdk" + +// workerd cannot spawn the claude process, and the Claude Agent plugin skips itself there. +throw new Error("The Claude Agent SDK is unavailable on the workerd runtime") diff --git a/packages/core/src/plugin/provider/claude-agent/claude/fork.ts b/packages/core/src/plugin/provider/claude-agent/claude/fork.ts new file mode 100644 index 000000000000..3da0bd6c83a6 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/fork.ts @@ -0,0 +1,34 @@ +export * as ClaudeFork from "./fork.js" + +import { ClaudeSDK } from "./sdk.js" + +export interface Result { + readonly sessionID: string + readonly messages: ReadonlyMap +} + +// The SDK gives forked messages fresh UUIDs. Verify the copied prefix before mapping checkpoints onto them. +export async function create(sessionID: string, messageID: string): Promise { + const { deleteSession, forkSession, getSessionMessages } = await ClaudeSDK.load() + const source = await getSessionMessages(sessionID) + const end = source.findIndex((message) => message.uuid === messageID) + if (end < 0) return + const fork = await forkSession(sessionID, { upToMessageId: messageID }) + try { + const copied = await getSessionMessages(fork.sessionId) + if (copied.length !== end + 1) throw new Error("Claude fork did not preserve the requested history boundary") + const messages = new Map() + for (let i = 0; i < copied.length; i++) { + const before = source[i]! + const after = copied[i]! + if (before.type !== after.type || JSON.stringify(before.message) !== JSON.stringify(after.message)) { + throw new Error("Claude fork changed the copied conversation") + } + messages.set(before.uuid, after.uuid) + } + return { sessionID: fork.sessionId, messages } + } catch (error) { + await deleteSession(fork.sessionId) + throw error + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/io.ts b/packages/core/src/plugin/provider/claude-agent/claude/io.ts new file mode 100644 index 000000000000..d71878e0eae9 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/io.ts @@ -0,0 +1,32 @@ +export * as ClaudeIO from "./io.js" + +import type { Options } from "@anthropic-ai/claude-agent-sdk" +import { which } from "../../../../util/which.js" +import { ClaudeFork } from "./fork.js" +import { ClaudeSDK } from "./sdk.js" + +// Everything that reaches outside opencode. Tests swap it for a fake `claude`. +export interface Interface { + // Undefined uses the SDK's own spawn + readonly spawn?: Options["spawnClaudeCodeProcess"] + readonly which: () => string | undefined + // Before our isolation settings + readonly env: () => Record + readonly sessionExists: (sessionID: string) => Promise + readonly fork: (sessionID: string, messageID: string) => Promise + readonly remove: (sessionID: string) => Promise +} + +export const real: Interface = { + which: () => process.env.OPENCODE_CLAUDE_PATH || which("claude") || undefined, + env: () => process.env, + sessionExists: async (sessionID) => { + const { getSessionInfo } = await ClaudeSDK.load() + return (await getSessionInfo(sessionID)) !== undefined + }, + fork: ClaudeFork.create, + remove: async (sessionID) => { + const { deleteSession } = await ClaudeSDK.load() + await deleteSession(sessionID) + }, +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/probe.ts b/packages/core/src/plugin/provider/claude-agent/claude/probe.ts new file mode 100644 index 000000000000..a2f0e0c2854c --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/probe.ts @@ -0,0 +1,117 @@ +export * as ClaudeProbe from "./probe.js" + +import type { AccountInfo, ModelInfo, Options, Query } from "@anthropic-ai/claude-agent-sdk" +import { Effect, Schema } from "effect" +import { ClaudeSDK } from "./sdk.js" + +export interface Model extends ModelInfo { + contextWindow?: number +} + +export interface Result { + readonly version: string | undefined + readonly account: AccountInfo + readonly models: readonly Model[] +} + +export class ProbeError extends Schema.TaggedError()("ClaudeProbe.ProbeError", { + cause: Schema.Defect(), +}) {} + +// Older binaries silently ignore verbatimPrompts and can interpret user text as Claude commands. +const MINIMUM_VERSION = "2.1.248" +// Older binaries do not support summary-only context usage. Their models keep the conservative fallback. +const CONTEXT_DISCOVERY_VERSION = "2.1.257" + +export function versionError(version: string | undefined) { + if (version && atLeast(version, MINIMUM_VERSION)) return + const message = version + ? `Claude Code ${version} is too old; ${MINIMUM_VERSION} or later is required.` + : "Claude Code did not report its version, so compatibility could not be checked." + return new Error(`${message} Run \`claude update\`.`) +} + +// Compares release numbers like 2.1.248. A pre-release or build suffix is ignored. +function atLeast(version: string, minimum: string) { + const parse = (value: string) => value.split(/[-+]/)[0].split(".").map(Number) + const current = parse(version) + const required = parse(minimum) + const index = required.findIndex((part, position) => (current[position] ?? 0) !== part) + return index === -1 || (current[index] ?? 0) > required[index] +} + +// The input never sends a message, so no model call is made and no tokens are used. +export const run = Effect.fn("ClaudeProbe.run")(function* (options: Options) { + const stop = Promise.withResolvers() + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: async () => { + const { query } = await ClaudeSDK.load() + return query({ prompt: waitFor(stop.promise), options: { ...options, persistSession: false } }) + }, + catch: (cause) => new ProbeError({ cause }), + }), + (session) => + Effect.gen(function* () { + const init = yield* Effect.tryPromise({ + try: () => session.initializationResult(), + catch: (cause) => new ProbeError({ cause }), + }).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(new ProbeError({ cause: new Error("claude did not start within 30 seconds") })), + }), + ) + // No prompt is sent here, so an old Claude Code can still list its models. + // The version is checked when a chat starts, and that chat's error tells the user to update. + const version = init.claude_code_version + const models: Model[] = init.models.map((model) => ({ ...model })) + if (version && atLeast(version, CONTEXT_DISCOVERY_VERSION)) yield* discoverContext(session, models) + return { version, account: init.account, models } satisfies Result + }), + (session) => + Effect.sync(() => { + stop.resolve() + session.close() + }), + ) +}) + +// Sets each model's context window in place, so models discovered before a timeout keep theirs. +const discoverContext = Effect.fn("ClaudeProbe.discoverContext")( + function* (session: Query, models: readonly Model[]) { + for (const model of models) { + if (model.value === "default") continue + yield* Effect.tryPromise(async () => { + await session.setModel(model.value) + const usage = await session.getContextUsage({ detail: "summary" }) + // rawMaxTokens is capacity; maxTokens can reflect Claude's own compaction window. + if (!Number.isSafeInteger(usage.rawMaxTokens) || usage.rawMaxTokens <= 0) { + throw new Error("Claude Code did not report a valid context window") + } + model.contextWindow = usage.rawMaxTokens + }).pipe( + Effect.catch((error) => + Effect.logWarning("Claude Code context discovery failed", { + model: model.value, + cause: error.cause, + }), + ), + ) + } + }, + Effect.timeoutOrElse({ + duration: "30 seconds", + // A stuck control must not become a login failure. Closing this query cancels the pending request. + orElse: () => + Effect.logWarning("Claude Code context discovery timed out; undiscovered models use conservative limits"), + }), +) + +export function isLoggedIn(account: AccountInfo) { + return account.tokenSource !== "none" || account.apiKeySource !== undefined +} + +async function* waitFor(done: Promise): AsyncGenerator { + await done +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/query-options.ts b/packages/core/src/plugin/provider/claude-agent/claude/query-options.ts new file mode 100644 index 000000000000..ef0a9b875419 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/query-options.ts @@ -0,0 +1,109 @@ +export * as ClaudeQueryOptions from "./query-options.js" + +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" +import type { Options } from "@anthropic-ai/claude-agent-sdk" +import { ToolHooks } from "../tools/hooks.js" +import type { PendingTools } from "../tools/pending.js" +import { ToolServer } from "../tools/server.js" +import type { ClaudeIO } from "./io.js" + +// Claude Code features that duplicate opencode's. opencode's version wins (specs/v2/claude-agent-provider.md, "Turned Off"). +const ISOLATION_ENV = { + CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1", + CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", + DISABLE_AUTO_COMPACT: "1", + CLAUDE_CODE_ENABLE_AWAY_SUMMARY: "0", + ENABLE_TOOL_SEARCH: "false", + CLAUDE_CODE_DISABLE_ADVISOR_TOOL: "1", + // Raised so opencode's own truncation applies instead + MAX_MCP_OUTPUT_TOKENS: "200000", + // Skips OAuth, so the subscription would not be used + CLAUDE_CODE_SIMPLE: undefined, + // A shell-level effort override would take precedence over OpenCode's live effort settings. + CLAUDE_CODE_EFFORT_LEVEL: undefined, + // Caps every OpenCode tool call, which would fail subagents and permission prompts that run longer + MCP_TOOL_TIMEOUT: undefined, +} + +export function connectionEnv(connection: { + readonly settings?: Readonly> + readonly headers?: Readonly> + readonly body?: Readonly> +}): Record { + const env: Record = {} + const settings = connection.settings ?? {} + if (typeof settings.baseURL === "string") env.ANTHROPIC_BASE_URL = settings.baseURL + if (typeof settings.apiKey === "string") env.ANTHROPIC_API_KEY = settings.apiKey + if (typeof settings.authToken === "string") env.ANTHROPIC_AUTH_TOKEN = settings.authToken + if (typeof settings.headerTimeout === "number") env.API_TIMEOUT_MS = String(settings.headerTimeout) + if (typeof settings.chunkTimeout === "number") env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = String(settings.chunkTimeout) + const headers: string[] = [] + for (const [name, value] of Object.entries(connection.headers ?? {})) { + headers.push(`${name}: ${value}`) + } + if (headers.length > 0) env.ANTHROPIC_CUSTOM_HEADERS = headers.join("\n") + if (connection.body !== undefined && Object.keys(connection.body).length > 0) { + env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify(connection.body) + } + return env +} + +export function base(input: { + executable: string + io: ClaudeIO.Interface + connection?: Readonly> +}): Options { + return { + pathToClaudeCodeExecutable: input.executable, + spawnClaudeCodeProcess: input.io.spawn, + env: { ...input.io.env(), ...input.connection, ...ISOLATION_ENV }, + settingSources: [], + settings: { disableAllHooks: true }, + // Without summaries, OpenCode has no reasoning text to display + thinking: { type: "adaptive", display: "summarized" }, + strictMcpConfig: true, + skills: [], + tools: [], + permissionMode: "dontAsk", + allowedTools: ["mcp__opencode__*"], + verbatimPrompts: true, + } +} + +export async function session(input: { + readonly executable: string + readonly io: ClaudeIO.Interface + readonly connection?: Readonly> + readonly directory: string + readonly model: string + readonly effort?: Options["effort"] + readonly maxOutputTokens?: number + readonly system: NonNullable + readonly tools: readonly LanguageModelV3FunctionTool[] + readonly pending: PendingTools.Interface + // Side calls run without saving a transcript + readonly persist: boolean + readonly resume?: string +}): Promise { + const options: Options = { + ...base(input), + cwd: input.directory, + model: input.model, + effort: input.effort, + systemPrompt: input.system, + includePartialMessages: true, + persistSession: input.persist, + hooks: ToolHooks.make(input.pending), + mcpServers: {}, + } + if (input.maxOutputTokens !== undefined) { + options.env = { ...options.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(input.maxOutputTokens) } + } + if (input.tools.length > 0) options.mcpServers = { opencode: await ToolServer.make(input.tools, input.pending) } + if (input.resume !== undefined) { + options.resume = input.resume + return options + } + options.sessionId = crypto.randomUUID() + return options +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/sdk.ts b/packages/core/src/plugin/provider/claude-agent/claude/sdk.ts new file mode 100644 index 000000000000..959f7ab33b54 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/sdk.ts @@ -0,0 +1,17 @@ +export * as ClaudeSDK from "./sdk.js" + +import type * as SDK from "@anthropic-ai/claude-agent-sdk" + +// Loaded on first use, so opencode starts without loading the SDK. +let sdk: Promise | undefined + +export function load() { + sdk ??= import("#claude-agent-sdk").catch((cause) => { + // Let the next request try again, for example after the SDK is installed. + sdk = undefined + throw new Error("Claude Agent SDK isn't installed. Install @anthropic-ai/claude-agent-sdk to use Claude Agent.", { + cause, + }) + }) + return sdk +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/session.ts b/packages/core/src/plugin/provider/claude-agent/claude/session.ts new file mode 100644 index 000000000000..afdf9acdde97 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/session.ts @@ -0,0 +1,162 @@ +export * as ClaudeSession from "./session.js" + +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" +import type { Options, SDKMessage, SDKResultMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk" +import { ToolNames } from "../tools/names.js" +import { ClaudeProbe } from "./probe.js" +import { ClaudeSDK } from "./sdk.js" + +interface Turn { + readonly drained: PromiseWithResolvers + readonly messages: SDKMessage[] + wake?: () => void + finished: boolean +} + +export type Interface = Awaited> + +export async function start(options: Options) { + const { query } = await ClaudeSDK.load() + let input!: ReadableStreamDefaultController + const prompt = new ReadableStream({ + start(controller) { + input = controller + }, + }) + const session = query({ prompt, options }) + let turn: Turn | undefined + let initialized = false + let ended = false + let failure: unknown + let stopping: Promise | undefined + let persisted = false + const ready = session.initializationResult().then((init): ClaudeProbe.Result => { + const error = ClaudeProbe.versionError(init.claude_code_version) + if (error) throw error + initialized = true + return { version: init.claude_code_version!, account: init.account, models: init.models } + }) + // A cancel during startup can stop the session before anything awaits ready. + ready.catch(() => {}) + + function finishTurn() { + if (!turn) return + turn.finished = true + turn.drained.resolve() + } + + // The only reader of the SDK iterator, including while a turn is being cancelled. + async function readAll() { + try { + for await (const message of session) { + if (turn) { + turn.messages.push(message) + turn.wake?.() + } + if (message.type === "assistant" || message.type === "result") persisted = true + if (message.type === "result") finishTurn() + } + } catch (error) { + failure = error + } finally { + ended = true + finishTurn() + turn?.wake?.() + } + } + const pump = readAll() + + async function interrupt() { + const active = turn + if (!active || active.finished || ended) return + await session.interrupt() + // interrupt() resolves before the result and transcript flush, so wait for the drain before another turn starts. + await active.drained.promise + } + + async function shutdown() { + try { + if (initialized && !ended) { + await session.interrupt() + if (turn && !turn.finished) await turn.drained.promise + } + input.close() + // An uninitialized process has no conversation to flush. + if (!initialized) session.close() + await pump + } finally { + session.close() + } + } + + function stop() { + if (!stopping) stopping = shutdown() + return stopping + } + + function begin(content: SDKUserMessage["message"]["content"], uuid = crypto.randomUUID()) { + if (ended || stopping) throw failure ?? new Error("Claude Code session has stopped") + if (turn && !turn.finished) throw new Error("Claude Code is still finishing the previous turn") + turn = { drained: Promise.withResolvers(), messages: [], finished: false } + input.enqueue({ type: "user", uuid, message: { role: "user", content }, parent_tool_use_id: null }) + } + + async function readStep( + emit: (message: SDKMessage) => void, + tools: readonly LanguageModelV3FunctionTool[], + ): Promise { + const active = turn + if (!active) throw new Error("No Claude turn to read") + let calledTools = false + while (true) { + const message = active.messages.shift() + if (!message) { + if (ended) throw failure ?? new Error("Claude Code stopped before finishing its reply") + await new Promise((resolve) => { + active.wake = resolve + }) + continue + } + emit(message) + if (message.type === "assistant" && message.parent_tool_use_id === null) { + for (const part of message.message.content) { + // Calls to tools we did not offer get Claude Code's own error, and the turn continues without OpenCode. + if (part.type === "tool_use" && ToolNames.original(part.name, tools)) calledTools = true + } + } + const replyEnded = + message.type === "stream_event" && message.parent_tool_use_id === null && message.event.type === "message_stop" + if (calledTools && replyEnded) return "tools" + if (message.type === "result") return message + } + } + + return { + id: options.resume ?? options.sessionId!, + ready, + get ended() { + return ended + }, + get persisted() { + return persisted + }, + get running() { + return !!turn && !turn.finished + }, + begin, + readStep, + addUser(content: SDKUserMessage["message"]["content"], uuid: NonNullable) { + input.enqueue({ + type: "user", + uuid, + priority: "next", + message: { role: "user", content }, + parent_tool_use_id: null, + }) + }, + interrupt, + stop, + setModel: (model: string) => session.setModel(model), + setEffort: (effort: Options["effort"]) => session.applyFlagSettings({ effortLevel: effort ?? null }), + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/sessions.ts b/packages/core/src/plugin/provider/claude-agent/claude/sessions.ts new file mode 100644 index 000000000000..b9204b05766d --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/sessions.ts @@ -0,0 +1,467 @@ +export * as ClaudeSessions from "./sessions.js" + +import type { Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk" +import type { + LanguageModelV3FunctionTool, + LanguageModelV3Prompt, + LanguageModelV3TextPart, + LanguageModelV3ToolCallPart, +} from "@ai-sdk/provider" +import { Effect, Fiber, Semaphore } from "effect" +import { ClaudeIO } from "./io.js" +import type { ClaudeStatus } from "./status.js" +import { ClaudeQueryOptions } from "./query-options.js" +import { ClaudeSession } from "./session.js" +import { SessionRecord } from "../sync/record.js" +import { Checkpoints } from "../sync/checkpoints.js" +import { SyncDecision } from "../sync/decide.js" +import { CatchUp } from "../sync/catch-up.js" +import { ConvertToClaudeMessage } from "../language-model/convert-to-claude-message.js" +import { PendingTools } from "../tools/pending.js" +import { ToolNames } from "../tools/names.js" +import { ToolResult } from "../tools/result.js" + +export const HANDOVER_MS = 10_000 +export const IDLE_MS = 5 * 60_000 + +export interface Request { + // Only primary calls have one. Side calls run in a throwaway process. + readonly sessionID?: string + // Native OpenCode fork lineage, or the session supplying a compaction/generate snapshot. + readonly sourceID?: string + readonly compaction?: boolean + readonly model: string + readonly effort?: Options["effort"] + readonly maxOutputTokens?: number + readonly connection?: Readonly> + readonly system: NonNullable + readonly prompt: LanguageModelV3Prompt + readonly tools: readonly LanguageModelV3FunctionTool[] + readonly emit: (message: SDKMessage) => void +} + +interface LiveProcess { + readonly session: ClaudeSession.Interface + readonly pending: PendingTools.Interface + model: string + effort?: Options["effort"] + // Settings that need a new process to change. Model and effort change on the running one. + readonly launch: string + waiting?: string[] +} + +interface ToolContinuation { + readonly end: number + readonly results: Array<{ id: string; result: ToolResult.Result }> +} + +interface Entry { + readonly lock: Semaphore.Semaphore + process?: LiveProcess + record?: SessionRecord.Record + idle?: Fiber.Fiber + active?: Fiber.Fiber +} + +export interface Interface { + readonly run: (request: Request) => Effect.Effect + // OpenCode stopped running the session, so a turn still waiting for tool results will not get them. + readonly idle: (sessionID: string) => Effect.Effect +} + +const launch = (request: Request) => + JSON.stringify([request.maxOutputTokens, request.connection, request.system, request.tools]) + +const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error))) +const attempt = (run: () => Promise) => Effect.tryPromise({ try: run, catch: toError }) +const attemptSync = (run: () => A) => Effect.try({ try: run, catch: toError }) + +export function registry() { + const entries = new Map() + // Side calls share startup/draining with chat, but are tracked only until their request ends. + const temporary = new Set() + let owners = 0 + let handover: Fiber.Fiber | undefined + + const interrupt = Effect.fn("ClaudeSessions.interrupt")(function* (entry: Entry) { + const process = entry.process + if (!process) return + yield* attempt(() => process.session.interrupt()) + process.pending.clear() + process.waiting = undefined + }, Effect.uninterruptible) + + const stop = Effect.fn("ClaudeSessions.stop")(function* (entry: Entry) { + const process = entry.process + if (!process) return + yield* attempt(() => process.session.stop()).pipe( + Effect.ensuring( + Effect.sync(() => { + process.pending.clear() + entry.process = undefined + }), + ), + ) + }, Effect.uninterruptible) + + const armIdle = Effect.fn("ClaudeSessions.armIdle")(function* (entry: Entry) { + if (entry.idle) yield* Fiber.interrupt(entry.idle) + entry.idle = yield* Effect.sleep(IDLE_MS).pipe( + Effect.andThen(entry.lock.withPermit(stop(entry))), + Effect.catch((error) => Effect.logWarning(error)), + Effect.forkDetach, + ) + }) + + const closeEntries = Effect.fn("ClaudeSessions.closeEntries")(function* () { + for (const entry of [...entries.values(), ...temporary]) { + if (entry.active) yield* Fiber.interrupt(entry.active) + // The active turn's finalizer can install a new idle timer. + if (entry.idle) yield* Fiber.interrupt(entry.idle) + yield* entry.lock.withPermit(stop(entry)).pipe(Effect.catch((error) => Effect.logWarning(error))) + } + entries.clear() + }) + + const cancelHandover = Effect.fn("ClaudeSessions.cancelHandover")(function* () { + if (!handover) return + yield* Fiber.interrupt(handover) + handover = undefined + }) + + const close = Effect.fn("ClaudeSessions.close")(function* () { + yield* cancelHandover() + yield* closeEntries() + }) + + const attach = Effect.fn("ClaudeSessions.attach")(function* (input: { + readonly io: ClaudeIO.Interface + readonly status: ClaudeStatus.Interface + readonly storage: SessionRecord.Storage + readonly directory: string + }) { + const records = SessionRecord.make(input.storage) + owners++ + yield* cancelHandover() + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + owners-- + if (owners !== 0) return + if (entries.size === 0 && temporary.size === 0) return + handover = yield* Effect.sleep(HANDOVER_MS).pipe(Effect.andThen(closeEntries()), Effect.forkDetach) + }), + ) + + const open = Effect.fn("ClaudeSessions.open")(function* (entry: Entry, request: Request) { + const starting = !entry.process + // A cancel can only land while waiting for ready, so a started process is always recorded and stopped. + const opened = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if (!entry.process) { + const executable = input.io.which() + if (!executable) { + yield* input.status.report({ type: "missing" }) + return yield* Effect.fail(new Error("Install Claude Code and run `claude auth login` first")) + } + const pending = PendingTools.make() + const session = yield* attempt(async () => + ClaudeSession.start( + await ClaudeQueryOptions.session({ + executable, + io: input.io, + connection: request.connection, + directory: input.directory, + model: request.model, + effort: request.effort, + maxOutputTokens: request.maxOutputTokens, + system: request.system, + tools: request.tools, + pending, + persist: request.sessionID !== undefined, + resume: entry.record?.claudeSessionID, + }), + ), + ) + entry.process = { session, pending, model: request.model, effort: request.effort, launch: launch(request) } + } + const process = entry.process + const probe = yield* restore( + attempt(() => process.session.ready).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(new Error("claude did not start within 30 seconds")), + }), + ), + ).pipe(Effect.onError(() => stop(entry).pipe(Effect.ignore))) + return { process, probe } + }), + ) + const process = opened.process + // A new process reports its version, login and models, which keeps the model list current without a separate check. + if (starting) yield* input.status.report({ type: "ready", probe: opened.probe }) + // Apply controls before sending input or releasing parked tool results. + if (process.model !== request.model) { + yield* attempt(() => process.session.setModel(request.model)) + process.model = request.model + } + if (process.effort !== request.effort) { + yield* attempt(() => process.session.setEffort(request.effort)) + process.effort = request.effort + } + return process + }) + + const turn = Effect.fn("ClaudeSessions.turn")(function* ( + entry: Entry, + request: Request, + start: number, + continuation?: ToolContinuation, + ) { + // Validate before opening a process: a rejected rebuild must not pair an empty process with the old record. + const content = yield* attemptSync(() => { + if (request.sessionID || entry.record) return CatchUp.content(request.prompt, continuation?.end ?? start) + return ConvertToClaudeMessage.content(request.prompt) + }) + const live = yield* open(entry, request) + const session = live.session + + const addsUserMessage = !continuation || content.length > 0 + const userID = addsUserMessage ? crypto.randomUUID() : undefined + + const reply: Array = [] + const toolMessages = new Map() + const seen = new Set() + let messageID: string | undefined + const waiting: string[] = [] + + const emit = (message: SDKMessage) => { + if (message.type === "system" && message.subtype === "init" && request.tools.length > 0) { + const connected = message.mcp_servers.some( + (server) => server.name === "opencode" && server.status === "connected", + ) + if (!connected) throw new Error("The OpenCode tool server did not connect to Claude Code") + } + if ( + message.type === "assistant" && + message.parent_tool_use_id === null && + !message.error && + !seen.has(message.uuid) + ) { + seen.add(message.uuid) + messageID = message.uuid + for (const part of message.message.content) { + if (part.type === "text") { + reply.push({ type: "text", text: part.text }) + continue + } + if (part.type !== "tool_use") continue + const name = ToolNames.original(part.name, request.tools) + if (!name) continue + live.pending.expect(part.id, part.name) + waiting.push(part.id) + reply.push({ type: "tool-call", toolCallId: part.id, toolName: name, input: part.input }) + } + } + if (message.type === "user" && message.uuid && Array.isArray(message.message.content)) { + for (const part of message.message.content) { + if (part.type === "tool_result") toolMessages.set(part.tool_use_id, message.uuid) + } + } + request.emit(message) + } + + let completed = false + let interrupted = false + let started = false + let parked = false + const step = Effect.gen(function* () { + if (!continuation) session.begin(content, userID) + if (continuation) { + if (userID !== undefined) session.addUser(content, userID) + for (const item of continuation.results) { + live.pending.resolve(item.id, item.result) + } + } + started = true + const outcome = yield* attempt(() => session.readStep(emit, request.tools)) + parked = outcome === "tools" + completed = true + live.waiting = parked ? waiting : undefined + }) + + // A tool boundary ends only the OpenCode step; the Claude turn waits for the results. Anything else interrupts and drains it. + const interruptUnlessParked = Effect.gen(function* () { + if (parked) return + yield* interrupt(entry).pipe(Effect.orDie) + }) + + const saveRecord = Effect.gen(function* () { + if (!request.sessionID || !session.persisted || !started) return + const history = + completed || interrupted + ? Checkpoints.build({ + prompt: request.prompt, + previous: entry.record, + userID, + reply: completed ? reply : [], + messageID: completed ? messageID : undefined, + toolMessages, + }) + : undefined + entry.record = { claudeSessionID: session.id, history, interrupted: interrupted && !completed } + yield* records.set(request.sessionID, entry.record) + }) + + yield* step.pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + interrupted = true + }), + ), + Effect.ensuring(interruptUnlessParked), + Effect.ensuring(saveRecord), + ) + }) + + const release = Effect.fn("ClaudeSessions.release")(function* (entry: Entry, request: Request) { + entry.active = undefined + if (!request.sessionID) { + yield* stop(entry).pipe(Effect.catch((error) => Effect.logWarning(error))) + const sessionID = entry.record?.claudeSessionID + if (sessionID) + yield* attempt(() => input.io.remove(sessionID)).pipe(Effect.catch((error) => Effect.logWarning(error))) + temporary.delete(entry) + return + } + // A turn waiting for tool results is still busy. Subagents, permission prompts and questions can take longer + // than IDLE_MS, so idle() starts the timer once OpenCode stops running the session. + if (entry.process?.waiting) return + yield* armIdle(entry) + }) + + const branch = Effect.fn("ClaudeSessions.branch")(function* ( + entry: Entry, + request: Request, + source?: SessionRecord.Record, + ) { + const boundary = SyncDecision.boundary(request.prompt, source) + if (!source || !boundary) return 0 + // Validate catch-up input before creating a forked transcript on disk. + yield* attemptSync(() => CatchUp.content(request.prompt, boundary.start)) + const fork = yield* attempt(() => input.io.fork(source.claudeSessionID, boundary.messageID)) + if (!fork) return 0 + const history = boundary.history.map((checkpoint) => ({ + hash: checkpoint.hash, + claudeMessageID: checkpoint.claudeMessageID ? fork.messages.get(checkpoint.claudeMessageID) : undefined, + })) + entry.record = { claudeSessionID: fork.sessionID, history } + return boundary.start + }, Effect.uninterruptible) + + const snapshot = Effect.fn("ClaudeSessions.snapshot")(function* (entry: Entry, request: Request) { + const sourceID = request.sourceID + if (!sourceID) return 0 + const source = entries.get(sourceID) + const copy = Effect.gen(function* () { + // Compaction ends the old primary context. Generate only takes a read-only snapshot. + if (request.compaction && source) yield* stop(source) + const record = source?.record ?? (yield* records.get(sourceID)) + return yield* branch(entry, request, record) + }) + // Hold the source only while copying its settled prefix, never during side-call inference. + if (!source) return yield* copy + return yield* source.lock.withPermit(copy) + }) + + const prepare = Effect.fn("ClaudeSessions.prepare")(function* (entry: Entry, request: Request) { + // Retire an exited or incompatible process first: once we continue its parked turn, open() must not replace it. + if (entry.process) { + const needsRestart = entry.process.session.ended || entry.process.launch !== launch(request) + if (needsRestart) yield* stop(entry) + } + + if (!request.sessionID) { + const start = yield* snapshot(entry, request) + return { start, continuation: undefined } + } + + if (!entry.record) entry.record = yield* records.get(request.sessionID) + const decision = SyncDecision.decide(request.prompt, entry.record) + const start = yield* Effect.gen(function* () { + if (decision.kind === "fresh" || decision.kind === "changed") { + yield* stop(entry) + const previous = entry.record + entry.record = undefined + if (decision.kind === "changed") return yield* branch(entry, request, previous) + if (!previous) return yield* snapshot(entry, request) + return decision.start + } + if (entry.process || !entry.record) return decision.start + const sessionID = entry.record.claudeSessionID + const exists = yield* attempt(() => input.io.sessionExists(sessionID)) + if (exists) return decision.start + entry.record = undefined + return 0 + }) + + const live = entry.process + if (!live || !live.session.running || !live.waiting) return { start, continuation: undefined } + const collected = yield* attemptSync(() => ToolResult.collect(request.prompt, start)) + const continuation: ToolContinuation = { end: collected.end, results: [] } + for (const id of live.waiting) { + const result = collected.results.get(id) + if (!result) { + yield* stop(entry) + entry.record = undefined + return { start: 0, continuation: undefined } + } + continuation.results.push({ id, result }) + } + if (collected.aborted) { + // A stopped OpenCode turn needs a fresh Claude turn; addUser would only steer the old one. + yield* interrupt(entry) + return { start, continuation: undefined } + } + return { start, continuation } + }) + + const entryFor = (request: Request) => { + if (request.sessionID) { + const existing = entries.get(request.sessionID) + if (existing) return existing + const entry: Entry = { lock: Semaphore.makeUnsafe(1) } + entries.set(request.sessionID, entry) + return entry + } + const entry: Entry = { lock: Semaphore.makeUnsafe(1) } + temporary.add(entry) + return entry + } + + const run = Effect.fn("ClaudeSessions.run")(function* (request: Request) { + const entry = entryFor(request) + yield* entry.lock.withPermit( + Effect.gen(function* () { + if (entry.idle) { + yield* Fiber.interrupt(entry.idle) + entry.idle = undefined + } + entry.active = yield* Effect.withFiber(Effect.succeed) + const prepared = yield* prepare(entry, request) + yield* turn(entry, request, prepared.start, prepared.continuation) + }).pipe(Effect.ensuring(release(entry, request))), + ) + }) + + const idle = Effect.fn("ClaudeSessions.idle")(function* (sessionID: string) { + const entry = entries.get(sessionID) + // Other processes already have a timer, and a running request starts one when it ends. + if (!entry?.process?.waiting || entry.active) return + yield* armIdle(entry) + }) + + return { run, idle } satisfies Interface + }) + + return { attach, close } +} diff --git a/packages/core/src/plugin/provider/claude-agent/claude/status.ts b/packages/core/src/plugin/provider/claude-agent/claude/status.ts new file mode 100644 index 000000000000..8711c5883037 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/claude/status.ts @@ -0,0 +1,50 @@ +export * as ClaudeStatus from "./status.js" + +import { Effect, Semaphore, SubscriptionRef } from "effect" +import type { ClaudeIO } from "./io.js" +import { ClaudeProbe } from "./probe.js" +import { ClaudeQueryOptions } from "./query-options.js" + +export type State = + | { readonly type: "checking" } + | { readonly type: "missing" } + | { readonly type: "failed"; readonly message: string } + | { readonly type: "ready"; readonly probe: ClaudeProbe.Result } + +export interface Interface { + readonly state: SubscriptionRef.SubscriptionRef + readonly refresh: () => Effect.Effect + // What a chat's own process found when it started + readonly report: (next: State) => Effect.Effect +} + +export const make = Effect.fn("ClaudeStatus.make")(function* ( + io: ClaudeIO.Interface, + connection: () => Effect.Effect>, +) { + const state = yield* SubscriptionRef.make({ type: "checking" }) + const lock = yield* Semaphore.make(1) + + // Serialized so a reconnect checks again after startup, rather than racing it with an older account snapshot. + const refresh = Effect.fn("ClaudeStatus.refresh")(function* () { + const executable = io.which() + if (!executable) return yield* SubscriptionRef.set(state, { type: "missing" }) + const env = yield* connection() + const next = yield* ClaudeProbe.run(ClaudeQueryOptions.base({ executable, io, connection: env })).pipe( + Effect.map((probe): State => ({ type: "ready", probe })), + Effect.catch((error) => + Effect.logWarning("Claude Code login check failed", { cause: error.cause }).pipe( + Effect.as({ + type: "failed", + message: error.cause instanceof Error ? error.cause.message : String(error.cause), + }), + ), + ), + ) + yield* SubscriptionRef.set(state, next) + }, lock.withPermit) + + const report = (next: State) => SubscriptionRef.set(state, next) + + return { state, refresh, report } satisfies Interface +}) diff --git a/packages/core/src/plugin/provider/claude-agent/index.ts b/packages/core/src/plugin/provider/claude-agent/index.ts new file mode 100644 index 000000000000..ea217f5e29d0 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/index.ts @@ -0,0 +1,48 @@ +import { define } from "@opencode/plugin/effect/plugin" +import { Effect, Option, Stream } from "effect" +import { ClaudeIO } from "./claude/io.js" +import { ClaudeSessions } from "./claude/sessions.js" +import { ClaudeStatus } from "./claude/status.js" +import { ClaudeAISDK } from "./aisdk.js" +import { ClaudeInstructions } from "./instructions.js" +import { ClaudeIntegration } from "./integration.js" +import { ClaudeProvider } from "./provider.js" +import { ClaudeSessionHooks } from "./session.js" +import { Environment } from "../../../environment/index.js" +import { EnvironmentUnavailable } from "../../../environment/unavailable.js" +import { Location } from "../../../location.js" +import type { PluginInternal } from "../../internal.js" + +export const ID = "opencode.provider.claude-agent" + +// Tests pass a fake ClaudeIO here. The registry lives outside setup's scope, so a reload can adopt its processes. +// Restart tests pass their own and close it, as an exiting opencode would. +export const make = (io: ClaudeIO.Interface, registry = ClaudeSessions.registry()) => + define({ + id: ID, + effect: Effect.fn("ClaudeAgent.Plugin")(function* (ctx) { + // Claude Agent runs the local `claude` binary, so skip it where opencode cannot start local processes. + const environment = yield* Effect.serviceOption(Environment.Service) + const location = yield* Effect.serviceOption(Location.Service) + if (Option.isSome(location) && location.value.workspaceID !== undefined) return + if (Option.isSome(environment) && environment.value.spawner === EnvironmentUnavailable.spawner) return + + const status = yield* ClaudeStatus.make(io, () => ClaudeProvider.connection(ctx)) + const sessions = yield* registry.attach({ io, status, storage: ctx.storage, directory: ctx.location.directory }) + yield* ctx.event + .subscribe({ + types: ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"], + }) + .pipe( + Stream.runForEach((event) => sessions.idle(event.data.sessionID)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* ClaudeProvider.register(ctx, status, io) + yield* ClaudeIntegration.register(ctx, status) + yield* ClaudeAISDK.register(ctx, sessions) + yield* ClaudeSessionHooks.register(ctx) + yield* ClaudeInstructions.register(ctx) + }), + } satisfies PluginInternal.InternalPlugin) + +export const ClaudeAgentPlugin = make(ClaudeIO.real) diff --git a/packages/core/src/plugin/provider/claude-agent/instructions.ts b/packages/core/src/plugin/provider/claude-agent/instructions.ts new file mode 100644 index 000000000000..74f7e8a90529 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/instructions.ts @@ -0,0 +1,54 @@ +export * as ClaudeInstructions from "./instructions.js" + +import type { SystemPart } from "@opencode/ai/effect" +import type { Context } from "@opencode/plugin/effect/plugin" +import { Effect, Option, Schema } from "effect" +import { ClaudeSystemPrompt } from "./language-model/system-prompt.js" +import { ClaudeProvider } from "./provider.js" + +const KINDS = ["context", "generate", "compaction"] as const + +const InstructionsMetadata = Schema.Struct({ + experimental: Schema.Struct({ + instructions: Schema.Array(Schema.Struct({ key: Schema.String, text: Schema.String })), + }), +}) + +// Claude Code writes its own date and environment, so OpenCode's would repeat them. +const SUPPLIED_BY_CLAUDE = new Set(["core/date", "core/environment"]) + +export const register = Effect.fn("ClaudeInstructions.register")(function* (ctx: Context) { + yield* Effect.forEach( + KINDS, + (kind) => + ctx.session.hook( + kind, + (event) => + Effect.gen(function* () { + const agent = yield* ctx.agent.get({ agentID: event.agent }).pipe(Effect.orDie) + // Read the role from the resolved agent, not by guessing which prompt text is a built-in. + event.options[ClaudeSystemPrompt.AGENT_OPTION] = agent.data.system ?? "" + event.options[ClaudeSystemPrompt.INSTRUCTIONS_OPTION] = instructions(event.system) + }), + { providerID: ClaudeProvider.ID }, + ), + { discard: true }, + ) +}) + +// The first part is OpenCode's own prompt, which Claude Code's prompt replaces. +function instructions(system: ReadonlyArray) { + const texts: string[] = [] + for (const part of system.slice(1)) { + const metadata = Schema.decodeUnknownOption(InstructionsMetadata)(part.metadata) + if (Option.isNone(metadata)) { + texts.push(part.text) + continue + } + for (const source of metadata.value.experimental.instructions) { + if (SUPPLIED_BY_CLAUDE.has(source.key)) continue + texts.push(source.text) + } + } + return texts.join("\n\n") +} diff --git a/packages/core/src/plugin/provider/claude-agent/integration.ts b/packages/core/src/plugin/provider/claude-agent/integration.ts new file mode 100644 index 000000000000..3e6f09490af5 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/integration.ts @@ -0,0 +1,69 @@ +export * as ClaudeIntegration from "./integration.js" + +import type { Context } from "@opencode/plugin/effect/plugin" +import { Effect, Stream, SubscriptionRef } from "effect" +import { Bus } from "../../../bus.js" +import { Credential } from "../../../credential.js" +import { Integration } from "../../../integration.js" +import type { IntegrationConnection } from "../../../integration/connection.js" +import { ClaudeProbe } from "./claude/probe.js" +import type { ClaudeStatus } from "./claude/status.js" +import { ClaudeProvider } from "./provider.js" + +export const METHOD_ID = "claude-login" + +// The login method only records that the user chose Claude Code. The login itself belongs to `claude`, and the plugin +// never sees a token (specs/v2/claude-agent-provider.md, "Login"). +export const register = Effect.fn("ClaudeIntegration.register")(function* ( + ctx: Context, + status: ClaudeStatus.Interface, +) { + yield* ctx.integration.transform((editor) => { + editor.update(ClaudeProvider.ID, (integration) => { + integration.name = "Claude Agent" + }) + editor.method.update({ + integrationID: ClaudeProvider.ID, + method: { id: METHOD_ID, type: "external", label: "Claude Code login (claude auth login)" }, + }) + }) + + yield* SubscriptionRef.changes(status.state).pipe( + Stream.runForEach((state) => + Effect.gen(function* () { + if (state.type === "checking") return + const connection = yield* ctx.integration.connection.active(ClaudeProvider.ID) + if (!connection) return + yield* ctx.integration.connection.status({ + integrationID: ClaudeProvider.ID, + connection, + status: problem(state), + }) + }), + ), + Effect.forkScoped, + ) + + // Check again when the user connects. opencode keeps a problem per connection, so a new connection starts with + // none, and the user may have just run `claude auth login`. + const bus = yield* Bus.Service + yield* bus.subscribe(Credential.Event.Switched).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make(ClaudeProvider.ID)), + Stream.runForEach(() => status.refresh()), + Effect.forkScoped({ startImmediately: true }), + ) +}) + +function problem(state: ClaudeStatus.State): IntegrationConnection.Status | undefined { + if (state.type === "missing") + return { + status: "needs_auth", + message: "Claude Code is not installed. Install it, then run `claude auth login`.", + url: "https://code.claude.com/docs/en/setup", + } + // The message already says what went wrong with Claude Code. + if (state.type === "failed") return { status: "needs_auth", message: state.message } + if (state.type === "ready" && !ClaudeProbe.isLoggedIn(state.probe.account)) + return { status: "needs_auth", message: "Claude Code is not logged in. Run `claude auth login` in a terminal." } + return undefined +} diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/claude-agent-error.ts b/packages/core/src/plugin/provider/claude-agent/language-model/claude-agent-error.ts new file mode 100644 index 000000000000..df3a8b91e1e1 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/claude-agent-error.ts @@ -0,0 +1,41 @@ +export * as ClaudeAgentError from "./claude-agent-error.js" + +import type { SDKAssistantMessageError, SDKMessage, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk" + +// Keep request-local diagnostics until the terminal result. A usage warning alone +// is not a failed request, and Claude Code owns any retries before this result. +export function make() { + let code: SDKAssistantMessageError | undefined + let rejected: SDKRateLimitInfo | undefined + return (message: SDKMessage): Error | undefined => { + if (message.type === "rate_limit_event") { + rejected = message.rate_limit_info.status === "rejected" ? message.rate_limit_info : undefined + } + if (message.type === "assistant" && message.parent_tool_use_id === null && message.error) code = message.error + if (message.type !== "result") return + + const status = message.subtype === "success" ? (message.api_error_status ?? undefined) : undefined + const httpError = status !== undefined && status >= 400 + if (message.subtype === "success" && !message.is_error && !httpError) return + const result = (message.subtype === "success" ? message.result : message.errors.join("\n")).replaceAll( + "Please run /login", + "Run `claude auth login` in a terminal", + ) + const failure = + status !== undefined && status >= 500 + ? (status === 529 ? "Anthropic is overloaded (529)." : `Anthropic returned HTTP ${status}.`) + + " Claude Code exhausted its retries. Try again shortly." + : result + const limited = code === "rate_limit" || (rejected && status === 429) + const window = rejected?.rateLimitType?.replaceAll("_", " ") + const limit = + limited && !failure ? `Claude Code subscription limit reached${window ? ` (${window})` : ""}.` : failure + const reset = limited && rejected?.resetsAt ? ` Resets at ${new Date(rejected.resetsAt * 1000).toISOString()}.` : "" + const billing = code === "billing_error" ? " Check your Claude plan and extra-usage settings." : "" + const text = limit + reset + billing + + if (text) return new Error(text) + if (status) return new Error(`Claude Code could not finish its reply (HTTP ${status})`) + return new Error("Claude Code could not finish its reply") + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/claude-agent-language-model.ts b/packages/core/src/plugin/provider/claude-agent/language-model/claude-agent-language-model.ts new file mode 100644 index 000000000000..6d743a2a34c6 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/claude-agent-language-model.ts @@ -0,0 +1,113 @@ +export * as ClaudeAgentLanguageModel from "./claude-agent-language-model.js" + +import type { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3FunctionTool, + LanguageModelV3StreamResult, +} from "@ai-sdk/provider" +import type { ClaudeSessions } from "../claude/sessions.js" +import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk" +import { Provider } from "@opencode/schema/provider" +import { ConvertClaudeStream } from "./convert-claude-stream.js" +import { RequestKind } from "./request-kind.js" +import { ClaudeSystemPrompt } from "./system-prompt.js" + +export interface Dependencies { + readonly run: (request: ClaudeSessions.Request, signal: AbortSignal) => Promise +} + +export class Model implements LanguageModelV3 { + readonly specificationVersion = "v3" + readonly provider = Provider.ID.claudeAgent + readonly supportedUrls = {} + + constructor( + readonly modelId: string, + readonly dependencies: Dependencies, + readonly connection: Readonly> = {}, + ) {} + + async doGenerate(_options: LanguageModelV3CallOptions): Promise { + throw new Error("Claude Agent models only support streaming") + } + + async doStream(options: LanguageModelV3CallOptions): Promise { + const abort = new AbortController() + const signal = options.abortSignal ? AbortSignal.any([abort.signal, options.abortSignal]) : abort.signal + const tools = + options.toolChoice?.type === "none" + ? [] + : (options.tools ?? []).filter((tool): tool is LanguageModelV3FunctionTool => tool.type === "function") + const headers = options.headers ?? {} + const kind = headers[RequestKind.HEADER] + const session = headers[RequestKind.SESSION_HEADER] + const settings = options.providerOptions?.[Provider.ID.claudeAgent] + const role = settings?.[ClaudeSystemPrompt.AGENT_OPTION] + const agent = typeof role === "string" ? role : undefined + const loaded = settings?.[ClaudeSystemPrompt.INSTRUCTIONS_OPTION] + const instructions = typeof loaded === "string" ? loaded : undefined + const effort = parseEffort(settings?.effort) + const primary = kind === "primary" + const branch = kind === "generate" || kind === "compaction" + const sessionID = primary ? session : undefined + // Conversation branches copy their source session and keep the Claude prompt. OpenCode supplies their task as user input. + const sourceID = primary ? headers[RequestKind.FORK_HEADER] : branch ? session : undefined + // Branches offer the chat's tools and system prompt so Claude reuses the chat's prompt cache. + const offered = primary || branch ? tools : [] + const convert = ConvertClaudeStream.make(offered) + // Titles are standalone tasks, so OpenCode's system prompt replaces Claude Code's. + const system = + primary || branch ? ClaudeSystemPrompt.make(agent, instructions) : ClaudeSystemPrompt.utility(options.prompt) + let cancelled = false + let task: Promise + const stream = new ReadableStream({ + start: (controller) => { + controller.enqueue({ type: "stream-start", warnings: [] }) + task = this.dependencies + .run( + { + sessionID, + sourceID, + compaction: kind === "compaction", + model: this.modelId, + effort, + maxOutputTokens: options.maxOutputTokens, + connection: this.connection, + system, + prompt: options.prompt, + tools: offered, + emit: (message) => { + if (cancelled) return + for (const part of convert(message)) controller.enqueue(part) + }, + }, + signal, + ) + .then( + () => { + if (!cancelled) controller.close() + }, + (error) => { + if (!cancelled) controller.error(error) + }, + ) + }, + cancel: () => { + cancelled = true + abort.abort() + return task + }, + }) + return { stream } + } +} + +const EFFORTS: readonly EffortLevel[] = ["low", "medium", "high", "xhigh", "max"] + +function parseEffort(value: unknown): EffortLevel | undefined { + if (value === undefined) return undefined + const effort = EFFORTS.find((effort) => value === effort) + if (effort !== undefined) return effort + throw new Error("Claude Agent effort must be low, medium, high, xhigh or max") +} diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts b/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts new file mode 100644 index 000000000000..eb1f4fb333d6 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/convert-claude-stream.ts @@ -0,0 +1,133 @@ +export * as ConvertClaudeStream from "./convert-claude-stream.js" + +import type { LanguageModelV3FunctionTool, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk" +import { Provider } from "@opencode/schema/provider" +import { ClaudeUsage } from "./usage.js" +import { ToolNames } from "../tools/names.js" +import { ClaudeAgentError } from "./claude-agent-error.js" + +export function make(tools: readonly LanguageModelV3FunctionTool[]) { + const usage = ClaudeUsage.make() + const failure = ClaudeAgentError.make() + const streamed = new Set() + const blocks = new Map() + let messageID = "" + let calledTools = false + let metadata: Record | undefined + + return (message: SDKMessage): LanguageModelV3StreamPart[] => { + const parts: LanguageModelV3StreamPart[] = [] + const error = failure(message) + if (error) return [{ type: "error", error }] + if (message.type === "stream_event" && message.parent_tool_use_id === null) { + const event = message.event + switch (event.type) { + case "message_start": + messageID = event.message.id + metadata = undefined + streamed.add(messageID) + usage.update(messageID, event.message.usage) + parts.push({ type: "response-metadata", id: messageID, modelId: event.message.model }) + break + case "content_block_start": { + const block = event.content_block + const id = `${messageID}:${event.index}` + if (block.type === "text") { + blocks.set(event.index, "text") + parts.push({ type: "text-start", id }) + if (block.text) parts.push({ type: "text-delta", id, delta: block.text }) + break + } + if (block.type === "thinking") { + blocks.set(event.index, "reasoning") + parts.push({ type: "reasoning-start", id }) + if (block.thinking) parts.push({ type: "reasoning-delta", id, delta: block.thinking }) + } + break + } + case "content_block_delta": { + const id = `${messageID}:${event.index}` + const delta = event.delta + if (delta.type === "text_delta") { + parts.push({ type: "text-delta", id, delta: delta.text }) + break + } + if (delta.type === "thinking_delta" && delta.thinking) { + parts.push({ type: "reasoning-delta", id, delta: delta.thinking }) + } + break + } + case "content_block_stop": { + const kind = blocks.get(event.index) + if (kind) parts.push({ type: `${kind}-end`, id: `${messageID}:${event.index}`, providerMetadata: metadata }) + blocks.delete(event.index) + break + } + case "message_delta": + usage.update(messageID, event.usage) + break + case "message_stop": + // Must match ClaudeSession.readStep's tool boundary, or OpenCode's step ends without a finish event. + if (calledTools) { + parts.push({ + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool_use" }, + usage: usage.snapshot(), + }) + } + break + } + } + if (message.type === "assistant" && message.parent_tool_use_id === null) { + // Synthetic API errors are diagnostics, not assistant answers. + if (message.error) return parts + // Stream events carry API message IDs; only the complete assistant message carries the transcript UUID. + metadata = { [Provider.ID.claudeAgent]: { sessionID: message.session_id, messageID: message.uuid } } + usage.update(message.message.id, message.message.usage) + for (const block of message.message.content) { + if (block.type !== "tool_use") continue + const toolName = ToolNames.original(block.name, tools) + if (!toolName) continue + calledTools = true + parts.push({ + type: "tool-call", + toolCallId: block.id, + toolName, + input: JSON.stringify(block.input), + providerMetadata: metadata, + }) + } + // Complete assistant messages repeat the streamed content. Only use them when nothing was streamed. + if (!streamed.has(message.message.id)) { + for (let i = 0; i < message.message.content.length; i++) { + const block = message.message.content[i]! + const id = `${message.uuid}:${i}` + if (block.type === "text") { + parts.push({ type: "text-start", id }) + parts.push({ type: "text-delta", id, delta: block.text }) + parts.push({ type: "text-end", id, providerMetadata: metadata }) + continue + } + if (block.type === "thinking") { + parts.push({ type: "reasoning-start", id }) + parts.push({ type: "reasoning-delta", id, delta: block.thinking }) + parts.push({ type: "reasoning-end", id }) + } + } + } + } + if (message.type === "result") { + if (message.num_turns === 1) usage.result(message.usage) + parts.push({ + type: "finish", + finishReason: { + unified: message.stop_reason === "max_tokens" ? "length" : "stop", + raw: message.stop_reason ?? undefined, + }, + usage: usage.snapshot(), + }) + } + return parts + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/convert-to-claude-message.ts b/packages/core/src/plugin/provider/claude-agent/language-model/convert-to-claude-message.ts new file mode 100644 index 000000000000..4bfadc5eb1e1 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/convert-to-claude-message.ts @@ -0,0 +1,55 @@ +export * as ConvertToClaudeMessage from "./convert-to-claude-message.js" + +import type { LanguageModelV3FilePart, LanguageModelV3Prompt } from "@ai-sdk/provider" +import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk" + +export type ContentBlock = Exclude[number] + +// Claude can't be given earlier turns, so their roles become text tags. Attachments stay real content blocks. +export function content(prompt: LanguageModelV3Prompt): ContentBlock[] { + const blocks: ContentBlock[] = [] + const history = prompt.some((message) => message.role === "assistant" || message.role === "tool") + for (const message of prompt) { + if (message.role === "system") continue + if (history) blocks.push({ type: "text", text: `<${message.role}>` }) + for (const part of message.content) { + if (part.type === "text") { + blocks.push({ type: "text", text: part.text }) + continue + } + if (part.type === "file") { + blocks.push(file(part)) + continue + } + if (part.type === "tool-call") { + const call = JSON.stringify({ id: part.toolCallId, name: part.toolName, input: part.input }) + blocks.push({ type: "text", text: `\n${call}\n` }) + continue + } + if (part.type === "tool-result") { + const result = JSON.stringify({ id: part.toolCallId, name: part.toolName, output: part.output }) + blocks.push({ type: "text", text: `\n${result}\n` }) + } + } + if (history) blocks.push({ type: "text", text: `` }) + } + return blocks +} + +function file(part: LanguageModelV3FilePart): ContentBlock { + const type = part.mediaType + if (type === "application/pdf") { + if (part.data instanceof URL) return { type: "document", source: { type: "url", url: part.data.href } } + return { type: "document", source: { type: "base64", media_type: type, data: base64(part.data) } } + } + if (type === "image/png" || type === "image/jpeg" || type === "image/gif" || type === "image/webp") { + if (part.data instanceof URL) return { type: "image", source: { type: "url", url: part.data.href } } + return { type: "image", source: { type: "base64", media_type: type, data: base64(part.data) } } + } + throw new Error(`Claude Code does not support ${type} attachments`) +} + +function base64(data: string | Uint8Array) { + if (typeof data === "string") return data + return Buffer.from(data).toString("base64") +} diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/request-kind.ts b/packages/core/src/plugin/provider/claude-agent/language-model/request-kind.ts new file mode 100644 index 000000000000..efc0813041c3 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/request-kind.ts @@ -0,0 +1,5 @@ +export * as RequestKind from "./request-kind.js" + +export const HEADER = "x-opencode-claude-agent-kind" +export const SESSION_HEADER = "x-opencode-session-id" +export const FORK_HEADER = "x-opencode-claude-agent-fork-source" diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/system-prompt.ts b/packages/core/src/plugin/provider/claude-agent/language-model/system-prompt.ts new file mode 100644 index 000000000000..2e7f00c9c4ef --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/system-prompt.ts @@ -0,0 +1,28 @@ +export * as ClaudeSystemPrompt from "./system-prompt.js" + +import type { Options } from "@anthropic-ai/claude-agent-sdk" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" + +export const AGENT_OPTION = "claudeCodeAgent" +export const INSTRUCTIONS_OPTION = "claudeCodeInstructions" + +const primary = { + type: "preset", + preset: "claude_code", + // Agent changes must take effect when a process resumes the same native session. + snapshot: false, + append: + "You are running in OpenCode through the Claude Code harness. Mention this only when asked.", +} satisfies Options["systemPrompt"] + +export function make(role?: string, instructions?: string) { + const blocks = [primary.append] + if (role) blocks.push("# Agent role (from OpenCode)\n" + role) + if (instructions) blocks.push(instructions) + return { ...primary, append: blocks.join("\n\n") } +} + +export function utility(prompt: LanguageModelV3Prompt) { + const blocks = prompt.filter((message) => message.role === "system").map((message) => message.content) + return blocks.join("\n\n") || "You are a text generation helper. Return only the requested output." +} diff --git a/packages/core/src/plugin/provider/claude-agent/language-model/usage.ts b/packages/core/src/plugin/provider/claude-agent/language-model/usage.ts new file mode 100644 index 000000000000..ae6d64b016ed --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/language-model/usage.ts @@ -0,0 +1,58 @@ +export * as ClaudeUsage from "./usage.js" + +import type { LanguageModelV3Usage } from "@ai-sdk/provider" + +interface Usage { + readonly input_tokens?: number | null + readonly cache_read_input_tokens?: number | null + readonly cache_creation_input_tokens?: number | null + readonly output_tokens?: number + readonly output_tokens_details?: { readonly thinking_tokens?: number } | null +} + +export function make() { + const messages = new Map() + let last: string | undefined + + function update(id: string, usage: Usage) { + const saved = messages.get(id) + const counts = saved ?? { input: 0, read: 0, write: 0, output: 0, reasoning: 0 } + if (!saved) { + messages.set(id, counts) + last = id + } + counts.input = Math.max(counts.input, usage.input_tokens ?? 0) + counts.read = Math.max(counts.read, usage.cache_read_input_tokens ?? 0) + counts.write = Math.max(counts.write, usage.cache_creation_input_tokens ?? 0) + counts.output = Math.max(counts.output, usage.output_tokens ?? 0) + counts.reasoning = Math.max(counts.reasoning, usage.output_tokens_details?.thinking_tokens ?? 0) + } + + return { + update, + result(usage: Usage) { + // Result usage sums the whole turn; only apply it when there was a single model call. + if (messages.size <= 1) update(last ?? "result", usage) + }, + snapshot(): LanguageModelV3Usage { + let output = 0 + let reasoning = 0 + for (const counts of messages.values()) { + output += counts.output + reasoning += counts.reasoning + } + // Context occupancy is the last API call's input, not the sum of repeated contexts. + const lastCall = last === undefined ? undefined : messages.get(last) + const inputTotal = lastCall ? lastCall.input + lastCall.read + lastCall.write : undefined + return { + inputTokens: { + total: inputTotal, + noCache: lastCall?.input, + cacheRead: lastCall?.read, + cacheWrite: lastCall?.write, + }, + outputTokens: { total: output, reasoning, text: output - reasoning }, + } + }, + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/provider.ts b/packages/core/src/plugin/provider/claude-agent/provider.ts new file mode 100644 index 000000000000..15ed23e5203d --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/provider.ts @@ -0,0 +1,141 @@ +export * as ClaudeProvider from "./provider.js" + +import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk" +import type { Context } from "@opencode/plugin/effect/plugin" +import { Effect, Option, Schema, Stream, SubscriptionRef } from "effect" +import { Model } from "../../../model.js" +import { Provider } from "../../../provider.js" +import type { ClaudeIO } from "./claude/io.js" +import type { ClaudeProbe } from "./claude/probe.js" +import { ClaudeQueryOptions } from "./claude/query-options.js" +import type { ClaudeStatus } from "./claude/status.js" + +export const ID = Provider.ID.claudeAgent +export const PACKAGE = "@opencode/claude-agent" + +// The model list we keep between starts, so opencode can list models without running `claude` +const CatalogModel = Schema.Struct({ + id: Schema.String, + name: Schema.String, + efforts: Schema.Array(Schema.String), + contextWindow: Schema.optionalKey(Schema.Number), + resolvedModel: Schema.optionalKey(Schema.String), +}) +type CatalogModel = typeof CatalogModel.Type +const Catalog = Schema.Struct({ + priceSource: Schema.optionalKey(Provider.ID), + models: Schema.Array(CatalogModel), +}) +type Catalog = typeof Catalog.Type +const CATALOG_KEY = "models" + +// Mantle is Bedrock serving Anthropic's API, and opencode drops most of its `anthropic.…` IDs from Bedrock's list, +// so it uses Anthropic's prices. +const PRICE_SOURCES: Partial, Provider.ID>> = { + firstParty: Provider.ID.anthropic, + anthropicAws: Provider.ID.anthropic, + anthropicGoogleCloud: Provider.ID.anthropic, + mantle: Provider.ID.anthropic, + bedrock: Provider.ID.amazonBedrock, + vertex: Provider.ID.googleVertex, + foundry: Provider.ID.azure, +} + +export const register = Effect.fn("ClaudeProvider.register")(function* ( + ctx: Context, + status: ClaudeStatus.Interface, + io: ClaudeIO.Interface, +) { + const saved = Schema.decodeUnknownOption(Catalog)(yield* ctx.storage.get(CATALOG_KEY)) + const current = { catalog: Option.getOrElse(saved, (): Catalog => ({ models: [] })) } + + yield* ctx.provider.transform((editor) => { + const source = current.catalog.priceSource + const prices = source ? editor.get(source)?.models : undefined + editor.add({ + info: { + id: ID, + name: "Claude Agent", + activation: "auto", + package: Provider.aisdk(PACKAGE), + }, + models: current.catalog.models.map((model) => toModel(model, prices)), + }) + }) + + yield* SubscriptionRef.changes(status.state).pipe( + Stream.runForEach((state) => + Effect.gen(function* () { + if (state.type !== "ready") return + const baseURL = (yield* connection(ctx)).ANTHROPIC_BASE_URL ?? io.env().ANTHROPIC_BASE_URL + const next = { + priceSource: priceSource(state.probe.account, baseURL), + models: fromClaude(state.probe.models, current.catalog.models), + } + if (JSON.stringify(next) === JSON.stringify(current.catalog)) return + current.catalog = next + yield* ctx.storage.set(CATALOG_KEY, current.catalog) + yield* ctx.provider.reload() + }), + ), + Effect.forkScoped, + ) + + // Without a saved list there is no model to pick, so the first message that would fill it can never be sent. + if (current.catalog.models.length === 0 && (yield* ctx.integration.connection.active(ID))) { + yield* status.refresh().pipe(Effect.forkScoped) + } +}) + +export const connection = Effect.fn("ClaudeProvider.connection")(function* (ctx: Context) { + const provider = yield* ctx.provider.get({ providerID: ID }).pipe(Effect.option) + if (Option.isNone(provider)) return {} + const info = provider.value.data + return ClaudeQueryOptions.connectionEnv({ settings: info.settings, headers: info.headers, body: info.body }) +}) + +// Context windows only come from the full check, so a chat's startup report keeps the saved ones. +function fromClaude(models: readonly ClaudeProbe.Model[], saved: readonly CatalogModel[]): CatalogModel[] { + const known = new Map() + for (const model of saved) known.set(model.id, model.contextWindow) + const result: CatalogModel[] = [] + for (const model of models) { + // "default" follows Claude Code's own default, which can change under a running opencode session + if (model.value === "default") continue + result.push({ + id: model.value, + name: model.displayName, + efforts: model.supportedEffortLevels ?? [], + contextWindow: model.contextWindow ?? known.get(model.value), + resolvedModel: model.resolvedModel, + }) + } + return result +} + +// A subscription covers usage, and a custom base URL or an enterprise gateway can bill anything, so they show no cost. +function priceSource(account: AccountInfo, baseURL: string | undefined) { + if (baseURL) return + if (account.apiProvider === undefined) return + if (account.apiProvider === "firstParty" && account.apiKeySource === undefined) return + return PRICE_SOURCES[account.apiProvider] +} + +function toModel(model: CatalogModel, prices: ReadonlyMap | undefined): Model.Info { + const defaults = Model.Info.default(ID, Model.ID.make(model.id)) + // The 1M context version's price tiers are on the base model + const id = (model.resolvedModel ?? model.id).replace(/\[1m\]$/, "") + // Mantle names models `anthropic.claude-…`, and Vertex lists current models as `claude-…@default` + const priced = prices?.get(id) ?? prices?.get(id.replace(/^anthropic\./, "")) ?? prices?.get(`${id}@default`) + return { + ...defaults, + name: model.name, + // The SDK reports context windows before a prompt, but not output limits. 0 means unknown in OpenCode. + limit: { context: model.contextWindow ?? defaults.limit.context, output: 0 }, + // OpenCode picks the title model by family (Model.small). + family: model.id.includes("haiku") ? Model.Family.make("claude-haiku") : undefined, + capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] }, + variants: model.efforts.map((effort) => ({ id: Model.VariantID.make(effort), settings: { effort } })), + cost: priced?.cost ?? [], + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/session.ts b/packages/core/src/plugin/provider/claude-agent/session.ts new file mode 100644 index 000000000000..6a6b9640d948 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/session.ts @@ -0,0 +1,55 @@ +export * as ClaudeSessionHooks from "./session.js" + +import type { Context } from "@opencode/plugin/effect/plugin" +import { Effect, Schema } from "effect" +import { RequestKind } from "./language-model/request-kind.js" +import { ClaudeProvider } from "./provider.js" + +// OpenCode records stopped tools (including a plain permission rejection) with this structured error. +const isAborted = Schema.is(Schema.Struct({ error: Schema.Struct({ type: Schema.Literal("aborted") }) })) + +export const register = Effect.fn("ClaudeSessionHooks.register")(function* (ctx: Context) { + yield* ctx.session.hook( + "model.request", + (event) => + Effect.gen(function* () { + event.headers[RequestKind.HEADER] = event.kind + if (event.kind !== "primary") return + const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie) + if (session.fork) event.headers[RequestKind.FORK_HEADER] = session.fork.sessionID + }), + { providerID: ClaudeProvider.ID }, + ) + yield* ctx.session.hook( + "context", + (event) => + Effect.sync(() => { + // Let Claude choose its normal output budget. Later hooks can still set an explicit maxTokens. + // Compaction has its own hook, so it keeps OpenCode's summary budget. + delete event.options.maxTokens + // Preserve tool error status before the AI SDK bridge flattens error results to plain text. + event.messages = event.messages.map((message) => { + const content = message.content.map((part) => { + if (part.type !== "tool-result" || part.result.type !== "error") return part + const flags = { + ...part.providerMetadata?.[ClaudeProvider.ID], + toolError: true, + toolAborted: isAborted(part.result.value), + } + return { ...part, providerMetadata: { ...part.providerMetadata, [ClaudeProvider.ID]: flags } } + }) + return { ...message, content } + }) + }), + { providerID: ClaudeProvider.ID }, + ) + // Claude Code already retries API failures. Replaying the whole turn here also retries permanent billing errors. + yield* ctx.session.hook( + "retry", + (event) => + Effect.sync(() => { + event.decision = { retry: false } + }), + { providerID: ClaudeProvider.ID }, + ) +}) diff --git a/packages/core/src/plugin/provider/claude-agent/sync/catch-up.ts b/packages/core/src/plugin/provider/claude-agent/sync/catch-up.ts new file mode 100644 index 000000000000..2b7de43840d1 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/sync/catch-up.ts @@ -0,0 +1,21 @@ +export * as CatchUp from "./catch-up.js" + +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import { ConvertToClaudeMessage } from "../language-model/convert-to-claude-message.js" +import { Fingerprint } from "./fingerprint.js" + +export function content(prompt: LanguageModelV3Prompt, start = 0): ConvertToClaudeMessage.ContentBlock[] { + const missed = prompt.slice(start) + const end = Fingerprint.groups(missed).findLast((group) => group.role !== "user")?.end ?? 0 + const blocks: ConvertToClaudeMessage.ContentBlock[] = [] + if (end > 0) { + blocks.push({ + type: "text", + text: "\nThe following is conversation history from OpenCode that you have not seen. Treat it as prior conversation, not a new request. Continue with the user messages after this checkpoint.", + }) + blocks.push(...ConvertToClaudeMessage.content(missed.slice(0, end))) + blocks.push({ type: "text", text: "" }) + } + blocks.push(...ConvertToClaudeMessage.content(missed.slice(end))) + return blocks +} diff --git a/packages/core/src/plugin/provider/claude-agent/sync/checkpoints.ts b/packages/core/src/plugin/provider/claude-agent/sync/checkpoints.ts new file mode 100644 index 000000000000..8b7a76938480 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/sync/checkpoints.ts @@ -0,0 +1,41 @@ +export * as Checkpoints from "./checkpoints.js" + +import type { LanguageModelV3Prompt, LanguageModelV3TextPart, LanguageModelV3ToolCallPart } from "@ai-sdk/provider" +import { Fingerprint } from "./fingerprint.js" +import type { SessionRecord } from "./record.js" + +export function build(input: { + prompt: LanguageModelV3Prompt + previous?: SessionRecord.Record + userID?: string + reply: Array + messageID?: string + toolMessages: Map +}) { + const history: SessionRecord.Checkpoint[] = [] + const groups = Fingerprint.groups(input.prompt) + for (let i = 0; i < groups.length; i++) { + const group = groups[i]! + const previous = input.previous?.history?.[i] + let claudeMessageID: string | undefined + if (previous && previous.hash === group.hash) claudeMessageID = previous.claudeMessageID + if (group.role === "tool") { + for (const message of input.prompt.slice(group.start, group.end)) { + if (message.role !== "tool") continue + for (const part of message.content) { + if (part.type !== "tool-result") continue + const toolMessage = input.toolMessages.get(part.toolCallId) + if (toolMessage !== undefined) claudeMessageID = toolMessage + } + } + } + const isLast = i === groups.length - 1 + if (isLast && input.userID) claudeMessageID = input.userID + history.push({ hash: group.hash, claudeMessageID }) + } + if (input.reply.length > 0) { + const replyGroup = Fingerprint.groups([{ role: "assistant", content: input.reply }])[0]! + history.push({ hash: replyGroup.hash, claudeMessageID: input.messageID }) + } + return history +} diff --git a/packages/core/src/plugin/provider/claude-agent/sync/decide.ts b/packages/core/src/plugin/provider/claude-agent/sync/decide.ts new file mode 100644 index 000000000000..d54f6e0f55a8 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/sync/decide.ts @@ -0,0 +1,58 @@ +export * as SyncDecision from "./decide.js" + +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import { Fingerprint } from "./fingerprint.js" +import type { SessionRecord } from "./record.js" + +export function decide(prompt: LanguageModelV3Prompt, record?: SessionRecord.Record) { + const saved = record?.history ?? [] + const groups = Fingerprint.groups(prompt, record?.interrupted ? saved : undefined) + if (saved.length === 0) return { kind: "fresh", start: 0 } as const + + let matched = 0 + while (matched < saved.length && matched < groups.length) { + if (saved[matched]!.hash !== groups[matched]!.hash) break + matched++ + } + if (matched === 0) return { kind: "fresh", start: 0 } as const + // Changed histories need an isolated branch, or a rebuild when no safe checkpoint survives. + if (matched < saved.length) return { kind: "changed", start: 0 } as const + + // Claude's drained transcript owns the interrupted reply, not OpenCode's partial copy. + while (record?.interrupted && groups[matched]?.role === "assistant") { + const reply = prompt[groups[matched]!.start]! + if (reply.role !== "assistant" || reply.content.some((part) => part.type === "tool-call")) break + matched++ + } + const start = groups[matched]?.start ?? prompt.length + if (record?.interrupted && start === prompt.length) return { kind: "changed", start: 0 } as const + const missed = groups.slice(matched) + const kind = missed.some((group) => group.role !== "user") ? "catch-up" : "continue" + return { kind, start } as const +} + +// A branch must not end between a native tool call and its results. +export function boundary(prompt: LanguageModelV3Prompt, record?: SessionRecord.Record) { + const groups = Fingerprint.groups(prompt) + const history = record?.history ?? [] + const pending = new Set() + let found: { messageID: string; start: number; history: readonly SessionRecord.Checkpoint[] } | undefined + for (let i = 0; i < groups.length; i++) { + const group = groups[i]! + // Undo can resubmit identical input; keep the current user group for the new turn. + if (i === groups.length - 1 && group.role === "user") break + const checkpoint = history[i] + if (!checkpoint || checkpoint.hash !== group.hash) break + for (const message of prompt.slice(group.start, group.end)) { + if (message.role === "system") continue + for (const part of message.content) { + if (part.type === "tool-call") pending.add(part.toolCallId) + if (part.type === "tool-result") pending.delete(part.toolCallId) + } + } + if (checkpoint.claudeMessageID && pending.size === 0) { + found = { messageID: checkpoint.claudeMessageID, start: group.end, history: history.slice(0, i + 1) } + } + } + return found +} diff --git a/packages/core/src/plugin/provider/claude-agent/sync/fingerprint.ts b/packages/core/src/plugin/provider/claude-agent/sync/fingerprint.ts new file mode 100644 index 000000000000..2b4a93ee95cf --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/sync/fingerprint.ts @@ -0,0 +1,117 @@ +export * as Fingerprint from "./fingerprint.js" + +import { createHash } from "node:crypto" +import type { LanguageModelV3Message, LanguageModelV3Prompt } from "@ai-sdk/provider" + +export interface Group { + readonly hash: string + readonly role: "user" | "assistant" | "tool" + readonly start: number + readonly end: number +} + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]` + if (value !== null && typeof value === "object") { + const object = value as Record + const fields: string[] = [] + for (const key of Object.keys(object).sort()) { + if (object[key] !== undefined) fields.push(`${JSON.stringify(key)}:${canonical(object[key])}`) + } + return `{${fields.join(",")}}` + } + return JSON.stringify(value) ?? "null" +} + +function hash(value: unknown) { + return createHash("sha256").update(canonical(value)).digest("hex") +} + +// opencode sends thinking and provider options differently depending on the target model, so they don't count. +function visible(message: Exclude) { + const parts: unknown[] = [] + let text = "" + for (const part of message.content) { + if (part.type === "reasoning") continue + if (part.type === "text") { + text += part.text + continue + } + if (text) parts.push({ type: "text", text }) + text = "" + if (part.type === "file") { + const data = + part.data instanceof URL + ? part.data.href + : typeof part.data === "string" + ? part.data + : Buffer.from(part.data).toString("base64") + parts.push({ type: part.type, mediaType: part.mediaType, data }) + continue + } + if (part.type === "tool-call") { + parts.push({ type: part.type, id: part.toolCallId, name: part.toolName, input: part.input }) + continue + } + if (part.type === "tool-result") { + const output = { ...part.output } + if ("providerOptions" in output) delete output.providerOptions + if (output.type === "content") { + output.value = output.value.map((part) => { + const item = { ...part } + delete item.providerOptions + return item + }) + } + parts.push({ type: part.type, id: part.toolCallId, name: part.toolName, output }) + } + } + if (text) parts.push({ type: "text", text }) + return parts +} + +// OpenCode lowers tool attachments into this separate user message. +function isToolMedia(message: LanguageModelV3Message) { + return ( + message.role === "user" && + message.content.length > 1 && + message.content[0]?.type === "text" && + message.content[0].text === "Attached media from tool result:" && + message.content.slice(1).every((part) => part.type === "file") + ) +} + +export function groups(prompt: LanguageModelV3Prompt, prefix: readonly { readonly hash: string }[] = []): Group[] { + const groups: Group[] = [] + let i = 0 + while (i < prompt.length) { + const message = prompt[i]! + const start = i + i++ + if (message.role === "system") continue + const parts = visible(message) + if (parts.length === 0) continue + const members = [hash(parts)] + if (message.role === "user") { + while (i < prompt.length) { + // A submitted input remains a boundary even when a later user message arrives without a visible reply. + const checkpoint = prefix[groups.length] + if (checkpoint && hash({ role: message.role, members: members.toSorted() }) === checkpoint.hash) break + const next = prompt[i]! + if (next.role !== "user") break + members.push(hash(visible(next))) + i++ + } + members.sort() + } + if (message.role === "tool") { + const next = prompt[i] + if (next && next.role === "user" && isToolMedia(next)) { + members.push(hash(visible(next))) + i++ + } + } + groups.push({ hash: hash({ role: message.role, members }), role: message.role, start, end: i }) + } + return groups +} diff --git a/packages/core/src/plugin/provider/claude-agent/sync/record.ts b/packages/core/src/plugin/provider/claude-agent/sync/record.ts new file mode 100644 index 000000000000..749e2532c29a --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/sync/record.ts @@ -0,0 +1,28 @@ +export * as SessionRecord from "./record.js" + +import { Effect, Option, Schema } from "effect" + +const Checkpoint = Schema.Struct({ hash: Schema.String, claudeMessageID: Schema.optional(Schema.String) }) +export type Checkpoint = typeof Checkpoint.Type +const Record = Schema.Struct({ + claudeSessionID: Schema.String, + history: Schema.optional(Schema.Array(Checkpoint)), + // After cancellation, Claude owns the partial reply; history ends at the input it received. + interrupted: Schema.optional(Schema.Boolean), +}) +export type Record = typeof Record.Type + +export interface Storage { + readonly get: (key: string) => Effect.Effect + readonly set: (key: string, value: Record) => Effect.Effect +} + +export function make(storage: Storage) { + return { + get: Effect.fn("SessionRecord.get")(function* (sessionID: string) { + const saved = Schema.decodeUnknownOption(Record)(yield* storage.get(`sessions/${sessionID}`)) + return Option.getOrUndefined(saved) + }), + set: (sessionID: string, record: Record) => storage.set(`sessions/${sessionID}`, record), + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/tools/hooks.ts b/packages/core/src/plugin/provider/claude-agent/tools/hooks.ts new file mode 100644 index 000000000000..f9c900e7179c --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/tools/hooks.ts @@ -0,0 +1,23 @@ +export * as ToolHooks from "./hooks.js" + +import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk" +import type { PendingTools } from "./pending.js" + +export function make(pending: PendingTools.Interface): Options["hooks"] { + const before: HookCallback = async (input) => { + if (input.hook_event_name === "PreToolUse") pending.before(input.tool_use_id, input.tool_name) + return {} + } + + const after: HookCallback = async (input) => { + if (input.hook_event_name !== "PostToolUse") return {} + const replacement = pending.after(input.tool_use_id) + if (!replacement) return {} + return { hookSpecificOutput: { hookEventName: "PostToolUse", updatedMCPToolOutput: replacement } } + } + + return { + PreToolUse: [{ hooks: [before] }], + PostToolUse: [{ hooks: [after] }], + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/tools/names.ts b/packages/core/src/plugin/provider/claude-agent/tools/names.ts new file mode 100644 index 000000000000..83958b5a4b01 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/tools/names.ts @@ -0,0 +1,30 @@ +export * as ToolNames from "./names.js" + +import { createHash } from "node:crypto" +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" + +export const PREFIX = "mcp__opencode__" + +export function short(name: string) { + const limit = 64 - PREFIX.length + if (name.length <= limit && /^[a-zA-Z0-9_-]+$/.test(name)) return name + const hash = createHash("sha256").update(name).digest("hex").slice(0, 10) + return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, limit - 11) + "_" + hash +} + +// Undefined for a tool we did not offer. Claude Code answers those calls itself, without our hooks or server. +export function original(name: string, tools: readonly LanguageModelV3FunctionTool[]) { + return tools.find((tool) => PREFIX + short(tool.name) === name)?.name +} + +// Claude Code cuts each MCP tool description at this many characters. +const DESCRIPTION_LIMIT = 2048 + +export function description(tool: LanguageModelV3FunctionTool) { + const text = tool.description ?? "" + if (tool.name !== "subagent" || text.length <= DESCRIPTION_LIMIT) return text.slice(0, DESCRIPTION_LIMIT) + // The subagent list comes last in a long description, so move it first to keep it after the cut. + const agents = text.indexOf("Available subagents:") + if (agents === -1) return text.slice(0, DESCRIPTION_LIMIT) + return (text.slice(agents) + "\n\n" + text.slice(0, agents)).slice(0, DESCRIPTION_LIMIT) +} diff --git a/packages/core/src/plugin/provider/claude-agent/tools/pending.ts b/packages/core/src/plugin/provider/claude-agent/tools/pending.ts new file mode 100644 index 000000000000..98ca7235ccf3 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/tools/pending.ts @@ -0,0 +1,72 @@ +export * as PendingTools from "./pending.js" + +import type { CallToolResult } from "@modelcontextprotocol/server" +import type { ToolResult } from "./result.js" + +interface Call { + readonly name: string + readonly result: PromiseWithResolvers +} + +export interface Interface { + readonly expect: (id: string, name: string) => void + readonly before: (id: string, name: string) => void + readonly call: (name: string) => Promise + readonly resolve: (id: string, result: ToolResult.Result) => void + readonly after: (id: string) => ToolResult.Result["replacement"] + readonly clear: () => void +} + +export function make(): Interface { + const calls = new Map() + const replacements = new Map() + let next: string | undefined + + function expect(id: string, name: string) { + const saved = calls.get(id) + const call = saved ?? { name, result: Promise.withResolvers() } + if (!saved) calls.set(id, call) + if (call.name !== name) throw new Error(`Mismatched Claude tool call ${id}`) + return call + } + + return { + expect, + before(id: string, name: string) { + expect(id, name) + next = id + }, + async call(name: string) { + const id = next + next = undefined + if (!id) throw new Error("Claude invoked an OpenCode tool without its PreToolUse ID") + const call = expect(id, name) + const result = await call.result.promise + if (result.replacement) replacements.set(id, result.replacement) + calls.delete(id) + return result.mcp + }, + resolve(id: string, result: ToolResult.Result) { + const call = calls.get(id) + if (!call) throw new Error(`No waiting Claude tool call ${id}`) + call.result.resolve(result) + }, + after(id: string) { + const replacement = replacements.get(id) + replacements.delete(id) + return replacement + }, + // Call only after interrupt-and-drain; resolving these handlers earlier can start another model call. + clear() { + for (const call of calls.values()) { + call.result.resolve({ + mcp: { isError: true, content: [{ type: "text", text: "Cancelled by user." }] }, + replacement: undefined, + }) + } + calls.clear() + replacements.clear() + next = undefined + }, + } +} diff --git a/packages/core/src/plugin/provider/claude-agent/tools/result.ts b/packages/core/src/plugin/provider/claude-agent/tools/result.ts new file mode 100644 index 000000000000..afdadf21c1a7 --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/tools/result.ts @@ -0,0 +1,110 @@ +export * as ToolResult from "./result.js" + +import type { + LanguageModelV3FilePart, + LanguageModelV3Prompt, + LanguageModelV3ToolResultOutput, + LanguageModelV3ToolResultPart, +} from "@ai-sdk/provider" +import type { CallToolResult } from "@modelcontextprotocol/server" +import { Provider } from "@opencode/schema/provider" +import { ConvertToClaudeMessage } from "../language-model/convert-to-claude-message.js" +import { Fingerprint } from "../sync/fingerprint.js" + +export interface Result { + readonly mcp: CallToolResult + readonly replacement: ConvertToClaudeMessage.ContentBlock[] | undefined +} + +export function convert( + output: LanguageModelV3ToolResultOutput, + files: readonly LanguageModelV3FilePart[] = [], + failed = false, +): Result { + const parts: Array<{ type: "text"; text: string } | LanguageModelV3FilePart> = [] + switch (output.type) { + case "text": + case "error-text": + parts.push({ type: "text", text: output.value }) + break + case "json": + case "error-json": + parts.push({ type: "text", text: JSON.stringify(output.value) }) + break + case "execution-denied": + parts.push({ type: "text", text: output.reason ?? "Tool execution was denied by the user." }) + break + case "content": + for (const part of output.value) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text }) + continue + } + if (part.type === "image-data" || part.type === "file-data") { + parts.push({ type: "file", mediaType: part.mediaType, data: part.data }) + continue + } + if (part.type === "image-url") { + // Only picks an image block. URL images are sent without a media type. + parts.push({ type: "file", mediaType: "image/png", data: new URL(part.url) }) + continue + } + throw new Error(`Claude Code does not support ${part.type} tool output`) + } + break + } + parts.push(...files) + const content = ConvertToClaudeMessage.content([{ role: "user", content: parts }]) + const isError = + failed || output.type === "error-text" || output.type === "error-json" || output.type === "execution-denied" + const mcp: CallToolResult = { content: [], isError } + let replace = false + for (const block of content) { + if (block.type === "text") { + mcp.content.push({ type: "text", text: block.text }) + continue + } + if (block.type === "image" && block.source.type === "base64") { + mcp.content.push({ type: "image", mimeType: block.source.media_type, data: block.source.data }) + continue + } + replace = true + mcp.content.push({ type: "text", text: "Attached document or image follows." }) + } + // The PostToolUse hook replaces tool_result.content, so it needs content blocks rather than an MCP envelope. + return { mcp, replacement: replace ? content : undefined } +} + +export function collect(prompt: LanguageModelV3Prompt, start: number) { + const results = new Map() + let end = start + let aborted = false + for (const group of Fingerprint.groups(prompt.slice(start))) { + if (group.role !== "tool") break + const messages = prompt.slice(start + group.start, start + group.end) + const tools: LanguageModelV3ToolResultPart[] = [] + const files: LanguageModelV3FilePart[] = [] + for (const message of messages) { + if (message.role === "tool") { + for (const part of message.content) { + if (part.type === "tool-result") tools.push(part) + } + } + if (message.role === "user") { + for (const part of message.content) { + if (part.type === "file") files.push(part) + } + } + } + // OpenCode puts the group's media in a following user message; attach it to the group's last result. + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]! + const flags = tool.providerOptions?.[Provider.ID.claudeAgent] + if (flags?.toolAborted === true) aborted = true + const isLast = i === tools.length - 1 + results.set(tool.toolCallId, convert(tool.output, isLast ? files : [], flags?.toolError === true)) + } + end = start + group.end + } + return { results, end, aborted } +} diff --git a/packages/core/src/plugin/provider/claude-agent/tools/server.ts b/packages/core/src/plugin/provider/claude-agent/tools/server.ts new file mode 100644 index 000000000000..997c13061abf --- /dev/null +++ b/packages/core/src/plugin/provider/claude-agent/tools/server.ts @@ -0,0 +1,34 @@ +export * as ToolServer from "./server.js" + +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" +import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk" +import type { Tool } from "@modelcontextprotocol/server" +import { ToolNames } from "./names.js" +import type { PendingTools } from "./pending.js" + +export async function make( + tools: readonly LanguageModelV3FunctionTool[], + pending: PendingTools.Interface, +): Promise { + // Loaded on first use, so opencode starts without loading the MCP SDK. + const { Server } = await import("@modelcontextprotocol/server") + const server = new Server({ name: "opencode", version: "1.0.0" }, { capabilities: { tools: {} } }) + const definitions = tools.map( + (tool): Tool => ({ + name: ToolNames.short(tool.name), + description: ToolNames.description(tool), + inputSchema: tool.inputSchema as Tool["inputSchema"], + // Claude serializes non-read-only tools, so each handler takes the ID from the preceding hook. + annotations: { readOnlyHint: false }, + _meta: { "anthropic/alwaysLoad": true, "anthropic/maxResultSizeChars": 500_000 }, + }), + ) + server.setRequestHandler("tools/list", async () => ({ tools: definitions })) + server.setRequestHandler("tools/call", async (request) => { + const name = ToolNames.PREFIX + request.params.name + if (!ToolNames.original(name, tools)) throw new Error(`Claude Code called an unregistered OpenCode tool: ${name}`) + return pending.call(name) + }) + // Claude Agent SDK bundles its own MCP SDK types; its public instance type also names the higher-level McpServer. + return { type: "sdk", name: "opencode", instance: server as unknown as McpSdkServerConfigWithInstance["instance"] } +} diff --git a/packages/core/src/plugin/provider/local.ts b/packages/core/src/plugin/provider/local.ts index 3935461be2da..65696cf37c91 100644 --- a/packages/core/src/plugin/provider/local.ts +++ b/packages/core/src/plugin/provider/local.ts @@ -2,7 +2,7 @@ import { define } from "@opencode/plugin/effect/plugin" import type { Entry } from "@opencode/schema/config" import { Duration, Effect, Option, Schedule, type Schema, Semaphore, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" -import { shellParserWasm } from "#shell-parser-wasm" +import photonWasm from "#photon-wasm" import { Config } from "../../config.js" import { Environment } from "../../environment/index.js" import { EnvironmentUnavailable } from "../../environment/unavailable.js" @@ -45,7 +45,7 @@ export function createLocalProviderPlugin(input: { const location = yield* Effect.serviceOption(Location.Service) const defaultOrigin = origin ?? - (shellParserWasm.bash === "" || + (photonWasm === "" || (Option.isSome(location) && location.value.workspaceID !== undefined) || (Option.isSome(environment) && environment.value.spawner === EnvironmentUnavailable.spawner) ? undefined diff --git a/packages/core/src/schedule.ts b/packages/core/src/schedule.ts index 96e096caf80b..dbfec7e6d90f 100644 --- a/packages/core/src/schedule.ts +++ b/packages/core/src/schedule.ts @@ -1,7 +1,8 @@ export * as Schedule from "./schedule.js" -import { and, asc, eq, gt, isNull, lte, min, or } from "drizzle-orm" -import { Cause, Clock, Context, Cron, DateTime, Effect, Latch, Layer, Option, Result, Schema } from "effect" +import { randomUUID } from "node:crypto" +import { and, asc, eq, gt, isNull, lte, min, or, sql } from "drizzle-orm" +import { Cause, Clock, Context, Cron, DateTime, Effect, Latch, Layer, Option, Result, Schedule as EffectSchedule, Schema } from "effect" import { Node, makeGlobalNode } from "@opencode/util/effect/app-node" import { LayerNode } from "@opencode/util/effect/layer-node" import { @@ -23,6 +24,7 @@ import { Config } from "./config.js" import { Database } from "./database/database.js" import { FileSystem } from "./filesystem.js" import { Instance } from "./instance/service.js" +import { KVTable } from "./kv/sql.js" import type { Permission } from "./permission.js" import { Session } from "./session.js" import { SessionExecution } from "./session/execution.js" @@ -70,8 +72,9 @@ export interface Interface { /** Fires every schedule that is due now, at most once each. */ readonly tick: Effect.Effect /** - * Runs `tick` whenever a schedule falls due, until interrupted. All state lives in the table, so exactly one - * process may run this: the managed server. + * Runs `tick` whenever a schedule falls due, until interrupted. Elects at most one active leader per SQLite + * database via a lease row, so auxiliary processes (`opencode acp`, `--standalone`) sharing a data directory + * never double-fire alongside a managed service. */ readonly run: Effect.Effect } @@ -90,6 +93,8 @@ const DEFAULT_UNTIL_AFTER = 24 * HOUR const LATE_AFTER = 60_000 /** The loop re-reads the table at least this often, so clock jumps and missed doorbells are caught. */ const MAX_SLEEP = 30_000 +const LEADER_KEY = "schedule:leader" +const LEADER_TTL = 2 * MAX_SLEEP /** How long a due schedule waits before its location's flag is read again, while schedules are off there. */ const DISABLED_RETRY = HOUR /** How long a schedule whose prompt failed waits before trying again. */ @@ -285,13 +290,16 @@ const layer = Layer.effect( if (row.check !== null) { const check = row.check - const outcome = yield* ScheduleCheck.evaluate(session, check, row.state ?? null).pipe( - instances.provide(session), - ) + const checkMessageID = SessionMessage.ID.make(`msg_${row.id}_check_${row.checks + 1}`) + const outcome = yield* ScheduleCheck.evaluate(session, check, row.state ?? null, { + messageID: checkMessageID, + now, + checks: row.checks, + }).pipe(instances.provide(session)) if (!outcome.ok) { const strikes = row.failures + 1 - if (strikes < MAX_CHECK_FAILURES) { + if (!outcome.invalid && strikes < MAX_CHECK_FAILURES) { yield* update(row, { failures: strikes, last_outcome: "failed", @@ -301,13 +309,16 @@ const layer = Layer.effect( return } const run = row.run_count + 1 + const note = outcome.invalid + ? `[Schedule paused after invalid check: ${outcome.error}]` + : `[Schedule paused after ${strikes} failed checks: ${outcome.error}]` const id = yield* admitWake( row, session.id, run, due, late, - `${row.action.prompt}\n\n[Schedule paused after ${strikes} failed checks: ${outcome.error}]`, + `${row.action.prompt}\n\n${note}`, { error: outcome.error, failures: strikes }, ) yield* db @@ -436,6 +447,7 @@ const layer = Layer.effect( const step = Effect.fnUntraced(function* (row: Row, now: number) { const due = row.next_due_at if (due === null || row.session_id === null) return + if (!(yield* claimLeader(yield* Clock.currentTimeMillis))) return const session = yield* sessions.get(row.session_id) if (!(yield* settings(session)).enabled) { // Reading the flag boots the Session's location, so check again in an hour rather than every tick. The row @@ -532,18 +544,98 @@ const layer = Layer.effect( ), ) + const hasActiveRows = db + .select({ id: ScheduleTable.id }) + .from(ScheduleTable) + .where(eq(ScheduleTable.status, "active")) + .limit(1) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => row !== undefined), + ) + + const ownerID = randomUUID() + let leading = false + let bootChecked = false + + const claimLeader = Effect.fnUntraced(function* (now: number) { + const value = { owner: ownerID, until: now + LEADER_TTL } + const claimed = yield* db + .insert(KVTable) + .values({ key: LEADER_KEY, value, time_created: now, time_updated: now }) + .onConflictDoUpdate({ + target: KVTable.key, + set: { value, time_updated: now }, + where: sql`json_extract(${KVTable.value}, '$.owner') = ${ownerID} or json_extract(${KVTable.value}, '$.until') <= ${now}`, + }) + .returning({ key: KVTable.key }) + .get() + .pipe(Effect.orDie) + return claimed !== undefined + }) + + const releaseLeader = Effect.sync(() => { + leading = false + }).pipe( + Effect.andThen( + db + .delete(KVTable) + .where(and(eq(KVTable.key, LEADER_KEY), sql`json_extract(${KVTable.value}, '$.owner') = ${ownerID}`)) + .run() + .pipe(Effect.ignore), + ), + ) + + const heartbeat = Clock.currentTimeMillis.pipe( + Effect.flatMap(claimLeader), + Effect.ignore, + Effect.schedule(EffectSchedule.spaced(LEADER_TTL / 3)), + ) + const loop = Effect.gen(function* () { yield* doorbell.close + const active = yield* hasActiveRows + if (!active && bootChecked) { + if (leading) yield* releaseLeader + yield* doorbell.await.pipe(Effect.timeoutOption(MAX_SLEEP)) + return + } const now = yield* Clock.currentTimeMillis - yield* tickAt(now).pipe(Effect.catchCause((cause) => Effect.logError("schedule tick failed", { cause }))) + const isLeader = yield* claimLeader(now) + if (!isLeader) { + leading = false + bootChecked = true + yield* doorbell.await.pipe(Effect.timeoutOption(MAX_SLEEP)) + return + } + if (!leading) { + leading = true + bootChecked = true + yield* rewake.pipe(Effect.catchCause((cause) => Effect.logError("schedule rewake failed", { cause }))) + } + if (!active) { + yield* releaseLeader + yield* doorbell.await.pipe(Effect.timeoutOption(MAX_SLEEP)) + return + } + yield* Effect.scoped( + Effect.forkScoped(heartbeat).pipe( + Effect.andThen( + tickAt(now).pipe(Effect.catchCause((cause) => Effect.logError("schedule tick failed", { cause }))), + ), + ), + ) + if (!(yield* hasActiveRows)) { + yield* releaseLeader + yield* doorbell.await.pipe(Effect.timeoutOption(MAX_SLEEP)) + return + } const wait = yield* nextWake(now) if (wait > 0) yield* doorbell.await.pipe(Effect.timeoutOption(wait)) }) - const run = rewake.pipe( - Effect.catchCause((cause) => Effect.logError("schedule rewake failed", { cause })), - Effect.andThen(Effect.forever(loop)), - ) + const run = Effect.forever(loop).pipe(Effect.ensuring(releaseLeader)) const resolveZone = (tz: string | undefined) => { if (tz === undefined) return Effect.succeed(DateTime.zoneMakeLocal()) @@ -628,11 +720,12 @@ const layer = Layer.effect( if (ends !== null && due > ends) return yield* new InvalidError({ reason: "The schedule ends before it first fires" }) + const scheduleID = ID.create() const seeded = check === undefined ? { matched: false as const, state: null as Schema.Json, checks: 0 } : yield* Effect.gen(function* () { - yield* ScheduleCheck.authorize(session, input.name, check).pipe( + yield* ScheduleCheck.authorize(session, input.name, when, check).pipe( instances.provide(session), Effect.mapError((error) => (typeof error === "string" ? new InvalidError({ reason: error }) : error)), Effect.catchTag("FileSystem.DirectoryNotFoundError", () => @@ -642,9 +735,15 @@ const layer = Layer.effect( ), ) const shouldSeed = - check.mode === "until" || (check.condition.type === "shell" && check.condition.changes === true) + check.mode === "until" || + check.condition.type === "code" || + (check.condition.type === "shell" && check.condition.changes === true) if (!shouldSeed) return { matched: false as const, state: null as Schema.Json, checks: 0 } - const init = yield* ScheduleCheck.evaluate(session, check, null).pipe( + const init = yield* ScheduleCheck.evaluate(session, check, null, { + messageID: SessionMessage.ID.make(`msg_${scheduleID}_check_0`), + now, + checks: 0, + }).pipe( instances.provide(session), Effect.catchTag("FileSystem.DirectoryNotFoundError", () => Effect.fail( @@ -652,8 +751,11 @@ const layer = Layer.effect( ), ), ) - // A target that is not up yet (e.g. curl exiting 7 while a server boots) is "not yet", not an invalid schedule. - if (!init.ok) return { matched: false as const, state: null as Schema.Json, checks: 0 } + if (!init.ok) { + // A code check that throws or fails at creation is broken (e.g. wrong MCP response field); fail right away. + if (init.invalid || check.condition.type === "code") return yield* new InvalidError({ reason: init.error }) + return { matched: false as const, state: null as Schema.Json, checks: 0 } + } if (check.mode === "until" && init.wake) return { matched: true as const, state: init.state, message: init.message } return { matched: false as const, state: init.state, checks: 1 } @@ -663,7 +765,7 @@ const layer = Layer.effect( const row = yield* db .insert(ScheduleTable) .values({ - id: ID.create(), + id: scheduleID, session_id: session.id, name: input.name ?? null, check: check ?? null, @@ -692,7 +794,7 @@ const layer = Layer.effect( const row = yield* db .insert(ScheduleTable) .values({ - id: ID.create(), + id: scheduleID, session_id: session.id, name: input.name ?? null, check: check ?? null, diff --git a/packages/core/src/schedule/check.ts b/packages/core/src/schedule/check.ts index 0ebf78a3b103..d628e29d4ae0 100644 --- a/packages/core/src/schedule/check.ts +++ b/packages/core/src/schedule/check.ts @@ -1,22 +1,57 @@ export * as ScheduleCheck from "./check.js" +import { CodeMode } from "@opencode/codemode" import { isRecord } from "@opencode/util/record" import { collectStream } from "@opencode/util/process" import type { Schedule as ScheduleSchema } from "@opencode/schema/schedule" -import { Array, Duration, Effect, Option, Result, type Schema } from "effect" +import { Array, Duration, Effect, Option, Result, Schema } from "effect" import { ChildProcess } from "effect/process" import { Agent } from "../agent.js" +import { CodeModeTool } from "../codemode/tool.js" import { Environment } from "../environment/index.js" import { FileAccess } from "../file-access.js" import { Location } from "../location.js" import { Permission } from "../permission.js" import { Plugin } from "../plugin.js" +import type { SessionMessage } from "../session/message.js" import type { SessionSchema } from "../session/schema.js" import { ShellParse } from "../shell/parse.js" import { ShellSelect } from "../shell/select.js" +import { Tool } from "../tool.js" +import { McpTool } from "../tool/mcp.js" export const TIMEOUT_MS = 30_000 const MAX_OUTPUT_BYTES = 32 * 1024 +const CODE_LIMITS: CodeMode.ExecutionLimits = { + timeoutMs: TIMEOUT_MS, + maxToolCalls: 20, + maxOutputBytes: MAX_OUTPUT_BYTES, +} + +const FORBIDDEN_CODE_TOOLS = new Set([ + "opencode.schedule_create", + "opencode.schedule_pause", + "opencode.schedule_resume", + "opencode.schedule_delete", + "opencode.session_move", + "opencode.session_rename", + "opencode.worktree_create", + "opencode.worktree_remove", +]) + +const decodeCodeObjectResult = Schema.decodeUnknownResult( + Schema.Struct({ + wake: Schema.Boolean, + state: Schema.optionalKey(Schema.Json), + message: Schema.optionalKey(Schema.String), + }), +) + +export interface RunContext { + readonly messageID: SessionMessage.ID + readonly now: number + readonly checks: number +} export type Outcome = | { @@ -28,6 +63,7 @@ export type Outcome = | { readonly ok: false readonly error: string + readonly invalid?: boolean } /** Validates condition syntax synchronously before asking for permissions or spawning anything. */ @@ -50,6 +86,16 @@ export function validate(condition: ScheduleSchema.Condition): Result.Result item.trim()) + .find((item) => item.length > 0) + return line ? `code (${line.slice(0, 60)})` : "code" + } } } @@ -64,13 +117,16 @@ export function describe(condition: ScheduleSchema.Condition): string { export const authorize = Effect.fnUntraced(function* ( session: SessionSchema.Info, name: string | undefined, + when: ScheduleSchema.When, check: ScheduleSchema.StoredCheck, ) { const plugins = yield* Plugin.Service yield* plugins.awaitActivation switch (check.condition.type) { case "shell": - return yield* authorizeShell(session, name, check.mode, check.condition) + return yield* authorizeShell(session, name, when, check.mode, check.condition) + case "code": + return yield* authorizeCode(session, name, when, check.mode, check.condition) } }) @@ -79,6 +135,7 @@ export const evaluate = Effect.fnUntraced(function* ( session: SessionSchema.Info, check: ScheduleSchema.StoredCheck, prevState: Schema.Json, + context: RunContext, ) { const run = Effect.gen(function* () { const plugins = yield* Plugin.Service @@ -86,6 +143,8 @@ export const evaluate = Effect.fnUntraced(function* ( switch (check.condition.type) { case "shell": return yield* runShell(session, check.condition, prevState) + case "code": + return yield* runCode(session, check.condition, prevState, context) } }) return yield* run.pipe( @@ -99,6 +158,184 @@ export const evaluate = Effect.fnUntraced(function* ( ) }) +const resolveCodeTools = Effect.fnUntraced(function* (paths: ReadonlyArray) { + const mcp = yield* McpTool.Service + const tools = yield* Tool.Service + if (paths.length > 0) yield* mcp.flush + const registered = yield* tools.list() + const resolved: Array<(typeof registered)[number]> = [] + for (const path of paths) { + if (FORBIDDEN_CODE_TOOLS.has(path)) { + return yield* Effect.fail(`Tool ${path} cannot be used inside a schedule check`) + } + const found = registered.find( + (item) => item.options?.codemode !== false && CodeModeTool.qualifiedName(item) === path, + ) + if (!found) return yield* Effect.fail(`Unknown Code Mode tool: ${path}`) + if (found.options?.permission === "edit") { + return yield* Effect.fail(`Tool ${path} cannot be used inside a schedule check`) + } + resolved.push(found) + } + return { tools, resolved } +}) + +const authorizeCode = Effect.fnUntraced(function* ( + session: SessionSchema.Info, + name: string | undefined, + when: ScheduleSchema.When, + mode: ScheduleSchema.CheckMode, + condition: ScheduleSchema.CodeCondition, +) { + const permission = yield* Permission.Service + const agents = yield* Agent.Service + const agent = yield* agents.select(session.agent) + const scanned = CodeMode.scanTools(condition.code) + if (Result.isFailure(scanned)) return yield* Effect.fail(scanned.failure.message) + const paths = scanned.success + + yield* permission.assertNotDenied({ + action: "execute", + resources: ["*"], + sessionID: session.id, + agent: agent.id, + }) + + const { resolved } = yield* resolveCodeTools(paths) + const actions = Array.dedupe(resolved.map((tool) => tool.options?.permission ?? tool.id)) + + if (actions.length === 0) { + yield* permission.assert({ + action: "execute", + resources: ["*"], + metadata: { schedule: name, mode, when, unattended: true, code: condition.code, tools: [] }, + sessionID: session.id, + agent: agent.id, + }) + return + } + + // Fail fast on any explicit deny before asking the user to approve earlier tools. + yield* Effect.forEach( + actions, + (action) => + permission.assertNotDenied({ + action, + resources: ["*"], + sessionID: session.id, + agent: agent.id, + }), + { discard: true }, + ) + + yield* Effect.forEach( + actions, + (action) => + permission.assert({ + action, + resources: ["*"], + // No `save`: "always" would save a project-wide allow, while scheduled ticks run via Unattended. + metadata: { schedule: name, mode, when, unattended: true, code: condition.code, tools: [...paths] }, + sessionID: session.id, + agent: agent.id, + }), + { discard: true }, + ) +}) + +const runCode = Effect.fnUntraced(function* ( + session: SessionSchema.Info, + condition: ScheduleSchema.CodeCondition, + prevState: Schema.Json, + context: RunContext, +) { + const permission = yield* Permission.Service + const agents = yield* Agent.Service + const agent = yield* agents.select(session.agent) + if (!agent.info) return { ok: false as const, error: `Agent not found: ${agent.id}` } + + const scanned = CodeMode.scanTools(condition.code) + if (Result.isFailure(scanned)) return { ok: false as const, error: scanned.failure.message } + const paths = scanned.success + + const resolvedExit = yield* Effect.result(resolveCodeTools(paths)) + if (Result.isFailure(resolvedExit)) return { ok: false as const, error: resolvedExit.failure } + + const actions = Array.dedupe(resolvedExit.success.resolved.map((tool) => tool.options?.permission ?? tool.id)) + const unattended: Permission.Ruleset = ["execute", ...actions].map((action) => ({ + action, + resource: "*", + effect: "allow" as const, + })) + + const notDenied = yield* Effect.forEach( + ["execute", ...actions], + (action) => + permission.assertNotDenied({ + action, + resources: ["*"], + sessionID: session.id, + agent: agent.id, + }), + { discard: true }, + ).pipe(Effect.provideService(Permission.Unattended, unattended), Effect.result) + + if (Result.isFailure(notDenied)) return { ok: false as const, error: notDenied.failure.message } + + const bindings: Record = { + state: prevState, + now: context.now, + runs: context.checks, + input: { state: prevState, now: context.now, runs: context.checks }, + } + + const result = yield* resolvedExit.success.tools + .check({ + code: condition.code, + tools: paths, + bindings, + permissions: Permission.merge(agent.info.permissions, session.permissions ?? []), + limits: CODE_LIMITS, + context: { + sessionID: session.id, + agent: agent.id, + messageID: context.messageID, + progress: () => Effect.void, + }, + }) + .pipe(Effect.provideService(Permission.Unattended, unattended)) + + if (!result.ok) { + const invalid = + result.error.kind === "ParseError" || + result.error.kind === "UnsupportedSyntax" || + result.error.kind === "UnknownTool" || + result.error.kind === "InvalidToolInput" || + result.error.message.includes("needs approval") + return { ok: false as const, error: result.error.message, ...(invalid ? { invalid: true } : {}) } + } + if (typeof result.value === "boolean") { + return { ok: true as const, wake: result.value, state: prevState } + } + const decoded = decodeCodeObjectResult(result.value) + if (Result.isFailure(decoded)) { + return { + ok: false as const, + error: `A code check must return a boolean or { wake, state?, message? }: ${decoded.failure}`, + invalid: true, + } + } + const state = decoded.success.state === undefined ? prevState : decoded.success.state + const trimmed = decoded.success.message?.trim() + const message = trimmed ? trimmed : undefined + return { + ok: true as const, + wake: decoded.success.wake, + state, + ...(decoded.success.wake && message !== undefined ? { message } : {}), + } +}) + const scanShell = Effect.fnUntraced(function* (command: string) { const location = yield* Location.Service const access = yield* FileAccess.Service @@ -107,7 +344,7 @@ const scanShell = Effect.fnUntraced(function* (command: string) { .resolve({ path: location.directory, kind: "directory" }) .pipe(Effect.mapError((error) => error.message)) const shell = yield* shellSelect.resolve({ priority: "compat" }) - const parsed = yield* ShellParse.scanPortable(command, shell, target.absolute).pipe( + const parsed = yield* ShellParse.scan(command, shell, target.absolute).pipe( Effect.mapError((error) => error.message), ) const directories = yield* Effect.forEach(parsed.directories, (directory) => @@ -153,6 +390,7 @@ const assertShellPermissions = Effect.fnUntraced(function* ( const authorizeShell = Effect.fnUntraced(function* ( session: SessionSchema.Info, name: string | undefined, + when: ScheduleSchema.When, mode: ScheduleSchema.CheckMode, condition: ScheduleSchema.ShellCondition, ) { @@ -160,6 +398,8 @@ const authorizeShell = Effect.fnUntraced(function* ( yield* assertShellPermissions(session, scanned, { schedule: name, mode, + when, + unattended: true, run: condition.run, }) }) @@ -171,7 +411,7 @@ const runShell = Effect.fnUntraced(function* ( ) { const environment = yield* Environment.Service const scannedExit = yield* Effect.result(scanShell(condition.run)) - if (Result.isFailure(scannedExit)) return { ok: false, error: scannedExit.failure } satisfies Outcome + if (Result.isFailure(scannedExit)) return { ok: false as const, error: scannedExit.failure } satisfies Outcome const scanned = scannedExit.success const unattended: Permission.Ruleset = [ ...scanned.external.map((resource) => ({ action: "external_directory", resource, effect: "allow" as const })), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 932658af9b5d..21c1eba79e91 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -397,6 +397,7 @@ const layer = Layer.effect( yield* result.get(sessionID) yield* execution.interrupt(sessionID) yield* execution.awaitIdle(sessionID) + yield* jobs.cancelSession(sessionID) yield* transport.close(sessionID) const children = yield* store.list({ parentID: sessionID }) yield* Effect.forEach(children, (child) => result.remove(child.id), { concurrency: 1, discard: true }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 917d08819b2b..49005f93db11 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,6 +1,7 @@ export * as SessionCompaction from "./compaction.js" import { + ai, AIError, type ContentPart, InvalidProviderOutputError, @@ -23,6 +24,7 @@ import { Agent } from "../agent.js" import { Bus } from "../bus.js" import { Database } from "../database/database.js" import { llmClient } from "../effect/app-node-platform.js" +import { Instructions } from "../instructions/index.js" import { Model } from "../model.js" import { State } from "../state.js" import { Token } from "../util/token.js" @@ -379,7 +381,7 @@ export const layer = Layer.effect( firstID: context.messages[0]?.id, keepID: anchor.id, model: context.model.ref, - initial: context.initial, + initial: Instructions.join(context.initial), tailLimit: tailLimit(settings.keep, model.cap), failures, usage, @@ -558,7 +560,7 @@ export const layer = Layer.effect( ) const send = (request: LLMRequest) => { - if (LLMClient.canCompact(request, { mechanism: "trigger" })) { + if (ai.canCompact(request, { mechanism: "trigger" })) { return Effect.gen(function* () { const history = yield* SessionHistory.load(db, context.session.id, "local").pipe(Effect.orDie) const retained = recentUserMessages(history, context.model, keep) @@ -566,7 +568,7 @@ export const layer = Layer.effect( return yield* toResult([...retained, Message.assistant(response.checkpoint)], response.usage) }) } - if (LLMClient.canCompact(request)) { + if (ai.canCompact(request)) { return llm .compact(request, { mechanism: "endpoint", http: prepared.options.http }) .pipe(Effect.flatMap((response) => toResult(response.replacement, response.usage))) @@ -949,7 +951,7 @@ export const node = makeLocationNode({ * conversation: a compaction or revert since replaced its start, or the model or instructions changed. */ const heldIndex = (held: Precomputed, context: SessionContext.Loaded) => { - if (held.firstID !== context.messages[0]?.id || held.initial !== context.initial) return -1 + if (held.firstID !== context.messages[0]?.id || held.initial !== Instructions.join(context.initial)) return -1 if (held.model.providerID !== context.model.ref.providerID || held.model.id !== context.model.ref.id) return -1 if (held.model.variant !== context.model.ref.variant) return -1 return context.messages.findIndex((message) => message.id === held.keepID) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 7ca92df4b07c..dea2cc092e28 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -36,7 +36,7 @@ export interface Loaded { readonly session: SessionSchema.Info readonly agent: Agent.Selection & { readonly info: Agent.Info } readonly model: SessionRunnerModel.Resolved - readonly initial: string + readonly initial: ReadonlyArray readonly messages: ReadonlyArray readonly tools: Tool.Snapshot } diff --git a/packages/core/src/session/execution/restart.ts b/packages/core/src/session/execution/restart.ts index 40f6533b0b01..bee326e8b9a6 100644 --- a/packages/core/src/session/execution/restart.ts +++ b/packages/core/src/session/execution/restart.ts @@ -107,24 +107,21 @@ export const layer = (options?: Options) => return true }) - const recoverShell = Effect.fnUntraced(function* ( + const restartMessage = ( background: Job.Background, - recovery: Extract, - ) { + recovery: Exclude, + ) => { const state = background.status === "running" ? "cancelled" : background.status - const text = - background.status === "running" - ? "Command cancelled because the server restarted" - : state === "completed" - ? (background.output ?? "Command completed") - : state === "error" - ? (background.error ?? "Command failed") - : "Command cancelled" - - yield* sessions - .synthetic({ - id: background.notificationID, - sessionID: recovery.sessionID, + if (recovery.kind === "shell") { + const text = + background.status === "running" + ? "Command cancelled because the server restarted" + : state === "completed" + ? (background.output ?? "Command completed") + : state === "error" + ? (background.error ?? "Command failed") + : "Command cancelled" + return { description: recovery.command, ...ShellResult.notification({ jobID: background.id, @@ -133,45 +130,39 @@ export const layer = (options?: Options) => state, text, }), - // Restart notices must not revive idle owners of long-lived shells. - // Interrupted executions resume separately after their notices are admitted. - resume: false, - }) - .pipe( - Effect.catchTag("Session.NotFoundError", () => Effect.void), - Effect.orDie, - ) - yield* jobs.completeBackground(background.notificationID) - }) - - const recoverImage = Effect.fnUntraced(function* ( - background: Job.Background, - recovery: Extract, - ) { + } + } const body = background.status === "running" ? `Image request for ${recovery.path} was cancelled because the server restarted.` - : background.status === "completed" + : state === "completed" ? (background.output ?? `Image request finished. Saved image: ${recovery.path} (${recovery.model}).`) - : background.status === "error" + : state === "error" ? (background.error ?? `Image request failed for ${recovery.path}.`) : `Image request for ${recovery.path} was cancelled.` + return { + description: "Image generation", + text: ImageNotice.text({ id: background.id, state, body }), + metadata: { + source: "experimental_image_generate", + jobID: background.id, + state, + path: recovery.path, + }, + } + } + + const recoverToolJob = Effect.fnUntraced(function* ( + background: Job.Background, + recovery: Exclude, + ) { yield* sessions .synthetic({ id: background.notificationID, sessionID: recovery.sessionID, - description: "Image generation", - text: ImageNotice.text({ - id: background.id, - state: background.status === "running" ? "cancelled" : background.status, - body, - }), - metadata: { - source: "experimental_image_generate", - jobID: background.id, - state: background.status === "running" ? "cancelled" : background.status, - path: recovery.path, - }, + ...restartMessage(background, recovery), + // Restart notices must not revive idle owners of long-lived background tools. + // Interrupted executions resume separately after their notices are admitted. resume: false, }) .pipe( @@ -531,8 +522,7 @@ export const layer = (options?: Options) => Effect.fnUntraced(function* (background) { if ((yield* jobs.get(background.id))?.status === "running") return const recovery = background.recovery - if (recovery.kind === "shell") return yield* recoverShell(background, recovery) - if (recovery.kind === "image") return yield* recoverImage(background, recovery) + if (recovery.kind !== "subagent") return yield* recoverToolJob(background, recovery) yield* recoverSubagent(background, recovery, suspended, ambiguous) }), { discard: true }, diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index ffb8b9af0bac..5c217236729c 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -1,11 +1,12 @@ export * as SessionModelRequest from "./model-request.js" import { + ai, GenerationOptions, type GenerationOptionsFields, HttpOptions, LanguageModel, - LLM, + type LLM, LLMRequest, type Media, Message, @@ -27,6 +28,7 @@ import { Cause, Context, Effect, Layer, Result, Stream } from "effect" import { HttpClientRequest, HttpClientResponse } from "effect/http" import { makeLocationNode } from "@opencode/util/effect/app-node" import { App } from "../app.js" +import { Instructions } from "../instructions/index.js" import { Permission } from "../permission.js" import { PluginHooks } from "../plugin/hooks.js" import { QuestionTool } from "../tool/plugin/question.js" @@ -100,20 +102,23 @@ export const baseTranscript = (input: { readonly agent: Agent.Info readonly model: SessionRunnerModel.Resolved readonly tools: Tool.Snapshot - readonly initial: string + readonly initial: ReadonlyArray readonly messages: ReadonlyArray }) => { const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider return { providerMetadataKey, system: [ - input.agent.system - ? input.agent.system - : SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)), - input.initial, - ] - .filter((part) => part.length > 0) - .map(SystemPart.make), + SystemPart.make( + input.agent.system + ? input.agent.system + : SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)), + ), + { + ...SystemPart.make(Instructions.join(input.initial)), + metadata: { experimental: { instructions: input.initial } }, + }, + ].filter((part) => part.text.length > 0), messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey), } } @@ -297,7 +302,7 @@ export const layer = Layer.effect( const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k))) const affinity = SessionAffinity.get(session) - const base = LLM.request({ + const base = ai.request({ model: model.model, http: { headers: { diff --git a/packages/core/src/shell/parse.ts b/packages/core/src/shell/parse.ts index e94f2288eb4e..ef0f25c20756 100644 --- a/packages/core/src/shell/parse.ts +++ b/packages/core/src/shell/parse.ts @@ -1,19 +1,19 @@ export * as ShellParse from "./parse.js" -import { Effect } from "effect" -import { fileURLToPath } from "url" +import { Effect, Result } from "effect" +import fs from "fs" import os from "os" import path from "path" -import type { Node } from "web-tree-sitter" -import { shellParserWasm } from "#shell-parser-wasm" +import { ShellScan } from "./scan.js" import { ShellSelect } from "./select.js" -import { lazy } from "../util/lazy.js" import { Wildcard } from "../util/wildcard.js" -type Part = { type: string; text: string } +type Part = { type: string; text: string; raw: string; unquotedTilde?: boolean } const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const POWERSHELL_PATH_FLAG_RE = /^-(?:p(?:ath|at|a)?|lp|l(?:i(?:t(?:e(?:r(?:a(?:l(?:p(?:a(?:t(?:h)?)?)?)?)?)?)?)?)?)?|psp(?:a(?:t(?:h)?)?)?):?$/i +const POWERSHELL_LITERAL_FLAG_RE = /^-(?:lp|l(?:i(?:t(?:e(?:r(?:a(?:l(?:p(?:a(?:t(?:h)?)?)?)?)?)?)?)?)?)?|psp(?:a(?:t(?:h)?)?)?):?$/i +const POWERSHELL_STACK_FLAG_RE = /^-(?:stack(?:name|nam|na|n)?|st):?$/i export type Result = { commands: Array<{ resource: string; save: string }> @@ -161,184 +161,368 @@ const ARITY: Record = { } const PREFIX_LENGTH = Math.max(...Object.values(ARITY)) -export const scan = Effect.fnUntraced(function* ( - command: string, - shell: string, - cwd: string, - options?: { portable?: boolean; wasm?: typeof shellParserWasm }, -) { - if (options?.portable || (options?.wasm ?? shellParserWasm).bash === "") - return yield* scanPortable(command, shell, cwd) - return yield* scanLegacy(command, shell, cwd) -}) +export function scan(command: string, shell: string, cwd: string): Effect.Effect { + return Effect.suspend(() => { + const powershell = ShellSelect.ps(shell) + const shellName = ShellSelect.name(shell) + const scanned = powershell + ? ShellScan.scanPowerShell(command) + : ShellScan.scan(command, shellName === "bash" || shellName === "zsh" ? shellName : "posix") + if (scanned.kind === "opaque") + return Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${scanned.reason}`)) -const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) { - const parsers = yield* Effect.promise(load) - const powershell = ShellSelect.ps(shell) - const tree = (powershell ? parsers.ps : parsers.bash).parse(command) - if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command")) - - return yield* Effect.acquireUseRelease( - Effect.succeed(tree), - (tree) => - Effect.sync(() => - tree.rootNode.descendantsOfType("command").reduce( - (result, node) => { - if (!node) return result - const command = parts(node) - const tokens = command.map((part) => part.text) - if (tokens.length === 0) return result - const name = powershell ? tokens[0].toLowerCase() : tokens[0] - if (CWD.has(name)) { - result.directories.push(...directoryArgs(command, powershell, cwd, shell)) - return result + const output: Result = { commands: [], directories: [] } + for (const item of scanned.commands) { + // Declarations are skipped as standalone permission resources, while nested substitutions remain. + if (item.declaration) continue + if (item.redirectPrefixEnd !== undefined) { + const head = item.resource.slice(0, item.redirectPrefixEnd) + const next = item.resource[item.redirectPrefixEnd] + const separator = next === "\t" || next === "\n" ? next : " " + output.commands.push({ resource: item.resource, save: `${head}${separator}*` }) + continue + } + const words = item.redirectWordCount === undefined ? item.rawWords : item.rawWords.slice(0, item.redirectWordCount) + // Bare statement-head foreach blocks act as PowerShell control flow rather than cmdlet invocations. + if ( + powershell && + item.statementHead && + /^foreach(?:-|$)/i.test(words[0] ?? "") && + words.length > 1 && + words.slice(1).every((word) => word.startsWith("{")) + ) + continue + const parts = powershell + ? words.flatMap((raw, index): Part[] => { + const text = + !raw.includes("`$") && /['"`]/.test(raw) ? (item.words[index] ?? raw) : raw.replaceAll("`$", "\0") + if (!/^-[A-Za-z_?][\w?-]*(?::|$)/.test(raw)) return [{ type: "word", text, raw }] + const colon = text.indexOf(":") + if (colon >= 0) { + const rawColon = raw.indexOf(":") + return colon + 1 < text.length + ? [ + { type: "command_parameter", text: text.slice(0, colon), raw: raw.slice(0, rawColon) }, + { type: "word", text: text.slice(colon + 1), raw: rawColon >= 0 ? raw.slice(rawColon + 1) : raw }, + ] + : [{ type: "command_parameter", text: text.slice(0, colon), raw }] } - result.commands.push({ - resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(), - save: `${prefix(tokens).join(" ")} *`, - }) - return result - }, - { commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] }, - ), - ), - (tree) => Effect.sync(() => tree.delete()), - ) -}) - -export const scanPortable = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) { - const { ShellScan } = yield* Effect.tryPromise({ - try: () => import("./scan.js"), - catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }), + return [{ type: "command_parameter", text, raw }] + }) + : item.rawWords.map((raw, index): Part => { + const word = item.words[index] ?? raw + return { + type: "word", + text: isStaticBashWord(raw) ? word.replaceAll("$", "\0") : "$", + raw, + unquotedTilde: raw.startsWith("~"), + } + }) + const directoryCommand = unwrapDirectoryCommand(powershell ? words : item.rawWords, parts, powershell) + if (directoryCommand) { + const resolved = directoryArgs(directoryCommand, powershell, cwd, shell) + output.directories.push(...resolved.directories) + if (directoryCommand === parts && resolved.complete) continue + } + const selected = prefix(words.slice(0, PREFIX_LENGTH)) + const conventional = `${selected.join(" ")} *` + const end = item.wordEnds?.[selected.length - 1] + // Keep existing grants stable unless normalized spacing loses the original source boundary. + const save = + !powershell || end === undefined || Wildcard.match(item.resource, conventional) + ? conventional + : item.resource[end] === "(" + ? `${item.resource.slice(0, end)}(*` + : (() => { + const boundary = + item.wordEnds?.find( + (value) => value >= end && (value >= item.resource.length || /\s/.test(item.resource[value])), + ) ?? end + const separator = /^\s+(?:`(?:\r\n|\r|\n)\s*)?/.exec(item.resource.slice(boundary))?.[0] + return `${item.resource.slice(0, boundary)}${separator ?? " "}*` + })() + output.commands.push({ + resource: item.resource, + save, + }) + } + return Effect.succeed(output) }) - const powershell = ShellSelect.ps(shell) - const name = ShellSelect.name(shell) - const result = powershell - ? ShellScan.scanPowerShell(command) - : ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix") - if (result.kind === "opaque") - return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`)) +} + +function unwrapDirectoryCommand(rawWords: string[], parts: Part[], powershell: boolean) { + const head = powershell ? rawWords[0]?.toLowerCase() : rawWords[0] + if (CWD.has(head ?? "")) return parts + if (powershell) return + let index = 0 + while (rawWords[index] === "builtin" || rawWords[index] === "command") { + const wrapper = rawWords[index] + index++ + while (rawWords[index]?.startsWith("-")) { + const flag = rawWords[index] + index++ + if (flag === "--") break + if (wrapper === "command" && /^-[p]*[vV]/.test(flag)) return + } + } + return index > 0 && CWD.has(rawWords[index] ?? "") ? parts.slice(index) : undefined +} - const output: Result = { commands: [], directories: [] } - for (const item of result.commands) { - // The legacy command walk skips declarations, not the substitutions within them. - if (item.declaration) continue - const words = item.redirectWordCount === undefined ? item.rawWords : item.rawWords.slice(0, item.redirectWordCount) - // The shipped PowerShell grammar treats bare statement-head foreach blocks as control flow. +function isStaticBashWord(raw: string) { + let quote: "'" | '"' | "$'" | undefined + let segmentStarted = false + let segmentLeadingDot = false + let segmentGlob = false + let hasAnsiEscape = false + let hasUnquotedGlob = false + const braces: boolean[] = [] + const recordChar = (char: string, unquotedGlob = false) => { + if (unquotedGlob) hasUnquotedGlob = true + if (hasAnsiEscape && hasUnquotedGlob) return false + if (char === "/") { + segmentStarted = false + segmentLeadingDot = false + segmentGlob = false + return true + } + if (!segmentStarted) { + segmentStarted = true + segmentLeadingDot = char === "." + } + if (unquotedGlob) segmentGlob = true + return !(segmentLeadingDot && segmentGlob) + } + + for (let index = 0; index < raw.length; index++) { + const char = raw[index] + const next = raw[index + 1] ?? "" + if (quote === "'") { + if (char === "'") { + quote = undefined + continue + } + if (!recordChar(char)) return false + continue + } + if (quote === "$'") { + if (char === "\\") { + hasAnsiEscape = true + if (hasUnquotedGlob) return false + index++ + continue + } + if (char === "'") { + quote = undefined + continue + } + if (!recordChar(char)) return false + continue + } + if (quote === '"') { + if (char === "\\" && '$`"\\\n'.includes(next)) { + index++ + if (next !== "\n" && !recordChar(next)) return false + continue + } + if (char === '"') { + quote = undefined + continue + } + if (char === "`" || (char === "$" && (/[A-Za-z_0-9@*#?$!({[-]/.test(next) || raw.startsWith("\\\n", index + 1)))) + return false + if (!recordChar(char)) return false + continue + } + if (char === "\\") { + index++ + if (next !== "\n" && !recordChar(next)) return false + continue + } + if (char === "'") { + quote = "'" + continue + } + if (char === '"') { + quote = '"' + continue + } + if (char === "$" && next === "'") { + quote = "$'" + index++ + continue + } + if (char === "$" && next === '"') { + quote = '"' + index++ + continue + } if ( - powershell && - item.statementHead && - /^foreach(?:-|$)/i.test(words[0] ?? "") && - words.length > 1 && - words.slice(1).every((word) => word.startsWith("{")) + char === "`" || + ((char === "<" || char === ">" || char === "=") && next === "(") || + (char === "$" && (/[A-Za-z_0-9@*#?$!({[-]/.test(next) || raw.startsWith("\\\n", index + 1))) ) + return false + if (char === "{") { + braces.push(false) + if (!recordChar(char)) return false continue - const name = powershell ? words[0]?.toLowerCase() : words[0] - if (CWD.has(name)) { - output.directories.push( - ...directoryArgs( - powershell - ? words.flatMap((raw, index): Part[] => { - const text = - !raw.includes("`$") && /['"`]/.test(raw) ? (item.words[index] ?? raw) : raw.replaceAll("`$", "\0") - if (!/^-[A-Za-z_?][\w?-]*(?::|$)/.test(raw)) return [{ type: "word", text }] - const colon = text.indexOf(":") - if (colon >= 0) - return colon + 1 < text.length - ? [ - { type: "command_parameter", text: text.slice(0, colon) }, - { type: "word", text: text.slice(colon + 1) }, - ] - : [{ type: "command_parameter", text: text.slice(0, colon) }] - return [{ type: "command_parameter", text }] - }) - : item.rawWords.map((text, index) => ({ - type: "word", - // Only literal quoting resolves to a static directory. - text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text, - })), - powershell, - cwd, - shell, - ), - ) + } + if (braces.length > 0 && (char === "," || (char === "." && next === "."))) { + braces[braces.length - 1] = true + } + if (char === "}" && braces.length > 0) { + if (braces.pop()) return false + if (!recordChar(char)) return false continue } - const selected = prefix(words.slice(0, PREFIX_LENGTH)) - const conventional = `${selected.join(" ")} *` - const end = item.wordEnds?.[selected.length - 1] - // Keep existing grants stable unless normalized spacing loses the original source boundary. - const save = - !powershell || end === undefined || Wildcard.match(item.resource, conventional) - ? conventional - : (() => { - const boundary = - item.wordEnds?.find( - (value) => value >= end && (value >= item.resource.length || /\s/.test(item.resource[value])), - ) ?? end - const separator = /^\s+(?:`(?:\r\n|\r|\n)\s*)?/.exec(item.resource.slice(boundary))?.[0] - return `${item.resource.slice(0, boundary)}${separator ?? " "}*` - })() - output.commands.push({ - resource: item.resource, - save, - }) + if (!recordChar(char, char === "*" || char === "?" || char === "[")) return false } - return output -}) + return quote === undefined +} -function parts(node: Node) { - return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => { - const child = node.child(index) - if (!child) return [] - if (child.type === "command_elements") - return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => { - const item = child.child(itemIndex) - if (!item || item.type === "command_argument_sep" || item.type === "redirection") return [] - return [{ type: item.type, text: item.text }] - }) - if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type)) - return [] - return [{ type: child.type, text: child.text }] - }) +function hasPowerShellDotWildcard(raw: string) { + let quote: "'" | '"' | undefined + let segmentStarted = false + let segmentLeadingDot = false + let segmentGlob = false + let hasQuoteEscape = false + let hasUnquotedGlob = false + const recordChar = (char: string, unquotedGlob = false) => { + if (unquotedGlob) hasUnquotedGlob = true + if (hasQuoteEscape && hasUnquotedGlob) return true + if (char === "/" || char === "\\") { + segmentStarted = false + segmentLeadingDot = false + segmentGlob = false + return false + } + if (!segmentStarted) { + segmentStarted = true + segmentLeadingDot = char === "." + } + if (unquotedGlob) segmentGlob = true + return segmentLeadingDot && segmentGlob + } + + for (let index = 0; index < raw.length; index++) { + const char = raw[index] + const next = raw[index + 1] ?? "" + if (quote === "'") { + if (char === "'" && next === "'") { + index++ + if (recordChar("'")) return true + continue + } + if (char === "'") { + quote = undefined + continue + } + if (recordChar(char)) return true + continue + } + if (quote === '"') { + if (char === "`") { + hasQuoteEscape = true + if (hasUnquotedGlob) return true + index++ + continue + } + if (char === '"' && next === '"') { + index++ + if (recordChar('"')) return true + continue + } + if (char === '"') { + quote = undefined + continue + } + if (recordChar(char)) return true + continue + } + if (char === "`") { + index++ + if (recordChar(next)) return true + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (recordChar(char, char === "*" || char === "?" || char === "[")) return true + } + return false } function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) { - if (!powershell) - return command - .slice(1) - .filter((part) => !part.text.startsWith("-")) - .map((part) => directoryArgument(part.text, powershell, cwd, shell)) - .filter((part) => part !== undefined) + const head = command[0]?.text.toLowerCase() ?? "" + if (head === "popd" || head === "pop-location") return { directories: [], complete: false } + + if (!powershell) { + let endOfOptions = false + const operands = command.slice(1).filter((part) => { + if (endOfOptions) return true + if (part.text === "--") { + endOfOptions = true + return false + } + if (part.text === "-") return true + return !part.text.startsWith("-") + }) + if (operands.length === 0) return { directories: [], complete: false } + const resolved = operands.map((part) => + part.text === "-" ? undefined : directoryArgument(part.text, false, cwd, shell, part.unquotedTilde), + ) + const directories = resolved.filter((item): item is string => item !== undefined) + return { directories, complete: directories.length === operands.length } + } const directories: string[] = [] - let path = false + let pathFlag = false + let literalFlag = false let endOfParameters = false + let sawOperand = false + let complete = true for (const part of command.slice(1)) { if (!endOfParameters && part.text === "--") { endOfParameters = true continue } - if (path) { - const value = directoryArgument(part.text, powershell, cwd, shell) + if (pathFlag) { + sawOperand = true + const value = + !literalFlag && (hasPowerShellDotWildcard(part.raw) || part.text === "-" || part.text === "+") + ? undefined + : directoryArgument(part.text, true, cwd, shell) if (value) directories.push(value) - path = false + if (!value) complete = false + pathFlag = false + literalFlag = false continue } if (!endOfParameters && part.type === "command_parameter") { - path = POWERSHELL_PATH_FLAG_RE.test(part.text) + if (POWERSHELL_STACK_FLAG_RE.test(part.text)) { + complete = false + return { directories, complete } + } + pathFlag = POWERSHELL_PATH_FLAG_RE.test(part.text) + literalFlag = POWERSHELL_LITERAL_FLAG_RE.test(part.text) continue } - const value = directoryArgument(part.text, powershell, cwd, shell) + sawOperand = true + const value = + hasPowerShellDotWildcard(part.raw) || (!endOfParameters && (part.raw === "-" || part.raw === "+")) + ? undefined + : directoryArgument(part.text, true, cwd, shell) if (value) directories.push(value) + if (!value) complete = false } - return directories + return { directories, complete: sawOperand && complete } } -function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) { +function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string, unquotedTilde = false) { + if (!powershell) return expandKnownDirectory(value, false, cwd, unquotedTilde) const quote = value[0] const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value - if (!powershell) return expandKnownDirectory(text) // PowerShell exposes environment variables through $env:NAME and provides these // automatic directory variables. Expand only values we can determine without executing code. @@ -352,20 +536,66 @@ function directoryArgument(value: string, powershell: boolean, cwd: string, shel if (key === "PWD") return cwd return path.dirname(shell) }), + true, + cwd, ) } -function expandKnownDirectory(value: string) { +function expandKnownDirectory(value: string, powershell: boolean, cwd: string, unquotedTilde = false) { // Unknown shell expressions cannot be resolved safely during permission analysis. - if (value.includes("$") || value.includes("`") || value.startsWith("(")) return + if (value.includes("$") || value.includes("`") || (powershell && value.startsWith("("))) return const resolved = value.replaceAll("\0", "$") if (resolved === "~") return os.homedir() if (resolved.startsWith("~/") || (process.platform === "win32" && resolved.startsWith("~\\"))) { return path.join(os.homedir(), resolved.slice(2)) } + if (unquotedTilde && process.platform !== "win32") { + if (resolved === "~+") return cwd + if (resolved.startsWith("~+/")) return path.join(cwd, resolved.slice(3)) + if (/^~(?:[-+](?:\d+)?|\d+)(?=$|\/)/.test(resolved)) return + const userMatch = /^~([A-Za-z_][A-Za-z0-9._-]*)(?=$|\/)/.exec(resolved) + if (userMatch) { + const user = userMatch[1] + const home = resolveUserHome(user) + if (!home) return + return path.join(home, resolved.slice(user.length + 1)) + } + } return resolved } +let cachedPasswdHomes: ReadonlyMap | undefined + +function passwdHomes() { + if (cachedPasswdHomes) return cachedPasswdHomes + const content = Result.try(() => fs.readFileSync("/etc/passwd", "utf8")) + const entries = Result.isSuccess(content) + ? content.success.split("\n").flatMap((line): Array<[string, string]> => { + if (!line || line.startsWith("#")) return [] + const fields = line.split(":") + const name = fields[0] + const home = fields[5] + return name && home ? [[name, home]] : [] + }) + : [] + cachedPasswdHomes = new Map(entries) + return cachedPasswdHomes +} + +function resolveUserHome(user: string) { + const current = Result.try(() => os.userInfo()) + if (Result.isSuccess(current) && current.success.username === user && current.success.homedir) { + return current.success.homedir + } + const fromPasswd = passwdHomes().get(user) + if (fromPasswd) return fromPasswd + if (process.platform === "darwin") { + const candidate = path.join("/Users", user) + const exists = Result.try(() => fs.statSync(candidate).isDirectory()) + if (Result.isSuccess(exists) && exists.success) return candidate + } +} + function environment(key: string) { if (process.platform !== "win32") return process.env[key] const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) @@ -379,25 +609,3 @@ function prefix(tokens: string[]) { } return tokens.slice(0, 1) } - -function resolve(asset: string) { - if (asset.startsWith("file://")) return fileURLToPath(asset) - if (path.isAbsolute(asset)) return asset - return fileURLToPath(new URL(asset, import.meta.url)) -} - -const load = lazy(initialize) - -async function initialize() { - const { Parser, Language } = await import("web-tree-sitter") - await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) }) - const [bashLanguage, psLanguage] = await Promise.all([ - Language.load(resolve(shellParserWasm.bash)), - Language.load(resolve(shellParserWasm.powershell)), - ]) - const bash = new Parser() - bash.setLanguage(bashLanguage) - const ps = new Parser() - ps.setLanguage(psLanguage) - return { bash, ps } -} diff --git a/packages/core/src/shell/parser-wasm.bun.ts b/packages/core/src/shell/parser-wasm.bun.ts deleted file mode 100644 index 278a169d9bb3..000000000000 --- a/packages/core/src/shell/parser-wasm.bun.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-ignore Bun embeds static file imports when compiling the CLI. -import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" } -// @ts-ignore Bun embeds static file imports when compiling the CLI. -import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" } -// @ts-ignore Bun embeds static file imports when compiling the CLI. -import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" } - -export const shellParserWasm = { runtime, bash, powershell } diff --git a/packages/core/src/shell/parser-wasm.node.ts b/packages/core/src/shell/parser-wasm.node.ts deleted file mode 100644 index d8a9c5d6efc9..000000000000 --- a/packages/core/src/shell/parser-wasm.node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createRequire } from "node:module" - -const require = createRequire(import.meta.url) - -export const shellParserWasm = { - runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"), - bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"), - powershell: - process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ?? - require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"), -} diff --git a/packages/core/src/shell/parser-wasm.workerd.ts b/packages/core/src/shell/parser-wasm.workerd.ts deleted file mode 100644 index 394d517a0429..000000000000 --- a/packages/core/src/shell/parser-wasm.workerd.ts +++ /dev/null @@ -1,4 +0,0 @@ -// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse -// loads these lazily and degrades when initialization fails, so empty paths -// keep module load side-effect free instead of resolving from disk. -export const shellParserWasm = { runtime: "", bash: "", powershell: "" } diff --git a/packages/core/src/shell/scan.ts b/packages/core/src/shell/scan.ts index 4896bd43c431..a8ad405f9941 100644 --- a/packages/core/src/shell/scan.ts +++ b/packages/core/src/shell/scan.ts @@ -19,8 +19,10 @@ type Command = { wordEnds?: number[] statementHead?: true declaration?: true - // Words after a trailing redirect are destinations in the legacy command span. + // Words before the first redirect when redirects appear between arguments. redirectWordCount?: number + // Exclusive prefix end relative to resource for commandless or group redirects. + redirectPrefixEnd?: number } // Opaque describes a parsing limitation, not a permission decision. @@ -28,7 +30,9 @@ export type Result = { kind: "scanned"; commands: Command[] } | { kind: "opaque" const BASH_REDIRECTS = ["&>>", "&>", "<<<", "<<-", "<<", "<>", "<&", ">&", ">|", ">>", ">", "<"] const BASH_LIST_OPERATORS = ["&&", "||", "|&"] +const ZSH_LIST_OPERATORS = ["&&", "||", "|&", "&!", "&|"] const BASH_CASE_OPERATORS = [";;&", ";;", ";&", ";|", ...BASH_LIST_OPERATORS] +const ZSH_CASE_OPERATORS = [";;&", ";;", ";&", ";|", ...ZSH_LIST_OPERATORS] // Builtins that bind their operands to variables or evaluate them as variable names. const BASH_BINDING_BUILTINS = new Set([ "declare", @@ -104,8 +108,12 @@ const FUNCTION_BODY_RE = const DO_AHEAD_RE = /(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*(?:do(?=(?:\\\n)*(?:[ \t\n;{(]|$))|\{(?=(?:\\\n)*[ \t\n]))/y const NOFORK_OPEN_RE = /\$\{(?:\\\n)*(?:[ \t\n]|\|)/y // Zsh flags that neither evaluate nor glob a value; s and j take an argument between repeated delimiters. -const ZSH_SAFE_FLAGS_RE = /\$\{\((?:[@AaBbCcDEfFikLMmNnOopqStUuVvWwXZz0-]|q[-+]?|[js]([^({[<\\])(?:(?!\1)[^)])*\1)*\)/y +const ZSH_SAFE_FLAGS_RE = /\$\{\((?:[@AaBbCcDEfFikLMmNnOopPqStUuVvWwXZz0-]|q[-+]?|[js]([^({[<\\])(?:(?!\1)[^)])*\1)*\)/y +const ZSH_QUOTED_FLAG_ARG_RE = /[js]([^({[<\\])(?:(?!\1)[^)])*\1/g const ZSH_GLOB_MODIFIER_RE = /(?:[\^=~#+]|\\\n)*~/y +// Trailing Zsh glob qualifiers without code execution (e, +cmd), delimiters (f, u, g, P), modifiers (:), or math expressions. +const ZSH_GLOB_QUALIFIER_RE = + /\((?:[/F.@=*rwxAIERWsStUG^MTNDn-]|[oO][nLlamcdN]|[amc][Mwhmsd]?[-+]?\d+|L[kKmMpPgGtT]?[-+]?\d+|[lY][-+]?\d+|\[[-+]?\d+(?:,[-+]?\d+)?\]|,)+\)(?=(?:\\\n)*(?:[ \t\n;&|<>)]|$))/y const ZSH_SUBSCRIPT_RE = /\$(?:[\^=+~#]|\\\n)*(?:[A-Za-z_](?:[A-Za-z0-9_]|\\\n)*|[@*#?$!0-9-])(?:\\\n)*\[/y const LINE_CONTINUATION_RE = /(?:\\\n)*/y const PARAMETER_PREFIX_RE = /(?:\\\n)*(?:\([^)]*\))?(?:[!#^=+~]|\\\n)*/y @@ -137,7 +145,10 @@ type BashContext = { dialect: Dialect boundDollar?: boolean boundOpen?: boolean + boundBracket?: boolean + sinkBracket?: boolean variableSink?: boolean + evalSink?: boolean } type BashHeredoc = { @@ -146,6 +157,7 @@ type BashHeredoc = { quoted: boolean tabs: boolean binding?: boolean + owned?: true command?: Command start?: number } @@ -175,9 +187,8 @@ type BashState = { wordStart: number wordEnd: number commandStart: number | undefined - commandEnd: number - resourceEnd: number | undefined redirectWordCount: number | undefined + redirectPrefixEnd: number | undefined // Posix shells such as Dash read `&>` as `&` and `>`, so later words would start another command there. ampersandRedirectWords: number | undefined commandWordIndex: number @@ -185,8 +196,10 @@ type BashState = { assignmentHeadUnsafe: boolean invalid: OpaqueReason | undefined redirectTarget: boolean + dupRedirect: boolean hereString: boolean hasRedirect: boolean + hasFileRedirect: boolean compoundEnd: boolean // A list operator (&&, ||, |, |&) still awaits its right-hand command. dangling: boolean @@ -210,7 +223,11 @@ export function scan(input: string, dialect: Dialect = "posix"): Result { const context: BashContext = { remaining: MAX_INPUT_LENGTH * MAX_SUBSTITUTION_DEPTH, dialect } const result = scanBash(input, 0, 0, context) if (result.kind === "opaque") return result - if (context.boundDollar && context.boundOpen && context.variableSink) + if ( + context.boundDollar && + context.boundOpen && + (context.evalSink || ((context.boundBracket || context.sinkBracket) && context.variableSink)) + ) return { kind: "opaque", reason: "dynamic-execution" } return { kind: "scanned", commands: result.commands } } @@ -235,7 +252,7 @@ function pending(state: BashState) { // Ends the current list; false when an operator still awaits its command. function endBashList(state: BashState) { - if (pending(state)) finishBashCommand(state) + if (pending(state)) finishBashCommand(state, false, true) return !state.dangling } @@ -248,7 +265,7 @@ function closeBashList(state: BashState, end: number): BashResult { function finishBashWord(state: BashState) { if (!state.wordStarted) return - if (/PS4/.test(state.word)) state.context.variableSink = true + if (/PS4/.test(state.word)) state.context.evalSink = true const evaluation = bashWordEvaluation(state) if ( state.context.dialect !== "bash" && @@ -258,6 +275,10 @@ function finishBashWord(state: BashState) { ) state.invalid ??= "dynamic-execution" if (evaluation && bashEvaluatesExpansion(state, evaluation, state.context)) state.invalid ??= "dynamic-execution" + if (state.word !== state.literal && state.commandWordIndex >= 0) { + const builtin = state.words[bashBuiltinIndex(state)] + if (builtin === "declare" || builtin === "typeset" || builtin === "local") state.context.evalSink = true + } if (state.outer) state.outer.output += ` ${state.literal} ${state.output}` const structure = state.structures.at(-1) const repeatHeader = structure?.kind === "repeat" && structure.phase === "header" @@ -268,9 +289,14 @@ function finishBashWord(state: BashState) { state.words.push(state.word) // Unquoted trailing continuations are ignored syntax, not part of the raw token. state.rawWords.push(state.input.slice(state.wordStart, state.wordEnd)) - state.commandEnd = state.wordEnd + } + if (state.redirectTarget) { + if (!state.dupRedirect || !/^(?:\d+-?|-)$/.test(state.input.slice(state.wordStart, state.wordEnd))) + state.hasFileRedirect = true + state.redirectPrefixEnd ??= state.wordEnd } state.redirectTarget = false + state.dupRedirect = false state.hereString = false state.word = "" state.literal = "" @@ -341,7 +367,7 @@ function bashBuiltinIndex(state: BashState) { return name } -function finishBashCommand(state: BashState, boundary = false) { +function finishBashCommand(state: BashState, boundary = false, newline = false) { finishBashWord(state) if (state.redirectTarget) state.invalid ??= "invalid-redirect" state.redirectTarget = false @@ -350,14 +376,15 @@ function finishBashCommand(state: BashState, boundary = false) { const inHeader = bashInHeader(state) if (name >= 0 && !state.words[name]) state.invalid ??= "invalid-structure" if (name >= 0 && !inHeader) { - const resource = state.input - .slice(state.commandStart, state.resourceEnd ?? state.wordEnd) - .replace(/^[ \t\n]+|[ \t\n]+$/g, "") + const resource = state.input.slice(state.commandStart, state.wordEnd).replace(/^[ \t\n]+|[ \t\n]+$/g, "") const command: Command = { resource, words: state.words.slice(name), rawWords: state.rawWords.slice(name), - ...(name === 0 && BASH_DECLARATIONS.has(state.rawWords[0]) && resource.startsWith(state.rawWords[0]) + ...(name === 0 && + !state.hasRedirect && + BASH_DECLARATIONS.has(state.rawWords[0]) && + resource.startsWith(state.rawWords[0]) ? { declaration: true as const } : {}), ...(state.redirectWordCount !== undefined && state.redirectWordCount < state.words.length @@ -368,7 +395,7 @@ function finishBashCommand(state: BashState, boundary = false) { const builtin = bashBuiltinIndex(state) const builtinName = state.words[builtin] const builtinArgs = state.words.slice(builtin + 1) - if (builtinName === "eval") state.context.variableSink = true + if (builtinName === "eval") state.context.evalSink = true if (builtinName === "set" && builtinArgs.some((word) => /^-[A-Za-z0-9]*k/.test(word) || word === "keyword")) state.invalid ??= "dynamic-execution" // Dash expands an alias defined earlier in the same script, which can name any command. @@ -402,14 +429,47 @@ function finishBashCommand(state: BashState, boundary = false) { if (BASH_BINDING_BUILTINS.has(builtinName ?? "")) { for (const heredoc of state.heredocs) heredoc.binding ??= true } - if (state.resourceEnd === undefined) { - for (const heredoc of state.heredocs) { - if (heredoc.command) continue + for (const heredoc of state.heredocs) { + if (heredoc.owned) continue + heredoc.owned = true + if (newline) { heredoc.command = command heredoc.start = state.commandStart } } + } else if ( + name < 0 && + !inHeader && + state.commandStart !== undefined && + (state.hasFileRedirect || (state.hasRedirect && !state.compoundEnd)) + ) { + const rawSpan = state.input.slice(state.commandStart, state.wordEnd) + const leading = /^[ \t\n]+/.exec(rawSpan)?.[0].length ?? 0 + const resource = rawSpan.slice(leading).replace(/[ \t\n]+$/g, "") + if (resource) { + const prefixEnd = + state.redirectPrefixEnd !== undefined + ? Math.max(1, Math.min(resource.length, state.redirectPrefixEnd - state.commandStart - leading)) + : resource.length + const prefixToken = resource.slice(0, prefixEnd) + const command: Command = { + resource, + words: [prefixToken], + rawWords: [prefixToken], + redirectPrefixEnd: prefixEnd, + } + state.commands.push(command) + for (const heredoc of state.heredocs) { + if (heredoc.owned) continue + heredoc.owned = true + if (newline) { + heredoc.command = command + heredoc.start = state.commandStart + } + } + } } + for (const heredoc of state.heredocs) heredoc.owned = true if (boundary && !state.words.length && !state.hasRedirect && !state.compoundEnd && !inHeader) state.invalid ??= "invalid-structure" if (state.words.length > (state.ampersandRedirectWords ?? Infinity)) state.invalid ??= "invalid-redirect" @@ -419,9 +479,11 @@ function finishBashCommand(state: BashState, boundary = false) { state.rawWords.length = 0 state.commandWordIndex = -1 state.commandStart = undefined + state.dupRedirect = false state.hasRedirect = false - state.resourceEnd = undefined + state.hasFileRedirect = false state.redirectWordCount = undefined + state.redirectPrefixEnd = undefined state.ampersandRedirectWords = undefined state.compoundEnd = false state.dangling = false @@ -454,17 +516,18 @@ function scanBash( wordStart: start, wordEnd: start, commandStart: undefined, - commandEnd: start, - resourceEnd: undefined, redirectWordCount: undefined, + redirectPrefixEnd: undefined, ampersandRedirectWords: undefined, commandWordIndex: -1, assignmentWord: false, assignmentHeadUnsafe: false, invalid: undefined, redirectTarget: false, + dupRedirect: false, hereString: false, hasRedirect: false, + hasFileRedirect: false, compoundEnd: false, dangling: false, outer, @@ -603,6 +666,19 @@ function scanBash( return { kind: "opaque", reason: "compound-command" } return closeBashList(state, index) } + if ( + char === "(" && + context.dialect === "zsh" && + state.wordStarted && + !inCasePattern && + ((ZSH_GLOB_QUALIFIER_RE.lastIndex = index), ZSH_GLOB_QUALIFIER_RE.test(input)) + ) { + const end = ZSH_GLOB_QUALIFIER_RE.lastIndex - 1 + bashAppend(state, input.slice(index, end + 1)) + index = end + state.wordEnd = index + 1 + continue + } if (char === "(") return { kind: "opaque", reason: state.wordStarted ? "dynamic-execution" : "compound-command" } if (/\s/.test(char) && !" \t\n".includes(char)) return { kind: "opaque", reason: "invalid-structure" } if (char === " " || char === "\t") { @@ -647,8 +723,6 @@ function scanBashOperatorOrSeparator( const fdPrefix = state.wordStarted && !state.assignmentHeadUnsafe && /^(?:\d+|\{[A-Za-z_][A-Za-z0-9_]*\})$/.test(state.word) if (fdPrefix) { - // A continuation separates the legacy number token from the redirect descriptor. - if (state.wordEnd < index) state.commandEnd = state.wordEnd state.word = "" state.literal = "" state.output = "" @@ -656,21 +730,24 @@ function scanBashOperatorOrSeparator( } if (!fdPrefix) finishBashWord(state) if (redirect.startsWith("&") && context.dialect === "posix") state.ampersandRedirectWords ??= state.words.length - // Trailing redirects wrap a whole list/pipeline in the legacy grammar, not its last command. - // Prefix redirects remain part of the command, and later words remain redirect destinations. if (state.redirectWordCount === undefined && state.commandWordIndex >= 0) { state.redirectWordCount = state.words.length - if (state.dangling) state.resourceEnd = state.commandEnd } if (redirect === "<<" || redirect === "<<-") { const delimiter = bashHeredocDelimiter(input, index + redirect.length, redirect === "<<-") if (!delimiter) return { kind: "opaque", reason: "invalid-redirect" } + state.hasFileRedirect = true + state.redirectPrefixEnd ??= + " \t".includes(input[index + redirect.length] ?? "") ? index + redirect.length : delimiter.end + 1 state.heredocs.push(delimiter) state.wordEnd = delimiter.end + 1 state.redirectTarget = false + state.dupRedirect = false return delimiter.end } + if (" \t\n".includes(input[index + redirect.length] ?? "")) state.redirectPrefixEnd ??= index + redirect.length state.redirectTarget = true + state.dupRedirect = redirect === "<&" || redirect === ">&" state.hereString = redirect === "<<<" return index + redirect.length - 1 } @@ -680,7 +757,15 @@ function scanBashOperatorOrSeparator( } const structure = state.structures.at(-1) const caseBody = structure?.kind === "case" && structure.phase === "body" - const operator = bashOperator(input, index, caseBody ? BASH_CASE_OPERATORS : BASH_LIST_OPERATORS) + const operators = + context.dialect === "zsh" + ? caseBody + ? ZSH_CASE_OPERATORS + : ZSH_LIST_OPERATORS + : caseBody + ? BASH_CASE_OPERATORS + : BASH_LIST_OPERATORS + const operator = bashOperator(input, index, operators) if (typeof operator === "object") return operator const separator = operator ?? char if (caseBody && separator.startsWith(";") && separator.length > 1) { @@ -705,9 +790,10 @@ function scanBashOperatorOrSeparator( return { kind: "opaque", reason: "compound-command" } } if (separator === "\n" && !pending(state)) return readBashHeredocs(state, index, depth, context) - finishBashCommand(state, true) + if (separator === ";" && context.dialect === "zsh" && !pending(state) && !state.dangling) return index + finishBashCommand(state, true, separator === "\n") if (structure?.kind === "for" && structure.phase === "header") structure.phase = "do" - state.dangling = separator !== "&" && separator !== ";" && separator !== "\n" + state.dangling = !(separator === ";" || separator === "\n" || (separator.startsWith("&") && separator !== "&&")) // Reprocess the newline to read pending heredoc bodies. if (separator === "\n" && state.heredocs.length) return index - 1 return index + separator.length - 1 @@ -1057,6 +1143,7 @@ function scanBashUnit( const span = mode === "quoted" ? BASH_SPANS.quotedSubscript : BASH_SPANS.subscript const end = scanBashSpan(input, bracket, depth, context, commands, span, false, subscript) if (typeof end === "object") return end + context.sinkBracket = true if (bashEvaluatesExpansion(subscript, "arithmetic", context)) return { kind: "opaque", reason: "dynamic-execution" } if (text) text.word += `${input.slice(index, bracket)}${subscript.word}]` return end @@ -1163,10 +1250,14 @@ function bashAppend(text: BashText, value: string) { function bashEvaluatesExpansion(text: BashText, evaluation: BashEvaluation, context: BashContext) { const combined = `${text.literal} ${text.output}` const hasExpansion = BASH_EXPANSION_RE.test(combined) - const hasEscape = BASH_ESCAPE_RE.test(text.literal) + // Escapes reach the value through $'...' or a substitution's printf, echo -e, or tr. + const hasEscape = BASH_ESCAPE_RE.test(combined) if (hasExpansion || hasEscape || /[$`]/.test(text.literal)) context.boundDollar = true if (hasExpansion || hasEscape || /[({`]/.test(text.literal)) context.boundOpen = true + // A substitution's output can hold `[` that the script never spells (printf '\x5b', tr, base64 -d). + if (hasExpansion || hasEscape || text.output.trim() || combined.includes("[")) context.boundBracket = true if (evaluation === "deferred") return false + if (text.word.includes("[") || combined.includes("[")) context.sinkBracket = true if ( evaluation === "arithmetic" ? /[A-Za-z_]|(?:\$(?![?#!-]|\$(?!\[)))/.test(text.word) @@ -1192,8 +1283,10 @@ function scanBashSubscript( ): number | Opaque { const text = bashText() const end = scanBashSpan(input, start, depth, context, commands, span, false, text) - if (typeof end === "number" && bashEvaluatesExpansion(text, "arithmetic", context)) - return { kind: "opaque", reason: "dynamic-execution" } + if (typeof end === "number") { + context.sinkBracket = true + if (bashEvaluatesExpansion(text, "arithmetic", context)) return { kind: "opaque", reason: "dynamic-execution" } + } return end } @@ -1285,8 +1378,14 @@ function scanBashDollarOrBacktick( // Literal text in parameter words reaches the enclosing word's value. const parameter = bashText() const end = scanBashParameter(input, start + 2, depth + 1, context, commands, quoted, parameter) - // ${!name} evaluates the subscript in its name. - if (/^(?:\\\n)*!/.test(input.slice(start + 2))) { + // ${!name} and Zsh ${(P)name} evaluate the subscript in the referenced name. + const indirect = + /^(?:\\\n)*!/.test(input.slice(start + 2)) || + (context.dialect !== "bash" && + input[start + 2] === "(" && + ((ZSH_SAFE_FLAGS_RE.lastIndex = start), ZSH_SAFE_FLAGS_RE.test(input)) && + input.slice(start + 3, ZSH_SAFE_FLAGS_RE.lastIndex - 1).replace(ZSH_QUOTED_FLAG_ARG_RE, "").includes("P")) + if (indirect) { context.variableSink = true if (bashEvaluatesExpansion(parameter, "binding", context)) return { kind: "opaque", reason: "dynamic-execution" } } @@ -1386,7 +1485,7 @@ function scanBashParameter( (/^(?:(?:dis_)?(?:functions|[gs]?aliases)|options|commands)$/.test(name) && "[=:".includes(input[colon] ?? ""))) ) return { kind: "opaque", reason: "dynamic-execution" } - if (name === "PS4") context.variableSink = true + if (name === "PS4") context.evalSink = true let evaluation: BashEvaluation | undefined let wordOffset = 0 let literalOffset = 0 @@ -1573,6 +1672,18 @@ function scanBashArrayOrPattern( wordStarted = false continue } + if ( + mode === "array" && + wordStarted && + char === "(" && + context.dialect === "zsh" && + ((ZSH_GLOB_QUALIFIER_RE.lastIndex = index), ZSH_GLOB_QUALIFIER_RE.test(input)) + ) { + const end = ZSH_GLOB_QUALIFIER_RE.lastIndex - 1 + if (text) text.literal += input.slice(index, end + 1) + index = end + continue + } // Array elements are globbed, where a parenthesized group can be a Zsh glob qualifier. if (char === "\n" || char === ";" || char === "&" || (mode === "array" && "|(".includes(char))) return { kind: "opaque", reason: "command-substitution" } @@ -1592,7 +1703,7 @@ function scanBashArrayOrPattern( return { kind: "opaque", reason: "command-substitution" } } -function bashAnsiQuote(input: string, start: number) { +function bashAnsiQuote(input: string, start: number, allowUnicode = true) { let value = "" for (let index = start + 1; index < input.length; index++) { if (input[index] === "'") return { value, end: index } @@ -1609,13 +1720,17 @@ function bashAnsiQuote(input: string, start: number) { const digits = escaped === "x" ? /^[\da-fA-F]{1,2}/.exec(input.slice(index + 1, index + 3))?.[0] - : /[0-7]/.test(escaped) - ? /^[0-7]{1,3}/.exec(input.slice(index, index + 3))?.[0] - : undefined + : allowUnicode && escaped === "u" + ? /^[\da-fA-F]{1,4}/.exec(input.slice(index + 1, index + 5))?.[0] + : allowUnicode && escaped === "U" + ? /^[\da-fA-F]{1,8}/.exec(input.slice(index + 1, index + 9))?.[0] + : /[0-7]/.test(escaped) + ? /^[0-7]{1,3}/.exec(input.slice(index, index + 3))?.[0] + : undefined if (!digits) return const octal = /[0-7]/.test(escaped) const point = parseInt(digits, octal ? 8 : 16) - if (point === 0) return + if (point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return value += String.fromCodePoint(point) index += digits.length - (octal ? 1 : 0) } @@ -1637,7 +1752,7 @@ function bashHeredocDelimiter(input: string, start: number, tabs: boolean) { if (!quote && char === "$" && input.startsWith("\\\n", index + 1)) return // Dash retains $ in $'...' heredoc delimiters while Bash and Zsh strip $. if (!quote && input.startsWith("$'", index)) { - const literal = bashAnsiQuote(input, index + 1) + const literal = bashAnsiQuote(input, index + 1, false) if (!literal || literal.end !== input.indexOf("'", index + 2)) return dashDelimiter = `${dashDelimiter ?? delimiter}$${input.slice(index + 2, literal.end)}` delimiter += literal.value @@ -1764,6 +1879,9 @@ function scanPowerShellList( let clause: PowerShellClause = initialClause let after: "do" | "if" | "try" | undefined let indexable: false | "variable" | "closed" | "member" = false + let memberChainStart: number | undefined + let hasMemberAccess = false + let hasFileRedirect = false let stopParsing = false let commandEnd = start let statementHead = true @@ -1772,6 +1890,11 @@ function scanPowerShellList( let dangling = false let invocation = false + const resetMember = () => { + indexable = false + memberChainStart = undefined + hasMemberAccess = false + } const finishWord = (end: number) => { if (!started) return // Generic tokens can spell stop-parsing with escapes or embedded quotes; literal strings cannot. @@ -1787,7 +1910,7 @@ function scanPowerShellList( ) stopParsing = true if (!redirectTarget) { - if (!words.length && !invocation && !expression) segment = wordStart + if (!words.length && !invocation) segment = wordStart words.push(word) rawWords.push(input.slice(wordStart, end)) wordEnds.push(end) @@ -1800,13 +1923,13 @@ function scanPowerShellList( } const finishCommand = (end: number, required = false) => { finishWord(end) - if (words.length && !expression && !clause) + if (words.length && ((!expression && !clause) || hasFileRedirect)) commands.push({ resource: input.slice(segment, commandEnd).trimEnd(), words: [...words], rawWords: [...rawWords], wordEnds: wordEnds.map((value) => value - segment), - ...(statementHead && !invocation ? { statementHead: true as const } : {}), + ...(statementHead && !invocation && !expression && !clause ? { statementHead: true as const } : {}), }) else if ( (!expression && !clause && invocation) || @@ -1820,11 +1943,12 @@ function scanPowerShellList( rawWords.length = 0 wordEnds.length = 0 redirectTarget = false + hasFileRedirect = false invocation = false expression = initialExpression clause = initialClause after = undefined - indexable = false + resetMember() stopParsing = false } @@ -1886,8 +2010,13 @@ function scanPowerShellList( if (char === "'" || char === '"') { if (!started && words.length === 0 && !invocation) expression = true quote = char - standalone = atBoundary || (expression && !redirectTarget) || (indexable === "member" && /[.:]$/.test(word)) - if (!standalone) indexable = false + const dynamicMember = indexable === "member" && /[.:]$/.test(started ? word : (words.at(-1) ?? "")) + standalone = atBoundary || (expression && !redirectTarget) || dynamicMember + if (dynamicMember) hasMemberAccess = true + else if (standalone) { + memberChainStart = index + hasMemberAccess = false + } else resetMember() started = true continue } @@ -1899,7 +2028,7 @@ function scanPowerShellList( // At a token boundary escaped whitespace is trivia, not a new argument. else if (!atBoundary || !rawSpace) { started = true - indexable = false + if (!(indexable === "member" && /[\p{L}\p{Nl}\d_]/u.test(escape.value))) resetMember() word += escape.value } index = escape.end @@ -1924,7 +2053,7 @@ function scanPowerShellList( if (started) finishWord(index) const end = input.indexOf("#>", index + 2) if (end < 0) return { kind: "opaque", reason: "invalid-structure" } - if (!memberOperator) indexable = false + if (!memberOperator) resetMember() index = end + 1 continue } @@ -1961,7 +2090,8 @@ function scanPowerShellList( if (redirectTarget || words.length === 0 || (clause && clause !== "member")) return { kind: "opaque", reason: "invalid-redirect" } redirectTarget = !redirect.includes("&") - indexable = false + if (redirectTarget) hasFileRedirect = true + resetMember() index += redirect.length - 1 commandEnd = index + 1 continue @@ -2030,7 +2160,8 @@ function scanPowerShellList( rawWords.length = 0 wordEnds.length = 0 expression = false - indexable = false + hasFileRedirect = false + resetMember() if (!assign || char !== "=") index++ continue } @@ -2047,6 +2178,8 @@ function scanPowerShellList( started = true index = literal.end finishWord(index + 1) + memberChainStart = wordStart + hasMemberAccess = false indexable = "closed" continue } @@ -2054,7 +2187,15 @@ function scanPowerShellList( const end = powerShellBracedVariable(input, index, skipped) if (end === undefined) return { kind: "opaque", reason: "invalid-structure" } if (!started && !words.length && !invocation && hash !== "switch") expression = true - indexable = atBoundary || (indexable === "member" && /[.:]$/.test(word)) ? "closed" : false + const dynamicMember = indexable === "member" && /[.:]$/.test(started ? word : (words.at(-1) ?? "")) + if (dynamicMember) { + indexable = "closed" + hasMemberAccess = true + } else if (atBoundary) { + memberChainStart = index + hasMemberAccess = false + indexable = "closed" + } else resetMember() word += input.slice(index, end + 1) started = true index = end @@ -2075,7 +2216,17 @@ function scanPowerShellList( ? index : undefined if (opener !== undefined) { + const tokenIndex = index const openChar = input[opener] + const prevMemberWord = started ? word : (words.at(-1) ?? "") + const dynamicMemberOpener = indexable === "member" && /[.:]$/.test(prevMemberWord) + const priorIndexable = Boolean(indexable) + const isMethodCall = + char === "(" && + opener === tokenIndex && + memberChainStart !== undefined && + ((indexable === "member" && started && !/[.:]$/.test(word)) || + (indexable === "closed" && !started && hasMemberAccess)) const standaloneBlock = atBoundary || inExpression || char === "(" || openChar === "{" const blockClose = openChar === "(" ? ")" : openChar === "[" ? "]" : "}" const blockHash = @@ -2105,12 +2256,28 @@ function scanPowerShellList( started = true word += input.slice(index, result.end + 1) index = result.end - if (openChar === "[") { + if (isMethodCall && memberChainStart !== undefined) { + finishWord(index + 1) + const callResource = input.slice(memberChainStart, result.end + 1) + const head = input.slice(memberChainStart, opener) + const args = input.slice(opener, result.end + 1) + nestedCommands.push({ + resource: callResource, + words: [head, args], + rawWords: [head, args], + wordEnds: [head.length, callResource.length], + }) indexable = "closed" + hasMemberAccess = false + } else if (openChar === "[") { + indexable = "closed" + if (!priorIndexable) memberChainStart = tokenIndex if (input[index + 1] !== "[") finishWord(index + 1) } else if (standaloneBlock) { finishWord(index + 1) indexable = "closed" + if (!dynamicMemberOpener) memberChainStart = tokenIndex + hasMemberAccess = dynamicMemberOpener } if ((clause === "paren" && char === "(") || (clause && clause !== "paren" && char === "{")) { const completed = clause === "do" || clause === "if" || clause === "try" ? clause : undefined @@ -2136,8 +2303,9 @@ function scanPowerShellList( continue } if (/\s/.test(char) && char !== "\n" && char !== "\r") { + const memberOperator = indexable === "member" && /[.:]$/.test(started ? word : (words.at(-1) ?? "")) finishWord(index) - indexable = false + if (!memberOperator) resetMember() continue } const next = input[index + 1] @@ -2161,12 +2329,12 @@ function scanPowerShellList( if ((clause && clause !== "member") || prev.endsWith(",") || memberOperator) { finishWord(index) index += separator.length - 1 - if (!memberOperator) indexable = false + if (!memberOperator) resetMember() continue } if (!started && !words.length && !invocation) { index += separator.length - 1 - indexable = false + resetMember() continue } } @@ -2178,39 +2346,53 @@ function scanPowerShellList( } if (!started && !words.length && !invocation && hash !== "switch" && testAt(POWERSHELL_EXPRESSION_RE, input, index)) expression = true - if ((atBoundary || (indexable === "member" && /[.:]$/.test(word))) && char === "$") - indexable = testAt(POWERSHELL_INDEXABLE_VAR_RE, input, index + 1) ? "variable" : false - else if (indexable === "closed") + const prevMember = started ? word : (words.at(-1) ?? "") + if ((atBoundary || (indexable === "member" && /[.:]$/.test(prevMember))) && char === "$") { + if (testAt(POWERSHELL_INDEXABLE_VAR_RE, input, index + 1)) { + if (indexable === "member" && /[.:]$/.test(prevMember)) hasMemberAccess = true + else { + memberChainStart = index + hasMemberAccess = false + } + indexable = "variable" + } else resetMember() + } else if (indexable === "closed") { indexable = - (char === "." && input[index + 1] !== "." && !/\s/.test(input[index + 1] ?? "")) || - (!started && char === ":" && input[index + 1] === ":" && !/\s/.test(input[index + 2] ?? "")) || + (char === "." && input[index + 1] !== "." && (!/\s/.test(input[index + 1] ?? "") || inExpression)) || + (!started && char === ":" && input[index + 1] === ":") || (!started && char === "?" && input[index + 1] === "." && input[index + 2] !== "." && - !/\s/.test(input[index + 2] ?? "")) + (!/\s/.test(input[index + 2] ?? "") || inExpression)) ? "member" : !started && char === "?" && input[index + 1] === "[" ? "closed" : false - else if (indexable === "variable") + if (indexable === "member") hasMemberAccess = true + else if (!indexable) resetMember() + } else if (indexable === "variable") { indexable = - (char === "." && input[index + 1] !== "." && !/\s/.test(input[index + 1] ?? "")) || - (char === ":" && input[index + 1] === ":" && !/\s/.test(input[index + 2] ?? "")) + (char === "." && input[index + 1] !== "." && (!/\s/.test(input[index + 1] ?? "") || inExpression)) || + (char === ":" && input[index + 1] === ":") ? "member" : (char === "." && input[index + 1] === ".") || !/[\p{L}\p{Nl}\d_:?]/u.test(char) ? false : "variable" - else if (indexable === "member") + if (indexable === "member") hasMemberAccess = true + else if (!indexable) resetMember() + } else if (indexable === "member") { indexable = char === "." && input[index + 1] === "." ? false - : (char === "." && !/\s/.test(input[index + 1] ?? "")) || + : (char === "." && (!/\s/.test(input[index + 1] ?? "") || inExpression)) || (char === ":" && (input[index + 1] === ":" || input[index - 1] === ":")) || (char === "?" && input[index + 1] === "." && input[index + 2] !== ".") || /[\p{L}\p{Nl}\d_]/u.test(char) ? "member" : false + if (!indexable) resetMember() + } after = undefined started = true dangling = false diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index da104905bdc6..711183d1d144 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -3,8 +3,9 @@ export { CallID, Content, Error, FileContent, TextContent } from "@opencode/sche export type { Context, Metadata, Namespace, Options, Result } from "@opencode/schema/tool" import { ToolDefinition, type ToolCall } from "@opencode/ai/effect" +import type { CodeMode } from "@opencode/codemode" import { Tool } from "@opencode/schema/tool" -import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, Types } from "effect" +import { Context, Effect, Layer, Option, Ref, Result, Schema, SchemaIssue, Types } from "effect" import { makeLocationNode } from "@opencode/util/effect/app-node" import type { Agent } from "./agent.js" import { CodeModeCatalog } from "./codemode/catalog.js" @@ -44,6 +45,20 @@ type Data = { export interface Interface extends State.Transformable { readonly list: () => Effect.Effect> readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect + /** + * Runs a Code Mode program with no model step over only the Code Mode tools named in `tools` (still subject to + * whole-tool permission filtering and each tool's own executor policy), with no `fetch`. + */ + readonly check: (input: CheckInput) => Effect.Effect +} + +export interface CheckInput { + readonly code: string + readonly tools: ReadonlyArray + readonly bindings: Readonly> + readonly permissions: Permission.Ruleset + readonly limits: CodeMode.ExecutionLimits + readonly context: Omit } /** A local execution result after hooks and content normalization. */ @@ -72,6 +87,7 @@ const layer = Layer.effect( Effect.gen(function* () { const hooks = yield* PluginHooks.Service const image = yield* Image.Service + const permission = yield* Permission.Service type NormalizedItem = Tool.Content | "decode" | "size" const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray) { @@ -291,10 +307,13 @@ const layer = Layer.effect( namespaces: data.namespaces, } const codeModeTool = codeModeEnabled - ? CodeModeTool.create(codeModeRuntimeInventory, (name, tool, input, context) => - beforeExecute(name, input, context).pipe( - Effect.flatMap((event) => executeTool(tool, name, event.input, context)), - ), + ? CodeModeTool.create( + codeModeInventory, + (name, tool, input, context) => + beforeExecute(name, input, context).pipe( + Effect.flatMap((event) => executeTool(tool, name, event.input, context)), + ), + permission, ) : undefined const codeModeCatalog = !codeModeEnabled @@ -348,6 +367,42 @@ const layer = Layer.effect( } }), ), + check: Effect.fn("Tool.check")(function* (input) { + if (whollyDisabled("execute", input.permissions)) { + return { + ok: false, + error: { kind: "ExecutionFailure", message: "Code Mode is disabled for this agent" }, + toolCalls: [], + } satisfies CodeMode.Result + } + const data = state.get() + const allowed = new Set(input.tools) + const tools = new Map( + Array.from(data.tools).filter( + ([name, tool]) => + tool.options?.codemode !== false && + allowed.has(CodeModeTool.qualifiedName(tool)) && + !whollyDisabled(tool.options?.permission ?? name, input.permissions), + ), + ) + const callSeq = yield* Ref.make(0) + return yield* CodeModeTool.check( + { tools, namespaces: data.namespaces }, + input.code, + input.bindings, + input.limits, + (name, tool, toolInput) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(callSeq, (value) => value + 1) + const context: Tool.Context = { + ...input.context, + id: Tool.CallID.make(`call_${input.context.messageID}_${index}`), + } + const event = yield* beforeExecute(name, toolInput, context) + return yield* executeTool(tool, name, event.input, context) + }), + ) + }), }) }), ) @@ -391,5 +446,5 @@ function namespaceError(name: string) { export const node = makeLocationNode({ service: Service, layer, - deps: [PluginHooks.node, Image.node], + deps: [PluginHooks.node, Image.node, Permission.node], }) diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index a37cdff427bf..7fbce8a31dae 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -105,11 +105,14 @@ export const layer = Layer.effect( ...(content.length === 0 ? {} : { content }), } }).pipe( - Effect.mapError((error) => - error instanceof ToolFailure - ? error - : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), - ), + Effect.mapError((error) => { + if (error instanceof ToolFailure) return error + if (error instanceof Permission.BlockedError) + return new ToolFailure({ message: error.message, error }) + if (error instanceof Permission.CorrectedError) + return new ToolFailure({ message: error.feedback, error }) + return new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}`, error }) + }), ), }) } diff --git a/packages/core/src/tool/plugin/artifact.ts b/packages/core/src/tool/plugin/artifact.ts new file mode 100644 index 000000000000..528755432896 --- /dev/null +++ b/packages/core/src/tool/plugin/artifact.ts @@ -0,0 +1,169 @@ +export * as ArtifactTools from "./artifact.js" + +import { ToolFailure } from "@opencode/ai/effect" +import type { Context } from "@opencode/plugin/effect/plugin" +import { optional } from "@opencode/schema/schema" +import { Effect, Schema, Stream } from "effect" +import { Artifact } from "../../artifact.js" +import { Config } from "../../config.js" +import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js" + +export const CreateToolInput = Schema.Struct({ + title: Schema.String.annotate({ description: "Human-readable artifact title." }), + files: Schema.Record(Schema.String, Schema.String).annotate({ + description: "UTF-8 file map keyed by relative path (for example index.html and assets/app.js).", + }), + entry: Schema.String.pipe(optional).annotate({ + description: "Entry file path relative to the artifact root. Defaults to index.html, index.md, or the sole file.", + }), + provider: Schema.String.pipe(optional).annotate({ + description: "Artifact provider ID to override the default provider for this request.", + }), +}) + +export const ReadToolInput = Schema.Struct({ + id: Artifact.ID.annotate({ description: "Artifact ID (art_...) to read." }), +}) + +export const UpdateToolInput = Schema.Struct({ + id: Artifact.ID.annotate({ description: "Artifact ID (art_...) to update." }), + title: Schema.String.pipe(optional).annotate({ description: "Updated title, or omit to keep the current title." }), + files: Schema.Record(Schema.String, Schema.String).annotate({ + description: "Replacement UTF-8 file map keyed by relative path.", + }), + entry: Schema.String.pipe(optional).annotate({ description: "Updated entry file path." }), +}) + +export const DeleteToolInput = Schema.Struct({ + id: Artifact.ID.annotate({ description: "Artifact ID (art_...) to delete." }), +}) + +const DeleteToolOutput = Schema.Struct({ + id: Artifact.ID, + deleted: Schema.Boolean, +}) + +export const Plugin = { + id: "opencode.tool.artifact", + effect: Effect.fn("ArtifactTools.Plugin")(function* (ctx: Context) { + const config = yield* Config.Service + const artifacts = yield* Artifact.Service + + const promptSnapshot = { text: yield* renderCreateDescription(artifacts) } + const refreshTools = Effect.gen(function* () { + promptSnapshot.text = yield* renderCreateDescription(artifacts) + yield* ctx.tool.reload() + }) + + const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, refreshTools) + yield* ctx.event.subscribe({ types: [Artifact.Event.Updated.type] }).pipe( + Stream.runForEach(() => refreshTools), + Effect.forkScoped({ startImmediately: true }), + ) + + const enabled = () => Config.latest(loaded.entries, "experimental")?.artifacts === true + const failure = (error: { readonly message: string }) => new ToolFailure({ message: error.message, error }) + + yield* ctx.tool + .transform((editor) => { + if (!enabled()) return + + editor.add({ + name: "artifact_create", + description: promptSnapshot.text, + input: CreateToolInput, + output: Artifact.Info, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + artifacts + .create({ + title: input.title, + files: input.files, + entry: input.entry, + provider: input.provider === undefined ? undefined : Artifact.ProviderID.make(input.provider), + sessionID: context.sessionID, + }) + .pipe( + Effect.map((info) => ({ + output: info, + content: `Published ${info.title} (${info.id} via ${info.provider}): ${info.url}`, + })), + Effect.mapError(failure), + ), + }) + + editor.add({ + name: "artifact_read", + description: "Read an artifact's metadata and file contents so you can inspect or update it.", + input: ReadToolInput, + output: Artifact.Content, + options: { namespace: "opencode", codemode: true }, + execute: (input) => + artifacts.get(input.id).pipe( + Effect.map((content) => ({ + output: content, + content: `${content.info.title} (${content.info.id} · ${Object.keys(content.files).length} files)`, + })), + Effect.mapError(failure), + ), + }) + + editor.add({ + name: "artifact_update", + description: "Replace an existing artifact's files at the same ID and URL.", + input: UpdateToolInput, + output: Artifact.Info, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + artifacts + .update(input.id, { + title: input.title, + files: input.files, + entry: input.entry, + sessionID: context.sessionID, + }) + .pipe( + Effect.map((info) => ({ + output: info, + content: `Updated ${info.title} (${info.id}): ${info.url}`, + })), + Effect.mapError(failure), + ), + }) + + editor.add({ + name: "artifact_delete", + description: "Delete an artifact and remove its stored copy.", + input: DeleteToolInput, + output: DeleteToolOutput, + options: { namespace: "opencode", codemode: true }, + execute: (input) => + artifacts.remove(input.id).pipe( + Effect.map(() => ({ + output: { id: input.id, deleted: true }, + content: `Deleted ${input.id}`, + })), + Effect.mapError(failure), + ), + }) + }) + .pipe(Effect.orDie) + }), +} + +function renderCreateDescription(artifacts: Artifact.Interface) { + return artifacts.prompts().pipe( + Effect.map(({ defaultProvider, others }) => { + const lines = [ + "Publish a new artifact to the default artifact provider or a named override.", + `Default provider (${defaultProvider.id} [${defaultProvider.capabilities.join(", ")}]): ${defaultProvider.prompt}`, + ] + if (others.length > 0) { + lines.push( + `Other active providers (pass provider to override): ${others.map((item) => `${item.id} [${item.capabilities.join(", ")}]`).join(", ")}.`, + ) + } + return lines.join("\n") + }), + ) +} diff --git a/packages/core/src/tool/plugin/schedule.ts b/packages/core/src/tool/plugin/schedule.ts index e8ef41a373b9..781ef69a5bd0 100644 --- a/packages/core/src/tool/plugin/schedule.ts +++ b/packages/core/src/tool/plugin/schedule.ts @@ -76,7 +76,7 @@ export const Plugin = { editor.add({ name: "schedule_create", description: - "Schedule a prompt that wakes this session later: once after a delay, at a time, every interval, on a cron, or gated by an `if` / `until` shell check. Returns the schedule with its next run (or `done` immediately if `until` already matches).", + "Schedule a prompt that wakes this session later: once after a delay, at a time, every interval, on a cron, or gated by an `if` / `until` shell (`{ run }`) or Code Mode (`{ code }`) check. Returns the schedule with its next run (or `done` immediately if `until` already matches).", input: Schedule.Request, output: View, options: { namespace: "opencode", codemode: true }, @@ -174,6 +174,13 @@ function formatCondition(condition: Schedule.Condition): string { switch (condition.type) { case "shell": return JSON.stringify(condition.run) + case "code": { + const first = condition.code + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + return `code ${JSON.stringify(first ? first.slice(0, 60) : "")}` + } } } diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 5dcbb927857c..c876ed2af334 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -6,7 +6,6 @@ import type { SessionHooks } from "@opencode/plugin/effect/session" import type { ShellCreateBefore } from "@opencode/plugin/effect/shell" import type { Tool } from "@opencode/schema/tool" import { Deferred, Effect, Schema, Scope } from "effect" -import { Config } from "../../config.js" import { Environment } from "../../environment/index.js" import { Job } from "../../job.js" import { FileAccess } from "../../file-access.js" @@ -15,7 +14,6 @@ import { NonNegativeInt } from "../../schema.js" import { Session } from "../../session.js" import { SessionSchema } from "../../session/schema.js" import { Shell } from "../../shell.js" -import { shellParserWasm } from "#shell-parser-wasm" import { ShellParse } from "../../shell/parse.js" import { ShellSelect } from "../../shell/select.js" import { ShellResult } from "../../shell/result.js" @@ -112,7 +110,6 @@ export const Plugin = { const shellSelect = yield* ShellSelect.Service const compatibleShell = shellSelect.resolve({ priority: "compat" }) const permission = yield* Permission.Service - const config = yield* Config.Service const prepare = Effect.fn("ShellTool.prepare")(function* (invocation: ShellCreateBefore, context: Tool.Context) { const source = { @@ -123,10 +120,7 @@ export const Plugin = { const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" }) invocation.cwd = target.absolute const timeout = invocation.timeout - const portable = - Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner ?? - (shellParserWasm.bash === "" || ctx.app.channel === "local" || ctx.app.channel === "dev") - const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable }) + const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute) const directories = yield* Effect.forEach(parsed.directories, (directory) => access.resolve({ path: FileAccess.resolvePath(target.absolute, directory), diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index f8f8dd3073be..eeb593730507 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -76,6 +76,8 @@ type Data = { export type Editor = { add: (provider: ProviderImplementation) => void + list: () => readonly ProviderImplementation[] + remove: (id: ID) => void default: { get: () => Selection | undefined set: (selection: Selection) => void @@ -109,6 +111,8 @@ const layer = Layer.effect( initial: () => ({ providers: new Map() }), editor: (editor) => ({ add: (provider) => editor.providers.set(provider.id, provider), + list: () => Array.from(editor.providers.values()), + remove: (id) => editor.providers.delete(id), default: { get: () => editor.selection, set: (selection) => (editor.selection = selection), diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index ab7b09e94529..e9c03a2aa844 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -7,19 +7,20 @@ import { toSessionError } from "@opencode/core/session/to-session-error" import { Model } from "@opencode/core/model" import { Provider } from "@opencode/core/provider" import { - Media, - LLM, + ai, AIError, CompactionPart, - ProviderID, HttpContext, InvalidRequestError, + isContextOverflowFailure, + LLM, LLMEvent, + Media, Message, + ProviderID, RateLimitError, TransportError, UnknownProviderError, - isContextOverflowFailure, } from "@opencode/ai/effect" import { LLMClient, RequestExecutor } from "@opencode/ai/route" import { compileRequest } from "@opencode/ai/testing" @@ -81,7 +82,7 @@ it.effect("rejects native provider compaction rather than silently dropping repl }) const resolved = yield* aisdk.model(model("@ai-sdk/openai")) const error = yield* compileRequest( - LLM.request({ + ai.request({ model: resolved, messages: [ Message.assistant( @@ -150,7 +151,7 @@ it.effect("uses canonical names and metadata without merging connection cache pa expect(resolved).toMatchObject({ id: "api-model", provider: "openai" }) expect(resolved.route).toMatchObject({ provider: "openai", providerMetadataKey: "openai" }) expect(resolved.route.model({ id: "another-model" })).toMatchObject({ provider: "openai" }) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(prepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high" } }) }), ) @@ -181,7 +182,7 @@ it.effect("projects request settings, headers, and body overlays", () => body: { safety_setting: "strict" }, }) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: resolved, prompt: "Hello", providerOptions: { safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }] }, @@ -240,7 +241,7 @@ it.effect("lowers chronological system updates to wrapped user messages", () => const resolved = yield* aisdk.model(model("opaque-provider")) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: resolved, system: "Initial instructions.", messages: [ @@ -279,7 +280,7 @@ it.effect("leaves max output tokens unset when the request omits them", () => ...model("@openrouter/ai-sdk-provider"), limit: { context: 500_000, output: 500_000 }, }) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(prepared.body.maxOutputTokens).toBeUndefined() }), @@ -298,7 +299,7 @@ it.effect("maps pro reasoning bodies to AI SDK provider options", () => ...model("@ai-sdk/openai"), body: { reasoning: { mode: "pro" } }, }) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(body).toBeUndefined() expect(prepared.body.providerOptions).toEqual({ @@ -323,7 +324,7 @@ it.effect("maps package-specific AI SDK provider option keys", () => ] as const for (const [packageName, key, settings] of cases) { const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" })) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(prepared.body.providerOptions).toEqual({ [key]: settings }) } }), @@ -337,13 +338,13 @@ it.effect("forces reasoning and projects both Azure AI SDK namespaces", () => }) const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" })) - const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" })) + const openaiPrepared = yield* compileRequest(ai.request({ model: openai, prompt: "Hello" })) expect(openaiPrepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high", forceReasoning: true }, }) const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" })) - const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" })) + const azurePrepared = yield* compileRequest(ai.request({ model: azure, prompt: "Hello" })) expect(azurePrepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high", forceReasoning: true }, azure: { reasoningEffort: "high", forceReasoning: true }, @@ -365,7 +366,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => }), modelID: Model.ID.make("anthropic/claude-sonnet-5"), }) - const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" })) + const anthropicPrepared = yield* compileRequest(ai.request({ model: anthropic, prompt: "Hello" })) expect(anthropicPrepared.body.providerOptions).toEqual({ gateway: { order: ["anthropic"] }, anthropic: { thinking: { type: "adaptive" } }, @@ -375,7 +376,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }), modelID: Model.ID.make("amazon/nova-2-lite"), }) - const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" })) + const bedrockPrepared = yield* compileRequest(ai.request({ model: bedrock, prompt: "Hello" })) expect(bedrockPrepared.body.providerOptions).toEqual({ bedrock: { reasoningConfig: { type: "enabled" } }, }) @@ -385,7 +386,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => modelID: Model.ID.make("openai/gpt-5.5"), }) const openaiPrepared = yield* compileRequest( - LLM.request({ model: openai, prompt: "Hello", providerOptions: { textVerbosity: "low" } }), + ai.request({ model: openai, prompt: "Hello", providerOptions: { textVerbosity: "low" } }), ) expect(openaiPrepared.body.providerOptions).toEqual({ gateway: { order: ["openai"] }, @@ -396,7 +397,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => ...model("@ai-sdk/gateway", { reasoningEffort: "high" }), modelID: Model.ID.make("deepseek/deepseek-v4"), }) - const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" })) + const fallbackPrepared = yield* compileRequest(ai.request({ model: fallback, prompt: "Hello" })) expect(fallbackPrepared.body.providerOptions).toEqual({ deepseek: { reasoningEffort: "high" }, }) @@ -422,7 +423,7 @@ it.effect("closes the open AI SDK reasoning part when the next one starts", () = }) const resolved = yield* aisdk.model(model("@ai-sdk/gateway")) - const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Think" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Think" })).pipe( Effect.provide(client), ) @@ -464,7 +465,7 @@ it.effect("normalizes repeated, reopened, and overlapping AI SDK fragment bounda }) const resolved = yield* aisdk.model(model("@ai-sdk/gateway")) - const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Think" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Think" })).pipe( Effect.provide(client), ) @@ -497,7 +498,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () => const resolved = yield* aisdk.model(model("@ai-sdk/anthropic")) expect(resolved.route.providerMetadataKey).toBe("anthropic") const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: resolved, messages: [ Message.assistant([ @@ -560,7 +561,7 @@ it.effect("normalizes file data across AI SDK prompt parts", () => const resolved = yield* aisdk.model(model("opaque-provider")) const bytes = new Uint8Array([0, 1, 2, 3]) const prepared = yield* compileRequest( - LLM.request({ + ai.request({ model: resolved, messages: [ Message.user([ @@ -743,7 +744,7 @@ it.effect("does not treat SSE comment heartbeats as model progress", () => fetch: customFetch, }), ) - const result = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe( + const result = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Hello" })).pipe( Effect.provide(client), Effect.result, Effect.ensuring( @@ -800,7 +801,7 @@ it.effect("routes AI SDK requests and responses through HTTP hook middleware", ( ), ) const seen: string[] = [] - const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" }), { + const response = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Hello" }), { http: (request, handler) => Effect.gen(function* () { // Read the body twice the way session hooks do, to prove it is not a single-use stream. @@ -845,7 +846,7 @@ it.effect("sends AI SDK requests directly when no HTTP hook middleware is attach { preconnect: fetch.preconnect }, ), ) - const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Hello" })).pipe( Effect.provide(client), ) expect(bodies).toHaveLength(1) @@ -879,7 +880,7 @@ it.effect("fails with a retryable transport error when response headers time out fetch: customFetch, }), ) - const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe( + const error = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Hello" })).pipe( Effect.provide(client), Effect.flip, ) @@ -911,7 +912,7 @@ it.effect("emits malformed AI SDK tool input without executing it", () => }) const resolved = yield* aisdk.model(model("test-ai-sdk")) - const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe( + const response = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Lookup" })).pipe( Effect.provide(client), ) @@ -949,7 +950,7 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () => }) const resolved = yield* aisdk.model(model("hosted-test-ai-sdk")) - const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe( + const error = yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Search" })).pipe( Effect.provide(client), Effect.flip, ) @@ -977,7 +978,7 @@ const streamFailure = (failure: unknown, streamed = false) => } }) const resolved = yield* aisdk.model(model("test-ai-sdk")) - return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe( + return yield* LLMClient.generate(ai.request({ model: resolved, prompt: "Hello" })).pipe( Effect.provide(client), Effect.flip, ) diff --git a/packages/core/test/artifact.test.ts b/packages/core/test/artifact.test.ts new file mode 100644 index 000000000000..5cbc5a4b2a0f --- /dev/null +++ b/packages/core/test/artifact.test.ts @@ -0,0 +1,206 @@ +import path from "node:path" +import { describe, expect } from "bun:test" +import { Effect, Layer, Stream } from "effect" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { Artifact } from "@opencode/core/artifact" +import { Config } from "@opencode/core/config" +import { Image } from "@opencode/core/image" +import { Location } from "@opencode/core/location" +import { Permission } from "@opencode/core/permission" +import { Session } from "@opencode/core/session" +import { Tool } from "@opencode/core/tool" +import { ArtifactTools } from "@opencode/core/tool/plugin/artifact" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Global } from "@opencode/util/global" +import { Document, Info } from "@opencode/schema/config" +import { ConfigExperimental } from "@opencode/schema/config/experimental" +import { AbsolutePath } from "@opencode/schema/schema" +import { tmpdirScoped } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" +import { codeModeListings, executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" + +const toolPaths = [ + "opencode.artifact_create", + "opencode.artifact_read", + "opencode.artifact_update", + "opencode.artifact_delete", +] + +const buildLayer = (artifactsEnabled: boolean, dir: string) => { + const entries = artifactsEnabled + ? [ + new Document({ + type: "document", + info: new Info({ + experimental: new ConfigExperimental.Info({ artifacts: true }), + }), + }), + ] + : [] + const ref = Location.Ref.make({ directory: AbsolutePath.make(dir) }) + return AppNodeBuilder.build( + LayerNode.group([Artifact.node, Config.node, Global.node, Location.node, Permission.node, Session.node, Tool.node]), + [ + Config.node.replace(Config.testLayer(entries)), + Global.node.replace(Global.layerWith({ data: path.join(dir, ".data") })), + Image.node.replace(imagePassthrough), + Location.node.replace(Location.boundNode(ref)), + Permission.node.replace(permissionLayer({ assert: () => Effect.void })), + ], + ) +} + +const callCodeMode = (registry: Tool.Interface, sessionID: Session.ID, name: string, input: unknown) => + executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: `call-${name}`, + name: "execute", + input: { code: `return await tools.opencode.${name}(${JSON.stringify(input)})` }, + }, + }).pipe( + Effect.map((result) => { + if (result.status !== "completed") { + throw new Error(`Tool ${name} failed: ${JSON.stringify(result.error)}`) + } + return JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "") + }), + ) + +const it = testEffect(Layer.empty) + +describe("experimental.artifacts service and tools", () => { + it.live("stays disabled and hides artifact tools when experimental.artifacts is unset", () => + Effect.gen(function* () { + const dir = (yield* tmpdirScoped("opencode-artifact-off-")).path + yield* Effect.gen(function* () { + const artifacts = yield* Artifact.Service + const registry = yield* Tool.Service + expect(yield* artifacts.enabled()).toBe(false) + expect( + yield* artifacts + .create({ title: "Draft", files: { "index.html": "

    hi

    " } }) + .pipe(Effect.flip), + ).toBeInstanceOf(Artifact.DisabledError) + + yield* registerToolPlugin(ArtifactTools.Plugin, { + event: { subscribe: () => Stream.never }, + }) + const snap = yield* registry.snapshot() + const listed = snap.codeModeCatalog ? codeModeListings(snap.codeModeCatalog).map((item) => item.path) : [] + for (const name of toolPaths) { + expect(listed).not.toContain(name) + } + }).pipe(Effect.provide(buildLayer(false, dir))) + }), + ) + + it.live("registers custom providers, routes overrides, and runs the 4 CRUD tools", () => + Effect.gen(function* () { + const dir = (yield* tmpdirScoped("opencode-artifact-on-")).path + + yield* Effect.gen(function* () { + const artifacts = yield* Artifact.Service + const registry = yield* Tool.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(dir) }), + }) + + const remoteStore = new Map>() + yield* artifacts.transform((editor) => { + editor.add({ + id: Artifact.ProviderID.make("cloudflare"), + name: "Cloudflare", + capabilities: ["static", "server-code", "sqlite", "durable-objects"], + prompt: "Artifacts deploy to Cloudflare with SQLite and Durable Objects.", + create: (input) => + Effect.sync(() => { + remoteStore.set(input.id, { ...input.files }) + return { url: `https://cf.example.test/${input.id}/` } + }), + read: (id) => Effect.sync(() => remoteStore.get(id) ?? {}), + update: (id, input) => + Effect.sync(() => { + remoteStore.set(id, { ...input.files }) + return { url: `https://cf.example.test/${id}/` } + }), + delete: (id) => + Effect.sync(() => { + remoteStore.delete(id) + }), + }) + editor.default.set(Artifact.ProviderID.make("cloudflare")) + }) + + yield* registerToolPlugin(ArtifactTools.Plugin, { + event: { subscribe: () => Stream.never }, + }) + const snap = yield* registry.snapshot() + const listings = snap.codeModeCatalog ? codeModeListings(snap.codeModeCatalog) : [] + expect(listings.map((item) => item.path)).toEqual(expect.arrayContaining(toolPaths)) + const rawTools = yield* registry.list() + const createTool = rawTools.find((item) => item.id === "opencode_artifact_create") + expect(createTool?.description).toContain( + "Default provider (cloudflare [static, server-code, sqlite, durable-objects]): Artifacts deploy to Cloudflare with SQLite and Durable Objects.", + ) + expect(createTool?.description).toContain( + "Other active providers (pass provider to override): local [static].", + ) + + const createdInfo = (yield* callCodeMode(registry, session.id, "artifact_create", { + title: "Flamegraph report", + files: { + "index.html": "", + "assets/app.js": "console.log('v1')", + }, + })) as Artifact.Info + + expect(createdInfo).toMatchObject({ + provider: "cloudflare", + sessionID: session.id, + title: "Flamegraph report", + entry: "index.html", + url: `https://cf.example.test/${createdInfo.id}/`, + }) + + const content = (yield* callCodeMode(registry, session.id, "artifact_read", { + id: createdInfo.id, + })) as Artifact.Content + expect(Object.keys(content.files).toSorted()).toEqual(["assets/app.js", "index.html"]) + expect(content.files["assets/app.js"]).toBe("console.log('v1')") + + // Per-request provider override ("local") stores independently of default ("cloudflare") + const localInfo = (yield* callCodeMode(registry, session.id, "artifact_create", { + title: "Local scratch", + files: { "index.html": "

    Notes

    " }, + provider: "local", + })) as Artifact.Info + expect(localInfo.provider).toBe(Artifact.LocalProviderID) + + // Update replaces files at the same ID and URL + const updatedInfo = (yield* callCodeMode(registry, session.id, "artifact_update", { + id: createdInfo.id, + files: { "index.html": "

    v2

    " }, + })) as Artifact.Info + expect(updatedInfo.url).toBe(createdInfo.url) + + yield* callCodeMode(registry, session.id, "artifact_delete", { id: createdInfo.id }) + expect(yield* artifacts.get(createdInfo.id).pipe(Effect.flip)).toBeInstanceOf(Artifact.NotFoundError) + + // Reject path traversal + const traversal = yield* artifacts + .create({ + title: "Bad path", + files: { "../escape.html": "nope" }, + }) + .pipe(Effect.flip) + expect(traversal).toBeInstanceOf(Artifact.InvalidError) + }).pipe(Effect.provide(buildLayer(true, dir))) + }), + ) +}) diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts index e8837c58fbb1..3721023f7f4c 100644 --- a/packages/core/test/config/compaction.test.ts +++ b/packages/core/test/config/compaction.test.ts @@ -170,7 +170,7 @@ const input = (tokens: number) => { model: resolved, messages, agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) }, - initial: "", + initial: [], tools: { definitions: [], execute: () => Effect.die("unused") }, }, } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 946123949a5e..e263631dde9d 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -220,37 +220,35 @@ describe("Config", () => { // Filesystem sandboxes deny reads outside the project. Simulate a denied discovery root with a // symlink into an unsearchable directory, which makes realpath fail with EACCES. - itWithPermissions( - "treats unreadable discovery roots as absent", - () => - Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( - Effect.flatMap((tmp) => { - const global = path.join(tmp.path, "global") - const home = path.join(global, "home") - const project = path.join(home, "project") - const locked = path.join(tmp.path, "locked") - return Effect.promise(async () => { - await fs.mkdir(path.join(locked, "agents"), { recursive: true }) - await fs.mkdir(project, { recursive: true }) - await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) - await fs.symlink(path.join(locked, "agents"), path.join(home, ".agents")) - await fs.symlink(path.join(locked, "agents"), path.join(project, ".opencode")) - await fs.chmod(locked, 0o000) - }).pipe( - Effect.andThen(Effect.addFinalizer(() => Effect.promise(() => fs.chmod(locked, 0o755)))), - Effect.andThen( - Effect.gen(function* () { - const config = yield* Config.Service - const entries = yield* config.entries() - expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual([ - "project", - ]) - }).pipe(Effect.provide(testLayer(project, global))), - ), - Effect.scoped, - ) - }), - ), + itWithPermissions("treats unreadable discovery roots as absent", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const home = path.join(global, "home") + const project = path.join(home, "project") + const locked = path.join(tmp.path, "locked") + return Effect.promise(async () => { + await fs.mkdir(path.join(locked, "agents"), { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + await fs.symlink(path.join(locked, "agents"), path.join(home, ".agents")) + await fs.symlink(path.join(locked, "agents"), path.join(project, ".opencode")) + await fs.chmod(locked, 0o000) + }).pipe( + Effect.andThen(Effect.addFinalizer(() => Effect.promise(() => fs.chmod(locked, 0o755)))), + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + const entries = yield* config.entries() + expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual([ + "project", + ]) + }).pipe(Effect.provide(testLayer(project, global))), + ), + Effect.scoped, + ) + }), + ), ) it.live("discovers the global config directory once when the project walk reaches it", () => @@ -326,9 +324,7 @@ describe("Config", () => { ]) expect(Config.latest(entries, "shell")).toBe("system") expect(Config.latest(entries, "username")).toBe("system-user") - expect( - ManagedPolicy.decision(ManagedPolicy.statements(entries), "tool.use", "shell:ls"), - ).toBe("deny") + expect(ManagedPolicy.decision(ManagedPolicy.statements(entries), "tool.use", "shell:ls")).toBe("deny") }).pipe( Effect.provide( testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, { @@ -646,6 +642,110 @@ describe("Config", () => { ), ) + it.live("keeps only the policies of documents from integrations managed policy denies", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + const system = path.join(tmp.path, "system") + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const allowed = "org_allowed" + const opencode = Integration.ID.make("opencode") + const origin = "https://ai.acme.com" + const credentialID = Credential.ID.create() + let org = "org_personal" + const credentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.mock(Credential.Service)({ + list: () => Effect.succeed([]), + get: (id) => + Effect.succeed( + new Credential.Info({ + id, + integrationID: opencode, + label: "Console", + value: Credential.Key.make({ type: "key", key: "secret", metadata: { orgID: org } }), + }), + ), + }), + deps: [], + }) + const entry: WellKnown.Entry = { origin, integrationID: Integration.ID.make(origin), manifest: {} } + const wellknownNode = makeGlobalNode({ + service: WellKnown.Service, + layer: Layer.mock(WellKnown.Service)({ + entries: () => Effect.succeed([entry]), + snapshot: () => [entry], + cached: () => + Effect.succeed([ + { + mcp: { servers: { wiki: { type: "remote", url: "https://wiki.acme.com/mcp" } } }, + policies: [{ action: "tool.use", resource: "shell:rm*", effect: "deny" }], + }, + ]), + }), + deps: [], + }) + const supplied = (entries: readonly Entry[]) => + entries.flatMap((entry) => + entry.type === "document" && !entry.path + ? [{ servers: Object.keys(entry.info.mcp?.servers ?? {}), policies: entry.info.policies?.length ?? 0 }] + : [], + ) + return Effect.promise(() => + fs.mkdir(system, { recursive: true }).then(() => + fs.writeFile( + path.join(system, "opencode.json"), + JSON.stringify({ + policies: [ + { action: "integration.use", resource: "*", effect: "deny" }, + { action: "integration.use", resource: `opencode#${allowed}`, effect: "allow" }, + ], + }), + ), + ), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + const install = () => + config.integration!( + opencode, + ConfigIntegration.make({ + integrationID: opencode, + connection: { type: "credential", id: credentialID, label: "Console", method: "key" }, + organization: org, + info: decodeInfo({ + mcp: { servers: { docs: { type: "remote", url: "https://docs.acme.com/mcp" } } }, + policies: [{ action: "mcp.use", resource: "github", effect: "deny" }], + }), + }), + ) + yield* install() + // The personal account's servers and the denied well-known origin's config are gone; their policies stay. + expect(supplied(yield* config.entries())).toEqual([ + { servers: [], policies: 1 }, + { servers: [], policies: 1 }, + ]) + org = allowed + yield* config.integration!(opencode, undefined) + yield* install() + expect(supplied(yield* config.entries())).toEqual([ + { servers: ["docs"], policies: 1 }, + { servers: [], policies: 1 }, + ]) + }).pipe( + Effect.provide( + testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, { + system, + }), + ), + ), + ), + ) + }), + ), + ) + it.live("skips project configuration when project discovery is disabled", () => Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( Effect.flatMap((tmp) => { diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts index fcbbf9d5c267..ae7974047b19 100644 --- a/packages/core/test/config/normalization.test.ts +++ b/packages/core/test/config/normalization.test.ts @@ -598,6 +598,22 @@ describe("ConfigNormalize", () => { ).toEqual({ policies: [{ action: "provider.use", resource: "*", effect: "deny" }] }) }) + test("converts legacy prefixed integration.use statements from config and MDM files", () => { + expect( + normalized({ + policies: [ + { action: "integration.use", resource: "mcp:github", effect: "deny" }, + { action: "integration.use", resource: "*", effect: "deny" }, + ], + }).encoded, + ).toEqual({ + policies: [ + { action: "mcp.use", resource: "github", effect: "deny" }, + { action: "integration.use", resource: "*", effect: "deny" }, + ], + }) + }) + test("reports unsupported legacy settings without including their values", () => { const secret = "do-not-log-this-value" const result = normalized({ diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index 1340f8ed51d5..9cfa22fca348 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -11,6 +11,9 @@ import { PluginHooks } from "@opencode/core/plugin/hooks" import { PluginHost } from "@opencode/core/plugin/host" import { Provider } from "@opencode/core/provider" import { Session } from "@opencode/core/session" +import { Credential } from "@opencode/core/credential" +import { ConfigIntegration } from "@opencode/core/config/integration" +import { WebSearch } from "@opencode/core/websearch" import { Effect, Schema } from "effect" import { testEffect } from "../lib/effect" import { organizationPolicy } from "../fixture/policy" @@ -31,6 +34,32 @@ const tool = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info effect, }) +const integration = ( + action: "integration.connect" | "integration.use", + effect: ConfigPolicy.Effect, + resource: string, +): ConfigPolicy.Info => ({ action, resource, effect }) + +// The MDM policy that requires one Console organization, as an admin would deploy it. +const orgID = "org_01M3CAP8H43Z49CER6NT0Y3ZF1" +const lockdown = () => + document( + integration("integration.connect", "deny", "*"), + integration("integration.connect", "allow", "opencode"), + integration("integration.use", "deny", "*"), + integration("integration.use", "allow", `opencode#${orgID}`), + ) + +const consoleCredential = (org: string) => + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "access", + refresh: "refresh", + expires: Number.MAX_SAFE_INTEGER, + metadata: { accountID: "acc_personal", orgID: org }, + }) + const addPlugin = Effect.fn(function* (entries: Entry[]) { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) @@ -227,6 +256,160 @@ describe("ConfigPolicyPlugin.Plugin", () => { ) }) +describe("integration policy", () => { + it.live("requires one Console organization through MDM policy alone", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const credentials = yield* Credential.Service + const websearch = yield* WebSearch.Service + const host = yield* PluginHost.make(yield* Plugin.Service) + const search = (id: string) => ({ + id: WebSearch.ID.make(id), + name: id, + execute: () => Effect.succeed([]), + }) + yield* catalog.transform((catalog) => { + // The signed-out free tier enables OpenCode without any connection. + catalog.update(Provider.ID.opencode, (provider) => { + provider.activation = "enabled" + }) + // An env API key: Anthropic's integration with no account. + catalog.update(Provider.ID.anthropic, (provider) => { + provider.activation = "enabled" + }) + // A provider authored in opencode.json. + catalog.update(Provider.ID.make("my-llm"), (provider) => { + provider.activation = "enabled" + }) + // A provider the Console organization supplies. + catalog.update(Provider.ID.make("acme-gateway"), (provider) => { + provider.integrationID = Integration.ID.make("opencode") + }) + }) + yield* websearch.transform((editor) => { + editor.add(search("opencode")) + editor.add(search("exa")) + }) + yield* ConfigPolicyPlugin.Plugin.effect(host) + const available = () => + catalog.all().pipe(Effect.map((providers) => providers.map((item): string => item.id).toSorted())) + const searches = () => + websearch.providers().pipe(Effect.map((providers) => providers.map((item): string => item.id).toSorted())) + + // Signed out: everything, including the free tier, is denied. + expect(yield* available()).toEqual([]) + expect(yield* searches()).toEqual([]) + + // A personal Console account is a different account of the same integration. + const personal = yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: consoleCredential("org_personal"), + }) + expect(yield* available()).toEqual([]) + + // Only the required organization's connection works, and with it everything that organization supplies. + yield* credentials.create({ integrationID: Integration.ID.make("opencode"), value: consoleCredential(orgID) }) + yield* eventually(available().pipe(Effect.map((ids) => ids.length > 0))) + expect(yield* available()).toEqual(["acme-gateway", "opencode"]) + expect(yield* searches()).toEqual(["opencode"]) + + // Switching back to the personal account revokes it again. + yield* credentials.activate(personal.id) + yield* eventually(available().pipe(Effect.map((ids) => ids.length === 0))) + expect(yield* searches()).toEqual([]) + }).pipe(Effect.provide(Config.testLayer([lockdown()]))), + ) + + it.effect("keeps an integration's account out of a bare-ID allow", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: consoleCredential("org_other"), + }) + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.opencode, () => {}) + catalog.update(Provider.ID.anthropic, () => {}) + }) + yield* addPlugin([ + document( + integration("integration.use", "deny", "*"), + integration("integration.use", "allow", "anthropic"), + integration("integration.use", "allow", "opencode"), + ), + ]) + expect(yield* catalog.get(Provider.ID.anthropic)).toBeDefined() + // `opencode` names only account-less use; a connected account needs `opencode#*` or its own ID. + expect(yield* catalog.get(Provider.ID.opencode)).toBeUndefined() + }), + ) + + for (const effect of ["allow", "deny"] as const) + it.effect(`decides a well-known origin's providers by the origin: ${effect}`, () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const origin = Integration.ID.make("https://ai.acme.com") + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.make("acme-gateway"), (provider) => { + provider.activation = "enabled" + }) + catalog.update(Provider.ID.make("my-llm"), (provider) => { + provider.activation = "enabled" + }) + }) + yield* addPlugin([ + document( + integration("integration.use", "deny", "*"), + integration("integration.use", effect, "https://ai.acme.com"), + ), + new ConfigIntegration.WellKnownDocument({ + type: "document", + integrationID: origin, + info: decode({ + providers: { "acme-gateway": { package: "@opencode/ai/providers/openai-compatible", models: {} } }, + }), + }), + new Document({ + type: "document", + info: decode({ + providers: { "my-llm": { package: "@opencode/ai/providers/openai-compatible", models: {} } }, + }), + }), + ]) + expect(yield* catalog.get(Provider.ID.make("acme-gateway"))).toEqual( + effect === "allow" ? expect.anything() : undefined, + ) + expect(yield* catalog.get(Provider.ID.make("my-llm"))).toBeUndefined() + }), + ) + + it.effect("converts legacy statements in Console config before enforcement sees them", () => + Effect.sync(() => { + const remote = Schema.decodeUnknownSync(ConfigIntegration.Remote)({ + policies: [ + { action: "integration.use", resource: "mcp:*", effect: "deny" }, + { action: "integration.use", resource: "skill:deploy", effect: "deny" }, + { action: "future.action", resource: "*", effect: "deny" }, + ], + }) + expect(remote.policies).toEqual([ + { action: "mcp.use", resource: "*", effect: "deny" }, + { action: "skill.use", resource: "deploy", effect: "deny" }, + ]) + }), + ) +}) + +const eventually = Effect.fnUntraced(function* (condition: Effect.Effect) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for policy reload") +}) + const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { for (let attempt = 0; attempt < 200; attempt++) { if (yield* condition) return diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 56d9e608d2cc..ad4f1ae32f71 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -860,6 +860,57 @@ describe("ConfigProviderPlugin.Plugin", () => { ) } + it.effect("preserves plugin-supplied variants on custom models when config omits variants", () => + Effect.gen(function* () { + const models = yield* Model.Service + const customID = Provider.ID.make("custom") + const perModelID = Provider.ID.make("per-model") + const customTiers = ["none", "low", "medium", "xhigh"].map((id) => ({ + id: Model.VariantID.make(id), + settings: { reasoningEffort: id }, + })) + yield* models.transform((editor) => { + for (const id of ["plain", "same-package"]) + editor.update(customID, Model.ID.make(id), (model) => { + model.variants = structuredClone(customTiers) + }) + editor.update(perModelID, Model.ID.make("only-model-package"), (model) => { + model.variants = structuredClone(customTiers) + }) + }) + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { + plain: { name: "Plain" }, + "same-package": { package: "aisdk:@ai-sdk/openai-compatible" }, + }, + }, + "per-model": { + models: { + "only-model-package": { package: "aisdk:@ai-sdk/openai-compatible" }, + }, + }, + }, + }), + }), + ]) + + const ids = (providerID: Provider.ID, id: string) => + models + .get(providerID, Model.ID.make(id)) + .pipe(Effect.map((model) => required(model).variants.map((variant) => variant.id))) + const expected = customTiers.map((variant) => variant.id) + expect(yield* ids(customID, "plain")).toEqual(expected) + expect(yield* ids(customID, "same-package")).toEqual(expected) + expect(yield* ids(perModelID, "only-model-package")).toEqual(expected) + }), + ) + it.effect("respells inherited variants when config moves a catalog model to another package", () => Effect.gen(function* () { const providers = yield* Provider.Service diff --git a/packages/core/test/fixture/persistent-pty-daemon.ts b/packages/core/test/fixture/persistent-pty-daemon.ts new file mode 100644 index 000000000000..c4a5e57794dc --- /dev/null +++ b/packages/core/test/fixture/persistent-pty-daemon.ts @@ -0,0 +1,187 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import net from "node:net" +import os from "node:os" +import path from "node:path" +import { Effect } from "effect" + +export const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 7 } + +export function temporaryDirectory() { + return Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-pty-test-"))), + (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), + ) +} + +export function listen( + socketPath: string, + handle: (socket: net.Socket, request: Record, token: string) => object | undefined, +) { + return Effect.acquireRelease( + Effect.promise(async () => { + const sockets = new Set() + const server = net.createServer((socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + void readRequest(socket) + .then((envelope) => { + const response = + envelope.request.op === "own" ? { type: "owned" } : handle(socket, envelope.request, envelope.token) + if (response) socket.write(frame(response)) + }) + .catch(() => socket.destroy()) + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(socketPath, resolve) + }) + return { + close: () => { + sockets.forEach((socket) => socket.destroy()) + return close(server) + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +function readRequest(socket: net.Socket) { + return new Promise((resolve, reject) => { + let pending = Buffer.alloc(0) + const cleanup = () => { + socket.off("data", onData) + socket.off("end", onEnd) + socket.off("error", onError) + } + const onData = (value: Buffer) => { + pending = Buffer.concat([pending, value]) + if (pending.length < 4) return + const length = pending.readUInt32BE(0) + if (pending.length < length + 4) return + cleanup() + resolve(pending.subarray(4, length + 4)) + } + const onEnd = () => { + cleanup() + reject(new Error("connection closed before request")) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + socket.on("data", onData) + socket.on("end", onEnd) + socket.on("error", onError) + }).then((payload) => { + const envelope: unknown = JSON.parse(payload.toString("utf8")) + if (!isRecord(envelope) || typeof envelope.token !== "string" || !isRecord(envelope.request)) + throw new Error("Invalid test daemon envelope") + return { token: envelope.token, request: envelope.request } + }) +} + +function frame(value: object) { + const payload = Buffer.from(JSON.stringify(value)) + const output = Buffer.allocUnsafe(payload.length + 4) + output.writeUInt32BE(payload.length) + payload.copy(output, 4) + return output +} + +export function writeRegistration( + directory: string, + socket: string, + instance = "test", + token = "test", + features?: string[], +) { + return writeFile( + path.join(directory, "service.json"), + JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token, features }), + ) +} + +// An in-process opencode-pty that a server adopts through a handoff, so no daemon is spawned. Without +// features it answers like a daemon that predates the features list. +export const fakeDaemon = Effect.fn(function* (features?: string[]) { + const base = yield* temporaryDirectory() + const root = path.join(base, "root") + const directory = path.join(root, "inherited") + const socket = path.join(base, "daemon.sock") + const terminals: Array & { user_title?: string | null }> = [] + const failTerminate = new Set() + const ops: string[] = [] + let nextID = 1 + yield* listen(socket, (connection, request) => { + ops.push(String(request.op)) + if (request.op === "ping") return features ? { ...pong, features } : pong + if (request.op === "list") return { type: "terminals", terminals } + if (request.op === "create") { + const base = terminal(nextID++, String(request.group_id)) + // A daemon that advertises features always serializes user_title. + const created = features ? { ...base, user_title: null } : base + terminals.push(created) + return { type: "created", terminal: created } + } + if (request.op === "control") return { type: "ok" } + if (request.op === "terminate") { + if (failTerminate.has(`pty_persistent_${request.id}`)) return { type: "error", message: "terminate failed" } + const index = terminals.findIndex((item) => item.id === request.id) + if (index === -1) return { type: "error", message: `terminal not found: ${String(request.id)}` } + terminals.splice(index, 1) + return { type: "ok" } + } + if (request.op === "rename" && features?.includes("rename")) { + const found = terminals.find((item) => item.id === request.id) + if (!found) return { type: "error", message: "terminal not found" } + found.user_title = typeof request.title === "string" && request.title !== "" ? request.title : null + found.title = found.user_title ?? "test" + return { type: "ok" } + } + // Old daemons cannot parse unknown requests and drop the connection. + if (request.op === "rename") { + connection.destroy() + return undefined + } + return { type: "error", message: `unexpected request: ${String(request.op)}` } + }) + yield* Effect.promise(async () => { + await mkdir(directory, { recursive: true }) + await writeRegistration(directory, socket, "test", "test", features) + }) + return { + failTerminate, + ops, + options: { + root, + handoff: { directory, instanceID: "test", ticket: "ticket", expiresAt: Date.now() + 60_000 }, + }, + } +}) + +export function terminal(id: number, groupID = "ses_test") { + return { + id, + pid: null, + title: "test", + foreground_process: null, + group_id: groupID, + command: ["test"], + cwd: "/tmp", + cols: 80, + rows: 24, + lifecycle: { status: "running" }, + output_head: 0, + output_tail: 0, + } +} + +function close(server: net.Server) { + if (!server.listening) return Promise.resolve() + return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/core/test/fixture/policy.ts b/packages/core/test/fixture/policy.ts index e4737b3d3f3f..f57bfb9a1e8f 100644 --- a/packages/core/test/fixture/policy.ts +++ b/packages/core/test/fixture/policy.ts @@ -7,6 +7,8 @@ import { Skill } from "@opencode/core/skill" import { Info } from "@opencode/schema/config" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ID } from "@opencode/schema/event" +import { Credential } from "@opencode/core/credential" +import { WebSearch } from "@opencode/core/websearch" import { Effect, Schema, Stream } from "effect" import { host } from "../plugin/host" @@ -66,5 +68,30 @@ export const registerIntegrationPolicy = Effect.fn(function* (input: { assert: () => Effect.die("unused permission.assert"), }, }), + ).pipe( + Effect.provideService( + Credential.Service, + Credential.Service.of({ + all: () => Effect.succeed([]), + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + lock: () => Effect.die("unused Credential.lock"), + create: () => Effect.die("unused Credential.create"), + activate: () => Effect.die("unused Credential.activate"), + update: () => Effect.die("unused Credential.update"), + remove: () => Effect.die("unused Credential.remove"), + }), + ), + Effect.provideService( + WebSearch.Service, + WebSearch.Service.of({ + providers: () => Effect.die("unused WebSearch.providers"), + default: () => Effect.die("unused WebSearch.default"), + select: () => Effect.die("unused WebSearch.select"), + query: () => Effect.die("unused WebSearch.query"), + transform: () => Effect.succeed({ dispose: Effect.void }), + reload: () => Effect.void, + }), + ), ) }) diff --git a/packages/core/test/fixture/subagent-recovery-process.ts b/packages/core/test/fixture/subagent-recovery-process.ts index 7c97959c74bc..8cec34fa02dc 100644 --- a/packages/core/test/fixture/subagent-recovery-process.ts +++ b/packages/core/test/fixture/subagent-recovery-process.ts @@ -175,7 +175,6 @@ const layer = AppNodeBuilder.build( SessionExecution.node, SessionRestart.node, LocationServiceMap.node, - Tool.node, ]), [ Database.node.replace(Database.layer({ path: "recovery.db" }).pipe(Layer.provide(globalLayer))), diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index 13abc3867827..0d12d39cbe70 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -274,7 +274,10 @@ describe("InstructionState", () => { const assembled = yield* preview(db, sessionID, instructions) - expect(assembled).toEqual({ initial: "Changed context", update: "" }) + expect(assembled).toEqual({ + initial: [{ key: Instructions.Key.make("test/context"), text: "Changed context" }], + update: "", + }) expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) expect( @@ -314,7 +317,7 @@ describe("InstructionState", () => { const assembled = yield* preview(db, sessionID, instructions) - expect(assembled.initial).toBe("Initial context") + expect(assembled.initial).toEqual([{ key: Instructions.Key.make("test/context"), text: "Initial context" }]) expect(assembled.update).toBe("Private update") expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) @@ -344,8 +347,10 @@ describe("InstructionState", () => { const assembled = yield* preview(db, sessionID, instructions) - expect(assembled.initial).toContain("Initial context") - expect(assembled.initial).toContain("Retired context") + expect(assembled.initial).toEqual([ + { key: Instructions.Key.make("test/current"), text: "Initial context" }, + { key: Instructions.Key.make("test/retired"), text: "Retired context" }, + ]) expect(assembled.update).toContain("Changed context") expect(assembled.update).toContain("Removed Retired context") }), @@ -393,7 +398,7 @@ describe("InstructionState", () => { const instructions = source("test/context", Effect.succeed("Initial context")) expect(yield* preview(db, sessionID, instructions)).toEqual({ - initial: "Initial context", + initial: [{ key: Instructions.Key.make("test/context"), text: "Initial context" }], update: "", }) expect(yield* instructionEvents(db, sessionID)).toEqual([]) @@ -418,7 +423,7 @@ describe("InstructionState", () => { const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) expect(yield* preview(db, sessionID, instructions)).toEqual({ - initial: "Committed context", + initial: [{ key: Instructions.Key.make("test/context"), text: "Committed context" }], update: "", }) expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) diff --git a/packages/core/test/instructions/index.test.ts b/packages/core/test/instructions/index.test.ts index 4b02a925082d..cc144b192a46 100644 --- a/packages/core/test/instructions/index.test.ts +++ b/packages/core/test/instructions/index.test.ts @@ -47,7 +47,9 @@ describe("Instructions", () => { delta: { "core/date": hash }, blobs: { [hash]: "2026-07-09" }, }) - expect(Instructions.renderInitial(instructions, { "core/date": "2026-07-09" })).toBe("Today's date: 2026-07-09") + expect(Instructions.join(Instructions.renderInitial(instructions, { "core/date": "2026-07-09" }))).toBe( + "Today's date: 2026-07-09", + ) }), ) @@ -189,9 +191,9 @@ describe("Instructions", () => { source({ key: "core/location", value: "location" }), ]) - expect(Instructions.renderInitial(instructions, { "core/date": "date", "core/location": "location" })).toBe( - "date\n\nlocation", - ) + expect( + Instructions.join(Instructions.renderInitial(instructions, { "core/date": "date", "core/location": "location" })), + ).toBe("date\n\nlocation") }) test("rejects duplicate source keys", () => { diff --git a/packages/core/test/job.test.ts b/packages/core/test/job.test.ts index 400a2adb4aaa..044159bf471a 100644 --- a/packages/core/test/job.test.ts +++ b/packages/core/test/job.test.ts @@ -1,14 +1,20 @@ import { describe, expect } from "bun:test" +import { Database } from "@opencode/core/database/database" +import jobMigration from "@opencode/core/database/migration/20261030054400_job" import { Job } from "@opencode/core/job" -import { KV } from "@opencode/core/kv" +import { JobTable } from "@opencode/core/job/sql" +import { KVTable } from "@opencode/core/kv/sql" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Integration } from "@opencode/core/integration" import { LayerNode } from "@opencode/util/effect/layer-node" +import { Global } from "@opencode/util/global" +import { eq, like } from "drizzle-orm" import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" +import { SessionMessage } from "@opencode/core/session/message" import { SessionSchema } from "@opencode/core/session/schema" import { testEffect } from "./lib/effect" -const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, KV.node]))) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, Database.node, Global.node]))) describe("Job", () => { it.live("tracks process-local work through explicit observation", () => @@ -450,4 +456,82 @@ describe("Job", () => { expect((yield* jobs.get(job.id))?.status).toBe("running") }), ) + + it.live("cancels running session jobs and removes unacknowledged SQLite job rows on cancelSession", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const db = (yield* Database.Service).db + const sessionID = SessionSchema.ID.make("ses_cleanup_owner") + const otherSessionID = SessionSchema.ID.make("ses_cleanup_other") + const interrupted = yield* Deferred.make() + + const owned = yield* jobs.start({ + id: "job_cleanup_owned", + type: "shell", + recovery: { kind: "shell", sessionID, shellID: "job_cleanup_owned", command: "bun run dev" }, + run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), + }) + const other = yield* jobs.start({ + id: "job_cleanup_other", + type: "shell", + recovery: { kind: "shell", sessionID: otherSessionID, shellID: "job_cleanup_other", command: "bun test" }, + run: Effect.succeed("kept"), + }) + yield* jobs.background(owned.id) + yield* jobs.background(other.id) + yield* jobs.wait({ id: other.id }) + + const cancelled = yield* jobs.cancelSession(sessionID) + expect(cancelled).toMatchObject([{ id: owned.id, status: "cancelled" }]) + yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) + + expect(yield* db.select().from(JobTable).where(eq(JobTable.session_id, sessionID)).all()).toEqual([]) + const remaining = yield* jobs.pendingBackground + expect(remaining).toMatchObject([{ id: other.id, status: "completed", output: "kept" }]) + if (remaining[0]) yield* jobs.completeBackground(remaining[0].notificationID) + }), + ) + + it.live("migrates legacy kv job.background/* markers into the job table", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const jobs = yield* Job.Service + const notificationID = SessionMessage.ID.create() + const sessionID = SessionSchema.ID.make("ses_migrated") + + yield* db.run("DROP TABLE `job`") + yield* db + .insert(KVTable) + .values({ + key: `job.background/${notificationID}`, + value: { + id: "shell_legacy", + notificationID, + recovery: { + kind: "shell", + sessionID, + shellID: "shell_legacy", + command: "bun test", + }, + status: "completed", + output: "all green", + }, + }) + .run() + + yield* db.transaction((tx) => jobMigration.up(tx)) + + expect(yield* db.select().from(KVTable).where(like(KVTable.key, "job.background/%")).all()).toEqual([]) + expect(yield* jobs.pendingBackground).toMatchObject([ + { + id: "shell_legacy", + notificationID, + status: "completed", + output: "all green", + recovery: { kind: "shell", sessionID, shellID: "shell_legacy", command: "bun test" }, + }, + ]) + yield* jobs.completeBackground(notificationID) + }), + ) }) diff --git a/packages/core/test/lib/instructions.ts b/packages/core/test/lib/instructions.ts index 060bb7683f36..00ef3a0ad2c1 100644 --- a/packages/core/test/lib/instructions.ts +++ b/packages/core/test/lib/instructions.ts @@ -20,7 +20,7 @@ export const readInitial = (instructions: Instructions.List) => ), ), ) - return { ...current, text: Instructions.renderInitial(instructions, current.values) } + return { ...current, text: Instructions.join(Instructions.renderInitial(instructions, current.values)) } }) export const readUpdate = (instructions: Instructions.List, previous: State) => diff --git a/packages/core/test/lib/permission.ts b/packages/core/test/lib/permission.ts index d44f32d0e1a1..447c778da8be 100644 --- a/packages/core/test/lib/permission.ts +++ b/packages/core/test/lib/permission.ts @@ -3,3 +3,10 @@ import { Effect, Layer } from "effect" export const permissionLayer = (overrides: Partial = {}) => Layer.mock(Permission.Service, { close: Effect.void, ...overrides }) + +export const permissionService = (overrides: Partial = {}) => + Effect.runSync( + Effect.gen(function* () { + return yield* Permission.Service + }).pipe(Effect.provide(permissionLayer(overrides))), + ) diff --git a/packages/core/test/location-activity.test.ts b/packages/core/test/location-activity.test.ts index c1addbda0e4b..3099d996a4d9 100644 --- a/packages/core/test/location-activity.test.ts +++ b/packages/core/test/location-activity.test.ts @@ -39,6 +39,8 @@ import { eq } from "drizzle-orm" import { Document, Info } from "@opencode/schema/config" import { ConfigExperimental } from "@opencode/schema/config/experimental" import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" +import { Permission } from "@opencode/core/permission" // Per-workspace experimental.location_idle_timeout, read by each location's Config. const idleTimeouts = new Map() @@ -341,7 +343,11 @@ type ToolGate = { } const toolServices = LayerNode.compile(LayerNode.group([Tool.node, PluginHooks.node]), { - replacements: [Image.node.replace(Layer.mock(Image.Service, { normalize: () => Effect.die("unused") }))], + replacements: [ + Image.node.replace(Layer.mock(Image.Service, { normalize: () => Effect.die("unused") })), + // Tool.node gained Permission.node in v2.2.3; stub it so this fixture does not need Location. + Permission.node.replace(permissionLayer()), + ], }) const gates = new Map() diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 128606c1f170..89480a7c6e75 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1619,7 +1619,6 @@ it.live("discovers and reads MCP resources through Code Mode", () => 'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://gone" })', ) expect(gone.metadata?.error).toBe(true) - expect(gone.output.output).toContain("Unable to read MCP resource resources:docs://gone") expect(gone.output.output).toContain("Resource not found") const missing = yield* run( 'return await tools.opencode.read_mcp_resource({ server: "missing", uri: "docs://readme" })', @@ -2684,7 +2683,14 @@ it.effect("does not call MCP when permission is blocked", () => Effect.gen(function* () { calls = 0 assertion = yield* Deferred.make() - decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] })) + decision = Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: "demo_search", + resources: ["*"], + reason: "Search is disabled in this workspace", + }), + ) const registry = yield* Tool.Service const registration = yield* McpTool.Service yield* registration.flush @@ -2701,11 +2707,56 @@ it.effect("does not call MCP when permission is blocked", () => input: { code: "return await tools.demo.search({})" }, }, }) - expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }]) + expect(execution.content).toEqual([{ type: "text", text: "Search is disabled in this workspace" }]) expect(execution.metadata).toEqual({ toolCalls: [{ tool: "demo.search", status: "error" }], error: true, }) + expect( + yield* executeTool(registry, { + sessionID: Session.ID.make("ses_mcp_blocked"), + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_blocked_direct", name: "direct_lookup", input: {} }, + }), + ).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Search is disabled in this workspace" }, + }) + expect(calls).toBe(0) + }), +) + +it.effect("returns permission feedback instead of calling MCP", () => + Effect.gen(function* () { + calls = 0 + assertion = yield* Deferred.make() + decision = Effect.fail(new Permission.CorrectedError({ feedback: "Search the docs folder instead" })) + const registry = yield* Tool.Service + const registration = yield* McpTool.Service + yield* registration.flush + const toolSet = yield* registry.snapshot() + + const execution = yield* toolSet.execute({ + sessionID: Session.ID.make("ses_mcp_corrected"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_corrected", + name: "execute", + input: { code: "return await tools.demo.search({})" }, + }, + }) + expect(execution.content).toEqual([{ type: "text", text: "Search the docs folder instead" }]) + expect( + yield* executeTool(registry, { + sessionID: Session.ID.make("ses_mcp_corrected"), + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_corrected_direct", name: "direct_lookup", input: {} }, + }), + ).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Search the docs folder instead" }, + }) expect(calls).toBe(0) }), ) @@ -2738,7 +2789,7 @@ for (const modern of [false, true]) { }).pipe( Effect.provide( resourceMcpLayer(server.url, undefined, undefined, { - policies: [{ action: "integration.use", resource: "mcp:*", effect: "deny" }], + policies: [{ action: "mcp.use", resource: "*", effect: "deny" }], }), ), ) @@ -2762,7 +2813,7 @@ for (const source of ["config", "organization"] as const) { mcp: new ConfigMCP.Info({ servers: { resources: new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }) }, }), - policies: [{ action: "integration.use", resource: "mcp:*", effect }], + policies: [{ action: "mcp.use", resource: "*", effect }], }), }) yield* test.setEntries([document("allow")]) @@ -2772,7 +2823,7 @@ for (const source of ["config", "organization"] as const) { expect((yield* service.tools()).length).toBeGreaterThan(0) if (source === "config") yield* test.setEntries([document("deny")]) if (source === "organization") - yield* setOrganizationPolicy([{ action: "integration.use", resource: "mcp:*", effect: "deny" }]) + yield* setOrganizationPolicy([{ action: "mcp.use", resource: "*", effect: "deny" }]) yield* PubSub.publish(updates, { type: Event.Updated.type }) yield* service.servers().pipe( Effect.filterOrFail( diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 10643bb8a538..76b5a649d6dc 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { LLM, LanguageModel, Message } from "@opencode/ai/effect" +import { ai, LanguageModel, Message } from "@opencode/ai/effect" import { OpenAIChat } from "@opencode/ai/protocols" import { compileRequest } from "@opencode/ai/testing" import { ConfigProvider, Effect, Layer } from "effect" @@ -166,7 +166,7 @@ describe("ModelResolver", () => { expect(String(selected.provider)).toBe(providerID) expect(selected.route.id).toBe(route) expect(selected.route.endpoint.baseURL).toBe("https://provider.example/v1/openai") - const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: selected, prompt: "Hello" })) expect(prepared.body.messages).toEqual([{ role: "user", content: "Hello" }]) expect(prepared.body).not.toHaveProperty("apiKey") } @@ -300,7 +300,7 @@ describe("ModelResolver", () => { http: { body: { custom_extension: { enabled: true } } }, }, }) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(prepared.body.max_output_tokens).toBeUndefined() expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens") }), @@ -313,7 +313,7 @@ describe("ModelResolver", () => { settings: { apiKey: "secret", baseURL: "https://openai.example/v1" }, }), ) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(JSON.stringify(prepared.body)).not.toContain("apiKey") expect(JSON.stringify(prepared.body)).not.toContain("secret") @@ -329,7 +329,7 @@ describe("ModelResolver", () => { }), ) const headers = yield* resolved.route.auth.apply({ - request: LLM.request({ model: resolved, prompt: "Hello" }), + request: ai.request({ model: resolved, prompt: "Hello" }), method: "POST", url: "https://openai.example/v1/responses", body: "{}", @@ -428,7 +428,7 @@ describe("ModelResolver", () => { Effect.gen(function* () { const resolved = yield* resolver.resolveModel(selection) const headers = yield* resolved.model.route.auth.apply({ - request: LLM.request({ model: resolved.model, prompt: "Hello" }), + request: ai.request({ model: resolved.model, prompt: "Hello" }), method: "POST", url: resolved.model.route.endpoint.baseURL ?? "", body: "{}", @@ -457,7 +457,7 @@ describe("ModelResolver", () => { ) const exit = yield* Effect.exit( resolved.route.auth.apply({ - request: LLM.request({ model: resolved, prompt: "Hello" }), + request: ai.request({ model: resolved, prompt: "Hello" }), method: "POST", url: "https://google.example.com/v1", body: "{}", @@ -491,7 +491,7 @@ describe("ModelResolver", () => { body: {}, }), ) - const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } }) + const request = ai.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } }) const prepared = yield* compileRequest(request) const headers = yield* resolved.route.auth.apply({ request, @@ -593,7 +593,7 @@ describe("ModelResolver", () => { reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(prepared.body).toMatchObject({ include: ["reasoning.encrypted_content"], reasoning: { effort: "xhigh", summary: "auto" }, @@ -613,7 +613,7 @@ describe("ModelResolver", () => { VariantID.make("high"), Credential.Key.make({ type: "key", key: "secret" }), ) - const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(ai.request({ model: resolved, prompt: "Hello" })) expect(resolved.route.id).toBe("xai-responses") expect(prepared.body).toMatchObject({ @@ -714,7 +714,7 @@ describe("ModelResolver", () => { }), Credential.Key.make({ type: "key", key: "secret" }), ) - const request = LLM.request({ model: resolved, prompt: "Hello" }) + const request = ai.request({ model: resolved, prompt: "Hello" }) const headers = yield* resolved.route.auth.apply({ request, method: "POST", @@ -739,7 +739,7 @@ describe("ModelResolver", () => { credential, ) const headers = yield* resolved.route.auth.apply({ - request: LLM.request({ model: resolved, prompt: "Hello" }), + request: ai.request({ model: resolved, prompt: "Hello" }), method: "POST", url: "https://openai.example/v1/responses", body: "{}", @@ -779,7 +779,7 @@ describe("ModelResolver", () => { ) const authorize = (resolved: LanguageModel) => resolved.route.auth.apply({ - request: LLM.request({ model: resolved, prompt: "Hello" }), + request: ai.request({ model: resolved, prompt: "Hello" }), method: "POST", url: "https://cli-resource.openai.azure.com/openai/v1/responses", body: "{}", @@ -862,7 +862,7 @@ describe("ModelResolver", () => { metadata: { accountID: "acct_123" }, }), ) - const request = LLM.request({ + const request = ai.request({ model: resolved, system: [ { type: "text", text: "Base instructions." }, @@ -948,7 +948,7 @@ describe("ModelResolver", () => { metadata: { accountID: "acct_123" }, }), ) - const request = LLM.request({ model: resolved, prompt: "Hello" }) + const request = ai.request({ model: resolved, prompt: "Hello" }) const headers = yield* resolved.route.auth.apply({ request, method: "POST", diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 7d3b57a463e6..8d5e6527bc1b 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -723,9 +723,9 @@ describe("shell scanner permission impact", () => { command: "printf ok && git status > output", approved: ["printf *", "git status *"], exact: ["printf ok", "git status"], - denied: "git status", + denied: "git status *", savedEffect: ["allow", "allow"], - exactEffect: ["allow", "allow"], + exactEffect: ["allow", "ask"], deniedEffect: ["deny", "deny"], }, { @@ -734,9 +734,9 @@ describe("shell scanner permission impact", () => { command: "printf ok | cat < input > output", approved: ["printf *", "cat *"], exact: ["printf ok", "cat"], - denied: "cat", + denied: "cat *", savedEffect: ["allow", "allow"], - exactEffect: ["allow", "allow"], + exactEffect: ["allow", "ask"], deniedEffect: ["deny", "deny"], }, { @@ -745,10 +745,10 @@ describe("shell scanner permission impact", () => { command: "FOO=bar > output; printf done", approved: ["printf *"], exact: ["printf done"], - denied: "FOO=bar > output; printf done", - savedEffect: ["ask", "allow"], - exactEffect: ["ask", "allow"], - deniedEffect: ["deny", "allow"], + denied: "FOO=bar > output", + savedEffect: ["ask", "ask"], + exactEffect: ["ask", "ask"], + deniedEffect: ["deny", "deny"], }, { name: "assignment redirect with an approved command substitution", @@ -757,9 +757,9 @@ describe("shell scanner permission impact", () => { approved: ["git describe *"], exact: ["git describe --tags"], denied: "VERSION=$(git describe --tags) > build/version.txt", - savedEffect: ["ask", "allow"], - exactEffect: ["ask", "allow"], - deniedEffect: ["deny", "allow"], + savedEffect: ["ask", "ask"], + exactEffect: ["ask", "ask"], + deniedEffect: ["deny", "deny"], }, { name: "substitution in a saved prefix", @@ -805,6 +805,39 @@ describe("shell scanner permission impact", () => { exactEffect: ["allow", "ask"], deniedEffect: ["deny", "allow"], }, + { + name: "trailing redirect after list operator", + shell: "bash", + command: "true && cat > secret", + approved: ["true *", "cat *"], + exact: ["true", "cat"], + denied: "* > secret*", + savedEffect: ["allow", "allow"], + exactEffect: ["allow", "ask"], + deniedEffect: ["allow", "deny"], + }, + { + name: "commandless redirect write", + shell: "zsh", + command: ">> ~/.zshrc <<< 'x'", + approved: [">> *"], + exact: [">> ~/.zshrc <<< 'x'"], + denied: ">> *", + savedEffect: ["allow", "allow"], + exactEffect: ["allow", "allow"], + deniedEffect: ["deny", "deny"], + }, + { + name: "PowerShell .NET method invocation", + shell: "pwsh", + command: '[IO.File]::Delete("C:\\victim")', + approved: ["[IO.File]::Delete(*"], + exact: ['[IO.File]::Delete("C:\\victim")'], + denied: "*Delete(*", + savedEffect: ["allow", "allow"], + exactEffect: ["allow", "allow"], + deniedEffect: ["deny", "deny"], + }, ] as const) { for (const scenario of [ { name: "no approval", saved: [], rules: [], expected: ["ask", "ask"] }, @@ -836,21 +869,20 @@ describe("shell scanner permission impact", () => { yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: scenario.saved }) const service = yield* Permission.Service - for (const [index, portable] of [false, true].entries()) { - const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable }) - expect(parsed.commands.length).toBeGreaterThan(0) - expect(parsed.directories).toEqual([]) - const result = yield* service.ask( - assertion({ - action: "shell", - resources: parsed.commands.map((command) => command.resource), - save: parsed.commands.map((command) => command.save), - }), - ) - expect(result.effect, portable ? "native" : "legacy").toBe(scenario.expected[index]) - const pending = yield* service.list() - expect(pending).toHaveLength(result.effect === "ask" ? 1 : 0) - if (result.effect !== "ask") continue + const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project") + expect(parsed.commands.length).toBeGreaterThan(0) + expect(parsed.directories).toEqual([]) + const result = yield* service.ask( + assertion({ + action: "shell", + resources: parsed.commands.map((command) => command.resource), + save: parsed.commands.map((command) => command.save), + }), + ) + expect(result.effect).toBe(scenario.expected[1]) + const pending = yield* service.list() + expect(pending).toHaveLength(result.effect === "ask" ? 1 : 0) + if (result.effect === "ask") { expect(pending[0]?.resources).toEqual(parsed.commands.map((command) => command.resource)) expect(pending[0]?.save).toEqual(parsed.commands.map((command) => command.save)) yield* service.reply({ requestID: result.id, reply: "once" }) @@ -921,14 +953,31 @@ describe("shell scanner permission impact", () => { name: "assignment redirect prefix", shell: "bash", command: "FOO=bar > output; printf done", - grants: [["printf *"], ["printf *"]], - // Identical saved rules cover only the native resource, regardless of which parser saved them. + grants: [ + ["printf *"], + ["FOO=bar > *", "printf *"], + ], repeat: [ ["ask", "allow"], ["ask", "allow"], ], - next: "printf next", - nextEffect: ["allow", "allow"], + next: "FOO=bar rm -rf /", + nextEffect: ["ask", "ask"], + }, + { + name: "PowerShell method prefix", + shell: "pwsh", + command: '[IO.File]::Delete("C:\\first")', + grants: [ + ["[IO.File]::Delete(*"], + ["[IO.File]::Delete(*"], + ], + repeat: [ + ["allow", "allow"], + ["allow", "allow"], + ], + next: '[IO.File]::WriteAllText("C:\\first", "x")', + nextEffect: ["ask", "ask"], }, { name: "PowerShell tab prefix", @@ -943,47 +992,41 @@ describe("shell scanner permission impact", () => { nextEffect: ["ask", "ask"], }, ] as const) { - for (const [origin, portable] of [false, true].entries()) { - it.live(`${fixture.name}: always allow from ${portable ? "native" : "legacy"}, then use either parser`, () => - Effect.gen(function* () { - yield* setup() - const service = yield* Permission.Service - const saved = yield* PermissionSaved.Service - const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable }) - const first = yield* service.ask( + it.live(`${fixture.name}: always allow covers repeat execution and narrows prefix scope`, () => + Effect.gen(function* () { + yield* setup() + const service = yield* Permission.Service + const saved = yield* PermissionSaved.Service + const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project") + const first = yield* service.ask( + assertion({ + action: "shell", + resources: parsed.commands.map((command) => command.resource), + save: parsed.commands.map((command) => command.save), + }), + ) + expect(first.effect).toBe("ask") + expect(yield* service.list()).toHaveLength(1) + yield* service.reply({ requestID: first.id, reply: "always" }) + expect(yield* service.list()).toEqual([]) + expect((yield* saved.list({ projectID: Project.ID.global })).map((rule) => rule.resource).sort()).toEqual( + [...fixture.grants[1]].sort(), + ) + + for (const command of [fixture.command, fixture.next]) { + const parsed = yield* ShellParse.scan(command, fixture.shell, "/project") + const result = yield* service.ask( assertion({ action: "shell", resources: parsed.commands.map((command) => command.resource), save: parsed.commands.map((command) => command.save), }), ) - expect(first.effect).toBe("ask") - expect(yield* service.list()).toHaveLength(1) - yield* service.reply({ requestID: first.id, reply: "always" }) + expect(result.effect, command).toBe(command === fixture.next ? fixture.nextEffect[1] : fixture.repeat[1]?.[1]) + if (result.effect === "ask") yield* service.reply({ requestID: result.id, reply: "once" }) expect(yield* service.list()).toEqual([]) - expect((yield* saved.list({ projectID: Project.ID.global })).map((rule) => rule.resource).sort()).toEqual( - [...fixture.grants[portable ? 1 : 0]].sort(), - ) - - for (const [index, target] of [false, true].entries()) { - for (const command of [fixture.command, fixture.next]) { - const parsed = yield* ShellParse.scan(command, fixture.shell, "/project", { portable: target }) - const result = yield* service.ask( - assertion({ - action: "shell", - resources: parsed.commands.map((command) => command.resource), - save: parsed.commands.map((command) => command.save), - }), - ) - expect(result.effect, `${target ? "native" : "legacy"}: ${command}`).toBe( - command === fixture.next ? fixture.nextEffect[origin] : fixture.repeat[origin]?.[index], - ) - if (result.effect === "ask") yield* service.reply({ requestID: result.id, reply: "once" }) - expect(yield* service.list()).toEqual([]) - } - } - }), - ) - } + } + }), + ) } }) diff --git a/packages/core/test/persistent-pty-daemon.test.ts b/packages/core/test/persistent-pty-daemon.test.ts index 8c0ffc9c7bff..70eb640737c6 100644 --- a/packages/core/test/persistent-pty-daemon.test.ts +++ b/packages/core/test/persistent-pty-daemon.test.ts @@ -1,16 +1,13 @@ import { expect } from "bun:test" import { spawn } from "node:child_process" import { existsSync } from "node:fs" -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises" -import net from "node:net" -import os from "node:os" +import { mkdir, stat, writeFile } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" import { makeDaemonTransport } from "../src/persistent-pty/daemon" +import { listen, pong, temporaryDirectory, terminal, writeRegistration } from "./fixture/persistent-pty-daemon" import { it } from "./lib/effect" -const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 7 } - it.live("rediscovers a same-protocol daemon after its registration rotates", () => Effect.gen(function* () { const { root, directory } = yield* temporaryRuntime() @@ -88,6 +85,33 @@ it.live("rediscovers a rotated registration when acquiring a subscription", () = }), ) +it.live("refreshes the daemon's features when its registration rotates", () => + Effect.gen(function* () { + const { root, directory } = yield* temporaryRuntime() + const socketPath = path.join(directory, "daemon.sock") + let token = "old-token" + let instance = "old-instance" + let features: string[] | undefined + yield* listen(socketPath, (_socket, request, receivedToken) => { + if (receivedToken !== token) return { type: "error", message: "authentication failed" } + if (request.op === "ping") return { ...pong, instance_id: instance, ...(features ? { features } : {}) } + return { type: "terminals", terminals: [] } + }) + yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token)) + const daemon = yield* makeDaemonTransport(root, "daemon") + expect(yield* daemon.supports("rename")).toBeFalse() + + token = "new-token" + instance = "new-instance" + features = ["rename"] + yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token, features)) + // The cached registration is replaced once a request is rejected, as rename's lookup does first. + yield* daemon.request({ op: "list" }) + + expect(yield* daemon.supports("rename")).toBeTrue() + }), +) + it.live("retries a start-required request when connection fails before dispatch", () => Effect.gen(function* () { const { root, directory } = yield* temporaryRuntime() @@ -248,119 +272,3 @@ function temporaryRuntime() { return { root, directory } }) } - -function temporaryDirectory() { - return Effect.acquireRelease( - Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-pty-test-"))), - (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), - ) -} - -function listen( - socketPath: string, - handle: (socket: net.Socket, request: Record, token: string) => object | undefined, -) { - return Effect.acquireRelease( - Effect.promise(async () => { - const sockets = new Set() - const server = net.createServer((socket) => { - sockets.add(socket) - socket.once("close", () => sockets.delete(socket)) - void readRequest(socket) - .then((envelope) => { - const response = - envelope.request.op === "own" ? { type: "owned" } : handle(socket, envelope.request, envelope.token) - if (response) socket.write(frame(response)) - }) - .catch(() => socket.destroy()) - }) - await new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(socketPath, resolve) - }) - return { - close: () => { - sockets.forEach((socket) => socket.destroy()) - return close(server) - }, - } - }), - (server) => Effect.promise(server.close), - ) -} - -function readRequest(socket: net.Socket) { - return new Promise((resolve, reject) => { - let pending = Buffer.alloc(0) - const cleanup = () => { - socket.off("data", onData) - socket.off("end", onEnd) - socket.off("error", onError) - } - const onData = (value: Buffer) => { - pending = Buffer.concat([pending, value]) - if (pending.length < 4) return - const length = pending.readUInt32BE(0) - if (pending.length < length + 4) return - cleanup() - resolve(pending.subarray(4, length + 4)) - } - const onEnd = () => { - cleanup() - reject(new Error("connection closed before request")) - } - const onError = (error: Error) => { - cleanup() - reject(error) - } - socket.on("data", onData) - socket.on("end", onEnd) - socket.on("error", onError) - }).then((payload) => { - const envelope: unknown = JSON.parse(payload.toString("utf8")) - if (!isRecord(envelope) || typeof envelope.token !== "string" || !isRecord(envelope.request)) - throw new Error("Invalid test daemon envelope") - return { token: envelope.token, request: envelope.request } - }) -} - -function frame(value: object) { - const payload = Buffer.from(JSON.stringify(value)) - const output = Buffer.allocUnsafe(payload.length + 4) - output.writeUInt32BE(payload.length) - payload.copy(output, 4) - return output -} - -function writeRegistration(directory: string, socket: string, instance = "test", token = "test") { - return writeFile( - path.join(directory, "service.json"), - JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token }), - ) -} - -function terminal(id: number) { - return { - id, - pid: null, - title: "test", - foreground_process: null, - group_id: "ses_test", - command: ["test"], - cwd: "/tmp", - cols: 80, - rows: 24, - lifecycle: { status: "running" }, - output_head: 0, - output_tail: 0, - } -} - -function close(server: net.Server) { - if (!server.listening) return Promise.resolve() - return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} diff --git a/packages/core/test/persistent-pty-session.test.ts b/packages/core/test/persistent-pty-session.test.ts new file mode 100644 index 000000000000..e154ebe85b5d --- /dev/null +++ b/packages/core/test/persistent-pty-session.test.ts @@ -0,0 +1,165 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Option, Stream } from "effect" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Database } from "@opencode/core/database/database" +import { Bus } from "@opencode/core/bus" +import { Instance } from "@opencode/core/instance/service" +import { Location } from "@opencode/core/location" +import { PersistentPty } from "@opencode/core/persistent-pty" +import { Project } from "@opencode/core/project" +import { AbsolutePath } from "@opencode/core/schema" +import { Session } from "@opencode/core/session" +import { SessionExecution } from "@opencode/core/session/execution" +import { SessionModelTransport } from "@opencode/core/session/model-transport" +import { SessionProjector } from "@opencode/core/session/projector" +import { SessionStore } from "@opencode/core/session/store" +import { SessionEnvironment } from "@opencode/core/session/environment" +import { LocationServiceMap } from "@opencode/core/location-services" +import { Removed, Updated } from "@opencode/schema/persistent-pty" +import { fakeDaemon } from "./fixture/persistent-pty-daemon" +import { it } from "./lib/effect" +import { globalProjectNode } from "./lib/project" +import { offlineModels } from "./fixture/models" +import { tmpdirScoped } from "./fixture/tmpdir" + +const transport = Layer.succeed( + SessionModelTransport.Service, + SessionModelTransport.Service.of({ + bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }), + close: () => Effect.void, + closeAll: Effect.void, + }), +) + +describe("PersistentPty session cleanup", () => { + it.live("removes a deleted session's terminals", () => + Effect.gen(function* () { + const daemon = yield* fakeDaemon() + yield* Effect.gen(function* () { + const session = yield* Session.Service + const pty = yield* PersistentPty.Service + const bus = yield* Bus.Service + const directory = yield* tmpdirScoped() + const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) }) + const deleted = yield* session.create({ location }) + const kept = yield* session.create({ location }) + const terminal = yield* pty.create(deleted.id, { args: [], cwd: directory.path, title: "deleted", env: {} }) + const other = yield* pty.create(kept.id, { args: [], cwd: directory.path, title: "kept", env: {} }) + const removed = yield* nextRemoved(bus) + + yield* session.remove(deleted.id) + + expect((yield* Fiber.join(removed)).data).toEqual({ sessionID: deleted.id, ptyID: terminal.id }) + expect(yield* pty.list(deleted.id)).toEqual([]) + expect((yield* pty.list()).map((item) => item.id)).toEqual([other.id]) + }).pipe(Effect.provide(layer(daemon))) + }), + ) + + it.live("keeps cleaning up later deletions after a terminal fails to stop", () => + Effect.gen(function* () { + const daemon = yield* fakeDaemon() + yield* Effect.gen(function* () { + const session = yield* Session.Service + const pty = yield* PersistentPty.Service + const bus = yield* Bus.Service + const directory = yield* tmpdirScoped() + const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) }) + const first = yield* session.create({ location }) + const second = yield* session.create({ location }) + const stuck = yield* pty.create(first.id, { args: [], cwd: directory.path, title: "stuck", env: {} }) + const terminal = yield* pty.create(second.id, { args: [], cwd: directory.path, title: "second", env: {} }) + yield* pty.control(stuck.id, "att", 80, 24) + daemon.failTerminate.add(stuck.id) + const removed = yield* nextRemoved(bus) + + yield* session.remove(first.id) + yield* session.remove(second.id) + + expect((yield* Fiber.join(removed)).data).toEqual({ sessionID: second.id, ptyID: terminal.id }) + expect((yield* pty.list()).map((item) => item.id)).toEqual([stuck.id]) + // The fake daemon rejects read_rows, so a stale selection would fail instead of returning null. + expect(yield* pty.read(first.id)).toBeNull() + }).pipe(Effect.provide(layer(daemon))) + }), + ) +}) + +describe("PersistentPty rename", () => { + it.live("renames a terminal, publishes the update and clears the user title", () => + Effect.gen(function* () { + const daemon = yield* fakeDaemon(["rename"]) + yield* Effect.gen(function* () { + const pty = yield* PersistentPty.Service + const bus = yield* Bus.Service + const directory = yield* tmpdirScoped() + const sessionID = Session.ID.make("ses_rename") + const terminal = yield* pty.create(sessionID, { args: [], cwd: directory.path, title: "test", env: {} }) + expect(terminal.userTitle).toBeNull() + const updated = yield* nextUpdated(bus) + + const renamed = yield* pty.rename(terminal.id, "build") + + expect(renamed).toMatchObject({ id: terminal.id, title: "build", userTitle: "build" }) + expect((yield* Fiber.join(updated)).data).toEqual({ sessionID, terminal: renamed }) + expect(yield* pty.rename(terminal.id, null)).toMatchObject({ title: "test", userTitle: null }) + }).pipe(Effect.provide(layer(daemon))) + }), + ) + + it.live("refuses to rename on a daemon without the rename feature and never sends it", () => + Effect.gen(function* () { + const daemon = yield* fakeDaemon() + yield* Effect.gen(function* () { + const pty = yield* PersistentPty.Service + const directory = yield* tmpdirScoped() + const terminal = yield* pty.create(Session.ID.make("ses_rename"), { + args: [], + cwd: directory.path, + title: "test", + env: {}, + }) + expect(terminal.userTitle).toBeNull() + + const error = yield* Effect.flip(pty.rename(terminal.id, "build")) + + expect(error).toEqual(new PersistentPty.UnsupportedError({ feature: "rename" })) + expect(daemon.ops).not.toContain("rename") + expect((yield* pty.get(terminal.id)).title).toBe("test") + }).pipe(Effect.provide(layer(daemon))) + }), + ) +}) + +const nextUpdated = (bus: Bus.Interface) => + bus + .subscribe(Updated) + .pipe(Stream.runHead, Effect.map(Option.getOrThrow), Effect.forkScoped({ startImmediately: true })) + +const nextRemoved = (bus: Bus.Interface) => + bus + .subscribe(Removed) + .pipe(Stream.runHead, Effect.map(Option.getOrThrow), Effect.forkScoped({ startImmediately: true })) + +const layer = (daemon: { readonly options: PersistentPty.Options }) => + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + SessionEnvironment.node, + Session.node, + Instance.node, + LocationServiceMap.node, + PersistentPty.node, + ]), + [ + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + SessionModelTransport.node.replace(transport), + PersistentPty.node.replace(PersistentPty.configured(daemon.options)), + offlineModels, + ], + ) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index c8f3a06a727c..d3d3fcea05cf 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -1,4 +1,5 @@ import { Agent } from "@opencode/core/agent" +import { Artifact } from "@opencode/core/artifact" import { AISDK } from "@opencode/core/aisdk" import { Command } from "@opencode/core/command" import { Config } from "@opencode/core/config" @@ -102,6 +103,7 @@ export const makePluginTestLayer = (permission = permissionLayer, generate = gen LayerNodePlatform.httpClient, Plugin.node, Agent.node, + Artifact.node, AISDK.node, Provider.node, Model.node, diff --git a/packages/core/test/plugin/goal.test.ts b/packages/core/test/plugin/goal.test.ts index 39455f77f792..73bdb518e3ba 100644 --- a/packages/core/test/plugin/goal.test.ts +++ b/packages/core/test/plugin/goal.test.ts @@ -504,7 +504,7 @@ function withTools(verdicts: ReturnType) { const last = JSON.stringify(request.messages.at(-1)) return last.includes("tool") && !last.includes("untrusted_objective") ? TestLLM.text("Checked.", "work") - : TestLLM.tool(`call-${verdicts.asked()}`, "missing_tool", {}) + : TestLLM.toolCall(`call-${verdicts.asked()}`, "missing_tool", {}) } } diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 5913df5c5c53..34ea625a4748 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -79,6 +79,10 @@ export function host(overrides: Overrides = {}): Plugin.Context { image: () => Effect.die("unused experimental.generate.image"), decisions: () => Effect.die("unused experimental.generate.decisions"), }, + artifact: { + transform: () => Effect.die("unused experimental.artifact.transform"), + reload: () => Effect.die("unused experimental.artifact.reload"), + }, }, generate: overrides.generate ?? { text: () => Effect.die("unused generate.text"), diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts index 2c6c58547cb1..ca2fde92dedb 100644 --- a/packages/core/test/plugin/module.test.ts +++ b/packages/core/test/plugin/module.test.ts @@ -417,7 +417,7 @@ it.live("blocks a local plugin before module initialization and honors replaceme ) const modules = yield* PluginModule.make() const operation = { type: "add" as const, target: directory.path, options: {} } - yield* setOrganizationPolicy([{ action: "integration.use", resource: `plugin:${directory.path}`, effect: "deny" }]) + yield* setOrganizationPolicy([{ action: "plugin.use", resource: directory.path, effect: "deny" }]) expect(yield* modules.load(operation)).toEqual({ blocked: true }) expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(false) yield* setOrganizationPolicy(undefined) @@ -475,7 +475,7 @@ for (const target of ["policy-fixture@1.2.3", "@scope/policy-fixture@1.2.3"]) { Effect.gen(function* () { const modules = yield* PluginModule.make() yield* setOrganizationPolicy([ - { action: "integration.use", resource: `plugin:${target.slice(0, target.lastIndexOf("@"))}`, effect: "deny" }, + { action: "plugin.use", resource: target.slice(0, target.lastIndexOf("@")), effect: "deny" }, ]) const operation = { type: "add" as const, target, options: {} } expect(yield* modules.load(operation, { install: false })).toEqual({ blocked: true }) diff --git a/packages/core/test/plugin/provider-antigravity.test.ts b/packages/core/test/plugin/provider-antigravity.test.ts index 2c9b5e20d343..057851820f1e 100644 --- a/packages/core/test/plugin/provider-antigravity.test.ts +++ b/packages/core/test/plugin/provider-antigravity.test.ts @@ -1,7 +1,8 @@ import { expect, spyOn } from "bun:test" import { Effect, Layer, Stream } from "effect" import { HttpClientResponse } from "effect/http" -import { LLM, Message, ToolDefinition } from "@opencode/ai" +import { Message, ToolDefinition } from "@opencode/ai" +import { LLM } from "@opencode/ai/effect" import { LLMClient, RequestExecutor } from "@opencode/ai/route" import { Gemini } from "@opencode/ai/protocols" import { Agent } from "@opencode/schema/agent" diff --git a/packages/core/test/plugin/provider-chatgpt.test.ts b/packages/core/test/plugin/provider-chatgpt.test.ts index 46fc786a53df..641738b86133 100644 --- a/packages/core/test/plugin/provider-chatgpt.test.ts +++ b/packages/core/test/plugin/provider-chatgpt.test.ts @@ -2,7 +2,7 @@ import { Money } from "@opencode/schema/money" import { Agent } from "@opencode/schema/agent" import { Session } from "@opencode/core/session" import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" -import { AIError, HttpContext, LLM, Message, RateLimitError } from "@opencode/ai/effect" +import { ai, AIError, HttpContext, Message, RateLimitError } from "@opencode/ai/effect" import { classifyProviderFailure } from "@opencode/ai/route" import { compileRequest } from "@opencode/ai/testing" import { describe, expect } from "bun:test" @@ -1097,7 +1097,7 @@ describe("ChatGPTPlugin", () => { const model = required(yield* models.get(Provider.ID.openai, modelID)) const body = (variant: string, messages: ReadonlyArray) => resolver.resolveModel(model, Model.VariantID.make(variant)).pipe( - Effect.flatMap((resolved) => compileRequest(LLM.request({ model: resolved.model, messages }))), + Effect.flatMap((resolved) => compileRequest(ai.request({ model: resolved.model, messages }))), Effect.map((compiled) => compiled.body), ) const switched = [ diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index dbe9dce194b9..866912a0df96 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { LLM } from "@opencode/ai/effect" +import { ai } from "@opencode/ai/effect" import { LLMClient, RequestExecutor } from "@opencode/ai/route" import { Document, Event, Info } from "@opencode/schema/config" import { ConfigPolicy } from "@opencode/schema/config/policy" @@ -1573,7 +1573,7 @@ describe("OpencodePlugin", () => { }) expect(projected.settings).toBeUndefined() const resolved = yield* ModelResolver.resolveModel(model, Model.VariantID.make(variant.id), credential) - yield* LLMClient.stream(LLM.request({ model: resolved, prompt: "Hello" })).pipe( + yield* LLMClient.stream(ai.request({ model: resolved, prompt: "Hello" })).pipe( Stream.runDrain, Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer))), ) diff --git a/packages/core/test/plugin/provider-poe.test.ts b/packages/core/test/plugin/provider-poe.test.ts index 6c571553dcd5..3014434c09a9 100644 --- a/packages/core/test/plugin/provider-poe.test.ts +++ b/packages/core/test/plugin/provider-poe.test.ts @@ -1,4 +1,4 @@ -import { LLM } from "@opencode/ai/effect" +import { ai } from "@opencode/ai/effect" import { LLMClient, RequestExecutor } from "@opencode/ai/route" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" @@ -71,7 +71,7 @@ const fixture = Effect.gen(function* () { const resolved = yield* resolver.resolve(Model.Ref.make({ providerID, id: modelID })) if (!resolved) throw new Error("Expected Poe model") expect(resolved.model.route.id).toBe("openai-compatible-chat") - return yield* LLMClient.stream(LLM.request({ model: resolved.model, prompt: "Hello" })).pipe( + return yield* LLMClient.stream(ai.request({ model: resolved.model, prompt: "Hello" })).pipe( Stream.runCollect, Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)), Effect.provideService(HttpClient.HttpClient, http), diff --git a/packages/core/test/plugin/provider/claude-agent/claude/branches.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/branches.test.ts new file mode 100644 index 000000000000..5731c26a1b9c --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/branches.test.ts @@ -0,0 +1,286 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber } from "effect" +import { SessionRecord } from "@opencode/core/plugin/provider/claude-agent/sync/record" +import { RequestKind } from "@opencode/core/plugin/provider/claude-agent/language-model/request-kind" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" +import { TestTools } from "../lib/tools" + +const first = TestSessions.prompt("Remember the marker COPPER-OTTER. Reply only with that marker.") + +describe("Claude history branches", () => { + it.effect("a failed compaction deletes its temporary branch and keeps the unmodified primary history resumable", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "billing-error" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const before = yield* SessionRecord.make(test.storage).get("session-a") + const reply = yield* TestSessions.reply(test.model, TestSessions.followup("Summarize"), "session-a", "compaction") + const error = reply.find((part) => part.type === "error") + expect(String(error?.error)).toContain("extra usage") + expect(claude.removed).toEqual([claude.forks[0]!.sessionID]) + expect(yield* SessionRecord.make(test.storage).get("session-a")).toEqual(before) + expect(TestSessions.text(yield* TestSessions.reply(test.model, TestSessions.followup())).trim()).toBe( + "COPPER-OTTER", + ) + expect(claude.runs[2]!.args).toContain(`--resume=${before!.claudeSessionID}`) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an unsupported attachment in changed history fails with its own message before forking", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const edited = TestSessions.followup() + edited[2] = { role: "assistant", content: [{ type: "text", text: "An edited reply" }] } + edited[3] = { role: "user", content: [{ type: "file", mediaType: "image/svg+xml", data: "PHN2Zy8+" }] } + const error = yield* TestSessions.reply(test.model, edited).pipe(Effect.flip) + expect(String(error.cause)).toContain("Claude Code does not support image/svg+xml attachments") + expect(claude.forks).toHaveLength(0) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("a fork finds its source after reload and rebuilds safely if that transcript has disappeared", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectInterrupt(), + ]) + const saved = TestSessions.storage() + const old = yield* TestSessions.start(claude, saved) + yield* TestSessions.reply(old.model, first) + yield* TestSessions.reply(old.model, TestSessions.followup()) + const source = (yield* SessionRecord.make(saved).get("session-a"))! + yield* old.registry.close() + const next = yield* TestSessions.start(claude, saved) + const fork = (sessionID: string) => + Effect.promise(async () => { + const result = await next.model.doStream({ + prompt: TestSessions.followup("A forked question"), + headers: { + [RequestKind.HEADER]: "primary", + [RequestKind.SESSION_HEADER]: sessionID, + [RequestKind.FORK_HEADER]: "session-a", + }, + }) + return TestSessions.collect(result.stream) + }) + yield* fork("child-a") + expect(claude.forks[0]!.source).toBe(source.claudeSessionID) + expect(claude.runs[1]!.args).toContain(`--resume=${claude.forks[0]!.sessionID}`) + claude.transcripts.delete(source.claudeSessionID) + yield* fork("child-b") + expect(claude.forks).toHaveLength(1) + expect(claude.runs[2]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const rebuilt = JSON.stringify( + claude.runs[2]!.received.find((message) => message.type === "user").message.content, + ) + expect(rebuilt).toContain("COPPER-OTTER") + expect(rebuilt).toContain("A forked question") + yield* next.registry.close() + claude.check() + }), + ) + + it.effect("undo resends an unchanged question, then branches again using remapped checkpoints", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const records = SessionRecord.make(test.storage) + yield* TestSessions.reply(test.model, first) + yield* TestSessions.reply(test.model, TestSessions.followup()) + const original = (yield* records.get("session-a"))! + yield* TestSessions.reply(test.model, TestSessions.followup()) + const branch = (yield* records.get("session-a"))! + expect(claude.forks[0]!.boundary).toBe(original.history![1]!.claudeMessageID!) + expect(claude.runs[1]!.received.find((message) => message.type === "user").message.content).toEqual([ + { type: "text", text: "What was the marker? Reply only with it." }, + ]) + expect(branch.claudeSessionID).not.toBe(original.claudeSessionID) + expect(branch.history![1]!.claudeMessageID).not.toBe(original.history![1]!.claudeMessageID) + const reply = yield* TestSessions.reply(test.model, TestSessions.followup("Another replacement")) + expect(claude.forks).toHaveLength(2) + expect(claude.forks[1]).toMatchObject({ + source: branch.claudeSessionID, + boundary: branch.history![1]!.claudeMessageID, + }) + expect(claude.runs[2]!.received.find((message) => message.type === "user").message.content).toEqual([ + { type: "text", text: "Another replacement" }, + ]) + expect(reply.find((part) => part.type === "text-end")?.providerMetadata?.["claude-agent"]?.sessionID).toBe( + claude.forks[1]!.sessionID, + ) + expect(claude.removed).toHaveLength(0) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an edited earlier reply forks before it and sends the edited history", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const prompt = TestSessions.followup() + prompt[2] = { role: "assistant", content: [{ type: "text", text: "REVISED-MARKER" }] } + yield* TestSessions.reply(test.model, prompt) + expect(claude.forks).toHaveLength(1) + expect(claude.runs[1]!.args).toContain(`--resume=${claude.forks[0]!.sessionID}`) + const user = JSON.stringify(claude.runs[1]!.received.find((message) => message.type === "user").message.content) + expect(user).toContain("REVISED-MARKER") + expect(user).not.toContain("COPPER-OTTER") + yield* test.registry.close() + claude.check() + }), + ) + + it.effect( + "cancelling one generate branch leaves another and the primary session usable, and deletes both temporary branches", + () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "utility" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectInterrupt({ recording: "text-cancel" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const before = yield* SessionRecord.make(test.storage).get("session-a") + const stream = yield* TestSessions.stream( + test.model, + TestSessions.followup("Write a long answer"), + "session-a", + "generate", + ) + const reader = stream.stream.getReader() + yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => {}))) + yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Generate ended before cancellation") + if (part.value.type === "text-delta") break + } + }) + const other = yield* TestSessions.reply( + test.model, + TestSessions.followup("Reply only LILAC-HERON"), + "session-a", + "generate", + ) + expect(TestSessions.text(other).trim()).toBe("LILAC-HERON") + expect(claude.forks).toHaveLength(2) + yield* Effect.promise(() => reader.cancel()) + expect(new Set(claude.removed)).toEqual(new Set(claude.forks.map((fork) => fork.sessionID))) + expect(yield* SessionRecord.make(test.storage).get("session-a")).toEqual(before) + expect(claude.runs[0]!.received.some((message) => message.request?.subtype === "interrupt")).toBe(false) + expect(TestSessions.text(yield* TestSessions.reply(test.model, TestSessions.followup())).trim()).toBe( + "COPPER-OTTER", + ) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("compaction drains a parked tool turn and the next primary request starts from the summary alone", () => + Effect.gen(function* () { + const cancel = FakeClaude.replay("tools-waiting-cancel") + const nextUser = cancel.findIndex((step, i) => i > 1 && step.type === "exchange" && step.input.type === "user") + const steps = cancel.slice(0, nextUser) + const terminal = steps.findIndex( + (step) => step.type === "exchange" && step.replies.some((message) => message.type === "result"), + ) + const ending = steps[terminal]! + if (ending.type !== "exchange") throw new Error("Missing recorded terminal result") + const drain = Promise.withResolvers() + steps[terminal] = { ...ending, beforeResult: drain.promise } + const claude = FakeClaude.make([ + ...steps, + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "utility" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* Effect.addFinalizer(() => Effect.sync(() => drain.resolve())) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), () => ({ + type: "execution-denied", + reason: "Compacted", + })) + const summary = yield* TestSessions.reply(test.model, prompt, "session-a", "compaction").pipe(Effect.forkChild) + yield* Effect.promise(() => claude.started(steps[terminal]!)) + expect(claude.forks).toHaveLength(0) + expect(claude.runs).toHaveLength(1) + drain.resolve() + yield* Fiber.join(summary) + expect(claude.forks).toHaveLength(1) + expect(claude.removed).toEqual([claude.forks[0]!.sessionID]) + const compacted = TestSessions.prompt( + "Only SUMMARY-HERON remains.", + ) + yield* TestSessions.reply(test.model, compacted) + expect(claude.runs[2]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const input = JSON.stringify(claude.runs[2]!.received.find((message) => message.type === "user").message.content) + expect(input).toContain("SUMMARY-HERON") + expect(input).not.toContain("lookup") + yield* test.registry.close() + claude.check() + }), + ) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/probe.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/probe.test.ts new file mode 100644 index 000000000000..62ae4bf08432 --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/probe.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Fiber } from "effect" +import { TestClock } from "effect/testing" +import { ClaudeProbe } from "@opencode/core/plugin/provider/claude-agent/claude/probe" +import { ClaudeQueryOptions } from "@opencode/core/plugin/provider/claude-agent/claude/query-options" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { FakeIO } from "../lib/fake-io" + +describe("login check", () => { + it.effect("discovers each model's capacity without sending a prompt", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("startup") + const context = steps.find( + (step) => step.type === "exchange" && step.input.request?.subtype === "get_context_usage", + ) + if (context?.type !== "exchange") throw new Error("Missing context recording") + // Claude's auto-compaction window is not the model's capacity. + context.replies[0].response.response.maxTokens = 200_000 + const claude = FakeClaude.make(steps) + const result = yield* ClaudeProbe.run( + ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + ) + expect(result.models.find((model) => model.value === "opus")?.contextWindow).toBe(1_000_000) + expect(result.models.find((model) => model.value === "claude-opus-4-6")?.contextWindow).toBe(200_000) + expect(claude.runs[0]!.received.some((message) => message.type === "user")).toBe(false) + claude.check() + }), + ) + + it.effect("keeps the login and other limits when one model reports an invalid capacity", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("startup") + const context = steps.find( + (step) => step.type === "exchange" && step.input.request?.subtype === "get_context_usage", + ) + if (context?.type !== "exchange") throw new Error("Missing context recording") + context.replies[0].response.response.rawMaxTokens = 0 + const claude = FakeClaude.make(steps) + const result = yield* ClaudeProbe.run( + ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + ) + expect(ClaudeProbe.isLoggedIn(result.account)).toBe(true) + expect(result.models.find((model) => model.value === "opus")?.contextWindow).toBeUndefined() + expect(result.models.find((model) => model.value === "sonnet")?.contextWindow).toBe(1_000_000) + claude.check() + }), + ) + + it.effect("closes stuck context discovery without turning it into a login failure", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("startup").slice(0, 3) + const context = { ...steps[2]!, hold: new Promise(() => {}) } + steps[2] = context + const claude = FakeClaude.make(steps) + const check = yield* ClaudeProbe.run( + ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + ).pipe(Effect.forkScoped) + yield* Effect.promise(() => claude.started(context)) + yield* TestClock.adjust("30 seconds") + const result = yield* Fiber.join(check) + expect(ClaudeProbe.isLoggedIn(result.account)).toBe(true) + expect(result.models.find((model) => model.value === "opus")?.contextWindow).toBeUndefined() + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(() => claude.check()).toThrow("1 script steps did not finish") + }), + ) + + it.effect("times out a stuck startup and closes the process", () => + Effect.gen(function* () { + const startup = FakeClaude.expectStartup({ hold: new Promise(() => {}) }) + const claude = FakeClaude.make([startup]) + const io = FakeIO.make({ claude }) + const check = yield* ClaudeProbe.run(ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io })).pipe( + Effect.flip, + Effect.forkScoped, + ) + yield* Effect.promise(() => claude.started(startup)) + yield* TestClock.adjust("30 seconds") + const error = yield* Fiber.join(check) + expect(String(error.cause)).toContain("did not start within 30 seconds") + yield* Effect.promise(() => claude.runs[0]!.closed) + }), + ) +}) + +describe("compatibility and login", () => { + test("only 2.1.248 or later is compatible", () => { + expect(ClaudeProbe.versionError("2.1.248")).toBeUndefined() + expect(ClaudeProbe.versionError("2.1.247")?.message).toContain("claude update") + expect(ClaudeProbe.versionError(undefined)?.message).toContain("claude update") + }) + + test("only a missing credential counts as logged out", () => { + expect(ClaudeProbe.isLoggedIn({ tokenSource: "none", apiProvider: "firstParty" })).toBe(false) + expect(ClaudeProbe.isLoggedIn({ tokenSource: "none", apiKeySource: "ANTHROPIC_API_KEY" })).toBe(true) + expect(ClaudeProbe.isLoggedIn({ subscriptionType: "Claude Team", apiProvider: "firstParty" })).toBe(true) + }) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/recorder.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/recorder.test.ts new file mode 100644 index 000000000000..b487a740aa21 --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/recorder.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import path from "node:path" +import { Recorder } from "../lib/recorder" + +describe("recording privacy", () => { + test("checked-in recordings contain no thinking blocks or deltas", () => { + const directory = path.join(import.meta.dir, "../fixtures/recordings") + for (const name of readdirSync(directory)) { + if (!name.endsWith(".jsonl")) continue + const lines = readFileSync(path.join(directory, name), "utf8").trim().split("\n") + for (const line of lines) { + // Compare booleans so failures cannot print the offending content. + const unchanged = JSON.stringify(Recorder.parse(line)) === JSON.stringify(JSON.parse(line)) + expect(unchanged, name).toBe(true) + } + } + }) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/session.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/session.test.ts new file mode 100644 index 000000000000..c1fa17cd5ecd --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/session.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { ClaudeQueryOptions } from "@opencode/core/plugin/provider/claude-agent/claude/query-options" +import { ClaudeSession } from "@opencode/core/plugin/provider/claude-agent/claude/session" +import { FakeClaude } from "../lib/fake-claude" +import { FakeIO } from "../lib/fake-io" + +describe("Claude session", () => { + test.each([0, 1])("exit code %i before a result reports failure and allows cleanup", async (code) => { + const turn = FakeClaude.expectTurn({ hold: new Promise(() => {}) }) + const claude = FakeClaude.make([FakeClaude.expectStartup(), turn]) + const session = await ClaudeSession.start({ + ...ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + sessionId: crypto.randomUUID(), + }) + try { + await session.ready + session.begin([{ type: "text", text: "Reply with a marker." }]) + await claude.started(turn) + await claude.runs[0]!.exit(code) + const message = code === 0 ? "Claude Code stopped before finishing its reply" : "code 1" + await expect(session.readStep(() => {}, [])).rejects.toThrow(message) + await session.interrupt() + expect(claude.runs[0]!.received.some((message) => message.request?.subtype === "interrupt")).toBe(false) + } finally { + await session.stop() + } + await claude.runs[0]!.closed + expect(() => claude.check()).toThrow("1 script steps did not finish") + }) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/sessions.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/sessions.test.ts new file mode 100644 index 000000000000..042971cae606 --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/sessions.test.ts @@ -0,0 +1,430 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber } from "effect" +import { TestClock } from "effect/testing" +import { ClaudeSessions } from "@opencode/core/plugin/provider/claude-agent/claude/sessions" +import { ClaudeAgentLanguageModel } from "@opencode/core/plugin/provider/claude-agent/language-model/claude-agent-language-model" +import { RequestKind } from "@opencode/core/plugin/provider/claude-agent/language-model/request-kind" +import { ClaudeSystemPrompt } from "@opencode/core/plugin/provider/claude-agent/language-model/system-prompt" +import { SessionRecord } from "@opencode/core/plugin/provider/claude-agent/sync/record" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" +import { TestTools } from "../lib/tools" +import { Recorder } from "../lib/recorder" + +const first = TestSessions.prompt("Remember the marker COPPER-OTTER. Reply only with that marker.") + +describe("Claude sessions", () => { + it.effect("streams consecutive turns once, on one process, with per-turn usage", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const one = yield* TestSessions.reply(test.model, first) + const two = yield* TestSessions.reply(test.model, TestSessions.followup()) + expect(TestSessions.text(one)).toBe("COPPER-OTTER") + expect(TestSessions.text(two)).toBe("COPPER-OTTER") + const finish = two.find((part) => part.type === "finish")! + const usage = Recorder.result("text", 1).usage + expect(finish.usage.inputTokens).toEqual({ + total: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, + noCache: usage.input_tokens, + cacheRead: usage.cache_read_input_tokens, + cacheWrite: usage.cache_creation_input_tokens, + }) + expect(finish.usage.outputTokens.total).toBe(usage.output_tokens) + expect(claude.runs).toHaveLength(1) + const users = claude.runs[0]!.received.filter((message) => message.type === "user") + expect(users).toHaveLength(2) + expect(users[1].message.content).toEqual([{ type: "text", text: "What was the marker? Reply only with it." }]) + expect(users[0].client_composed).toBe(true) + yield* test.registry.close() + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("a missing Claude transcript is rebuilt from history, even after a rejected attachment", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const before = (yield* SessionRecord.make(test.storage).get("session-a"))! + yield* test.registry.close() + const registry = ClaudeSessions.registry() + yield* Effect.addFinalizer(() => registry.close()) + const sessions = yield* registry.attach({ + ...test.input, + io: { ...test.input.io, sessionExists: async () => false }, + }) + const model = yield* TestSessions.model(sessions) + const prompt = TestSessions.followup("Inspect this image") + prompt.push({ role: "user", content: [{ type: "file", mediaType: "image/svg+xml", data: "PHN2Zy8+" }] }) + const error = yield* TestSessions.reply(model, prompt).pipe(Effect.flip) + expect(String(error.cause)).toContain("Claude Code does not support image/svg+xml attachments") + yield* TestSessions.reply(model, TestSessions.followup()) + expect(claude.runs).toHaveLength(2) + expect(claude.runs[1]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const user = claude.runs[1]!.received.find((message) => message.type === "user") + expect(JSON.stringify(user.message.content)).toContain("") + expect(JSON.stringify(user.message.content)).toContain("COPPER-OTTER") + expect(JSON.stringify(user.message.content)).not.toContain("image/svg+xml") + const after = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(after.claudeSessionID).not.toBe(before.claudeSessionID) + yield* registry.close() + claude.check() + }), + ) + + it.effect("cancellation drains past the interrupt receipt before accepting another turn", () => + Effect.gen(function* () { + const drain = Promise.withResolvers() + const interrupt = FakeClaude.expectInterrupt({ recording: "text-cancel", beforeResult: drain.promise }) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + interrupt, + FakeClaude.expectTurn({ recording: "text-cancel", index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const result = yield* TestSessions.stream(test.model, first) + const reader = result.stream.getReader() + // Wait for real partial text + const partial = yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Reply ended before any text") + if (part.value.type === "text-delta") return part.value.delta + } + }) + const cancel = yield* Effect.promise(() => reader.cancel()).pipe(Effect.forkScoped({ startImmediately: true })) + yield* Effect.promise(() => claude.started(interrupt)) + const prompt = [ + ...first, + { role: "assistant" as const, content: [{ type: "text" as const, text: partial }] }, + { role: "user" as const, content: [{ type: "text" as const, text: "Continue" }] }, + ] + const next = yield* TestSessions.reply(test.model, prompt).pipe(Effect.forkScoped({ startImmediately: true })) + expect(claude.runs[0]!.received.filter((message) => message.type === "user")).toHaveLength(1) + drain.resolve() + yield* Fiber.join(cancel) + expect(TestSessions.text(yield* Fiber.join(next))).toBe("COPPER-OTTER") + // Claude's transcript already holds the partial reply, so only the new input is sent on the same process. + expect(claude.runs).toHaveLength(1) + const users = claude.runs[0]!.received.filter((message) => message.type === "user") + expect(users[1].message.content).toEqual([{ type: "text", text: "Continue" }]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("cancelled history resumes after a registry restart without replaying the partial reply", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + FakeClaude.expectInterrupt({ recording: "text-cancel" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel", index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const result = yield* TestSessions.stream(test.model, first) + const reader = result.stream.getReader() + yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Reply ended before any text") + if (part.value.type === "text-delta") break + } + await reader.cancel() + }) + const saved = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(saved.interrupted).toBe(true) + yield* test.registry.close() + const registry = ClaudeSessions.registry() + yield* Effect.addFinalizer(() => registry.close()) + const sessions = yield* registry.attach(test.input) + const model = yield* TestSessions.model(sessions) + // OpenCode may save no assistant text when cancellation happens early. + const prompt = [...first, { role: "user" as const, content: [{ type: "text" as const, text: "Continue" }] }] + expect(TestSessions.text(yield* TestSessions.reply(model, prompt))).toBe("COPPER-OTTER") + expect(claude.runs[1]!.args).toContain(`--resume=${saved.claudeSessionID}`) + const user = claude.runs[1]!.received.find((message) => message.type === "user") + expect(user.message.content).toEqual([{ type: "text", text: "Continue" }]) + const after = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(after.interrupted).toBe(false) + yield* registry.close() + claude.check() + }), + ) + + it.effect("chat keeps the Claude Code preset when OpenCode's built-in prompt changes", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const prompt = TestSessions.followup() + prompt[0] = { role: "system", content: "A changed OpenCode built-in agent prompt" } + yield* TestSessions.reply(test.model, prompt) + expect(claude.runs).toHaveLength(1) + const run = claude.runs[0]! + const init = run.received.find((message) => message.request?.subtype === "initialize").request + expect(init.systemPrompt).toBeUndefined() + expect(JSON.stringify(run.received)).not.toContain("A changed OpenCode built-in agent prompt") + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("a turn arriving during idle shutdown waits for the old process, then resumes its session", () => + Effect.gen(function* () { + const release = Promise.withResolvers() + const stop = FakeClaude.expectInterrupt({ hold: release.promise }) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + stop, + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-resume" }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const id = claude.runs[0]!.args.find((arg) => arg.startsWith("--session-id="))!.split("=")[1] + const timeout = yield* TestClock.adjust(ClaudeSessions.IDLE_MS).pipe( + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.promise(() => claude.started(stop)) + const next = yield* TestSessions.reply(test.model, TestSessions.followup()).pipe( + Effect.forkScoped({ startImmediately: true }), + ) + expect(claude.runs).toHaveLength(1) + release.resolve() + yield* Fiber.join(timeout) + expect(TestSessions.text(yield* Fiber.join(next))).toBe("COPPER-OTTER") + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(claude.runs).toHaveLength(2) + expect(claude.runs[1]!.args).toContain(`--resume=${id}`) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("cancelling during startup closes it and does not save a nonexistent session", () => + Effect.gen(function* () { + const startup = FakeClaude.expectStartup({ hold: new Promise(() => {}) }) + const claude = FakeClaude.make([ + startup, + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const stream = yield* TestSessions.stream(test.model, first) + yield* Effect.promise(() => claude.started(startup)) + yield* Effect.promise(() => stream.stream.cancel()) + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(test.storage.data.size).toBe(0) + expect(TestSessions.text(yield* TestSessions.reply(test.model, first))).toBe("COPPER-OTTER") + expect(claude.runs[1]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + yield* test.registry.close() + expect(() => claude.check()).toThrow("1 script steps did not finish") + }), + ) + + it.effect("chat checks compatibility before sending a prompt", () => + Effect.gen(function* () { + const startup = FakeClaude.expectStartup() + startup.reply.claude_code_version = "2.1.247" + const claude = FakeClaude.make([startup]) + const test = yield* TestSessions.start(claude) + const error = yield* TestSessions.reply(test.model, first).pipe(Effect.flip) + expect(String(error.cause)).toContain("claude update") + expect(claude.runs[0]!.received.some((message) => message.type === "user")).toBe(false) + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("changes model and effort live, and resumes when the output limit changes or is cleared", () => + Effect.gen(function* () { + const controls = FakeClaude.replay("controls").filter( + (step) => + step.type === "exchange" && ["set_model", "apply_flag_settings"].includes(step.input.request?.subtype), + ) + const recorded = (index: number) => FakeClaude.expectTurn({ recording: "controls", index }) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + recorded(0), + controls[0]!, + recorded(1), + controls[1]!, + recorded(2), + controls[2]!, + controls[3]!, + recorded(3), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + recorded(4), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + recorded(4), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const prompt = TestSessions.prompt("Remember CONTROL-OTTER. Reply only CONTROL-OTTER.") + const settings = [ + { model: "haiku", effort: "low", maxOutputTokens: 2048 }, + { model: "haiku", effort: "high", maxOutputTokens: 2048 }, + { model: "haiku", maxOutputTokens: 2048 }, + { model: "sonnet", effort: "low", maxOutputTokens: 2048 }, + { model: "sonnet", effort: "low", maxOutputTokens: 4096 }, + { model: "sonnet", effort: "low" }, + ] + for (const setting of settings) { + const model = new ClaudeAgentLanguageModel.Model(setting.model, test.model.dependencies) + const result = yield* Effect.promise(() => + model.doStream({ + prompt, + maxOutputTokens: setting.maxOutputTokens, + providerOptions: { "claude-agent": setting.effort ? { effort: setting.effort } : {} }, + headers: { [RequestKind.HEADER]: "primary", [RequestKind.SESSION_HEADER]: "session-a" }, + }), + ) + expect(TestSessions.text(yield* Effect.promise(() => TestSessions.collect(result.stream)))).toBe( + "CONTROL-OTTER", + ) + prompt.push( + { role: "assistant", content: [{ type: "text", text: "CONTROL-OTTER" }] }, + { role: "user", content: [{ type: "text", text: "What was the marker? Reply only with it." }] }, + ) + } + expect(claude.runs).toHaveLength(3) + const id = claude.runs[0]!.args.find((arg) => arg.startsWith("--session-id="))!.split("=")[1] + const requests = claude.runs[0]!.received.map((message) => message.request) + expect(requests).toContainEqual({ subtype: "set_model", model: "sonnet" }) + expect(requests).toContainEqual({ subtype: "apply_flag_settings", settings: { effortLevel: "high" } }) + expect(requests).toContainEqual({ subtype: "apply_flag_settings", settings: { effortLevel: null } }) + expect(claude.runs[0]!.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("2048") + expect(claude.runs[1]!.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("4096") + expect(claude.runs[2]!.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBeUndefined() + for (const run of claude.runs.slice(1)) { + expect(run.args).toContain("--model=sonnet") + expect(run.args).toContain(`--resume=${id}`) + } + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("titles use their own task prompt in a throwaway session that leaves the chat alone", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "utility" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + yield* TestSessions.reply(test.model, TestSessions.followup(), "session-a", "title") + expect(TestSessions.text(yield* TestSessions.reply(test.model, TestSessions.followup()))).toBe("COPPER-OTTER") + expect(claude.runs).toHaveLength(2) + const title = claude.runs[1]! + const init = title.received.find((message) => message.request?.subtype === "initialize").request + expect(init.systemPrompt).toEqual(["You are a concise test assistant."]) + expect(title.args).toContain("--no-session-persistence") + expect(test.storage.data.size).toBe(1) + yield* test.registry.close() + claude.check() + }), + ) + + // Claude's prompt cache only matches when tools and system prompt are identical, so branches must not drop them. + it.effect("conversation branches send the chat's tools and system prompt, and a tool call ends the branch", () => + Effect.gen(function* () { + // The branch replays a recorded tool call that is stopped while waiting for its result. + const waiting = FakeClaude.replay("tools-waiting-cancel") + const resumed = waiting.findIndex( + (step, index) => index > 1 && step.type === "exchange" && step.input.type === "user", + ) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + ...waiting.slice(0, resumed), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + // OpenCode's session hooks set these on chats and branches alike. + const settings = { + "claude-agent": { + [ClaudeSystemPrompt.AGENT_OPTION]: "AGENT-ROLE", + [ClaudeSystemPrompt.INSTRUCTIONS_OPTION]: "OPENCODE-INSTRUCTIONS", + }, + } + yield* TestTools.step(test.model, first, TestTools.tools, "primary", settings) + const parts = yield* TestTools.step(test.model, TestSessions.followup(), TestTools.tools, "generate", settings) + const chat = claude.runs[0]!.received.find((message) => message.request?.subtype === "initialize").request + const branch = claude.runs[1]!.received.find((message) => message.request?.subtype === "initialize").request + expect(chat.sdkMcpServers).toEqual(["opencode"]) + expect(branch.sdkMcpServers).toEqual(chat.sdkMcpServers) + expect(chat.appendSystemPrompt).toContain("AGENT-ROLE") + expect(chat.appendSystemPrompt).toContain("OPENCODE-INSTRUCTIONS") + expect(branch.appendSystemPrompt).toBe(chat.appendSystemPrompt) + // The reply ends at the tool call, and the throwaway process and fork are cleaned up. + expect(parts.filter((part) => part.type === "tool-call")).toHaveLength(2) + yield* Effect.promise(() => claude.runs[1]!.closed) + expect(claude.removed).toEqual([claude.forks[0]!.sessionID]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("closing the registry interrupts and drains a running side call", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + FakeClaude.expectInterrupt({ recording: "text-cancel" }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const stream = yield* TestSessions.stream(test.model, first, "session-a", "generate") + const reader = stream.stream.getReader() + yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => {}))) + yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Reply ended before shutdown could be tested") + if (part.value.type === "text-delta") break + } + }) + yield* test.registry.close() + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(test.storage.data.size).toBe(0) + claude.check() + }), + ) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/status.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/status.test.ts new file mode 100644 index 000000000000..50df3cef03ed --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/status.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, SubscriptionRef } from "effect" +import { ClaudeProbe } from "@opencode/core/plugin/provider/claude-agent/claude/probe" +import { ClaudeStatus } from "@opencode/core/plugin/provider/claude-agent/claude/status" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { FakeIO } from "../lib/fake-io" + +describe("login status", () => { + it.effect("finds claude when it is installed after the plugin starts", () => + Effect.gen(function* () { + const claude = FakeClaude.make([FakeClaude.expectStartup()]) + let executable: string | undefined + const io = { ...FakeIO.make({ claude }), which: () => executable } + const status = yield* ClaudeStatus.make(io, () => Effect.succeed({})) + + yield* status.refresh() + expect(yield* SubscriptionRef.get(status.state)).toEqual({ type: "missing" }) + + executable = FakeIO.EXECUTABLE + yield* status.refresh() + expect((yield* SubscriptionRef.get(status.state)).type).toBe("ready") + claude.check() + }), + ) + + it.effect("a reconnect waits for the startup check, then publishes the new login", () => + Effect.gen(function* () { + const hold = Promise.withResolvers() + const atStart = FakeClaude.expectStartup({ recording: "startup-logged-out", hold: hold.promise }) + const onConnect = FakeClaude.expectStartup() + const claude = FakeClaude.make([atStart, onConnect]) + const status = yield* ClaudeStatus.make(FakeIO.make({ claude }), () => Effect.succeed({})) + + const startup = yield* status.refresh().pipe(Effect.forkScoped) + yield* Effect.promise(() => claude.started(atStart)) + const reconnect = yield* status.refresh().pipe(Effect.forkScoped({ startImmediately: true })) + expect(claude.runs).toHaveLength(1) + + hold.resolve() + yield* Fiber.join(startup) + yield* Fiber.join(reconnect) + const state = yield* SubscriptionRef.get(status.state) + expect(state.type).toBe("ready") + if (state.type !== "ready") throw new Error("login check did not finish") + expect(ClaudeProbe.isLoggedIn(state.probe.account)).toBe(true) + claude.check() + }), + ) + + it.effect("reports a failed startup and recovers on the next check", () => + Effect.gen(function* () { + const claude = FakeClaude.make([FakeClaude.expectStartup({ exitCode: 1 }), FakeClaude.expectStartup()]) + const status = yield* ClaudeStatus.make(FakeIO.make({ claude }), () => Effect.succeed({})) + + yield* status.refresh() + const failed = yield* SubscriptionRef.get(status.state) + expect(failed.type).toBe("failed") + if (failed.type !== "failed") throw new Error("startup failure was not reported") + expect(failed.message).toContain("code 1") + + yield* status.refresh() + expect((yield* SubscriptionRef.get(status.state)).type).toBe("ready") + claude.check() + }), + ) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/sync.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/sync.test.ts new file mode 100644 index 000000000000..02c0146edf03 --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/sync.test.ts @@ -0,0 +1,70 @@ +import { describe, expect } from "bun:test" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import { Effect } from "effect" +import { SessionRecord } from "@opencode/core/plugin/provider/claude-agent/sync/record" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" + +describe("recorded history sync", () => { + it.effect("catches up once, saves transcript boundaries, then continues without replaying history", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "sync" }), + FakeClaude.expectTurn({ recording: "sync", index: 1 }), + FakeClaude.expectTurn({ recording: "sync", index: 2 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const history: LanguageModelV3Prompt = [ + { role: "user", content: [{ type: "text", text: "Give me a marker." }] }, + { role: "assistant", content: [{ type: "text", text: "My marker is LILAC-HERON." }] }, + ] + const ask = Effect.fn(function* (text: string) { + history.push({ role: "user", content: [{ type: "text", text }] }) + const parts = yield* TestSessions.reply(test.model, history) + const reply = TestSessions.text(parts) + history.push({ role: "assistant", content: [{ type: "text", text: reply }] }) + return reply + }) + yield* ask("Remember COPPER-OTTER too. Reply only with COPPER-OTTER.") + history.push( + { role: "user", content: [{ type: "text", text: "Read the marker file." }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "marker-read", toolName: "read", input: { path: "marker.txt" } }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "marker-read", + toolName: "read", + output: { type: "text", value: "LEMON-IBIS" }, + }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "I read the file successfully." }] }, + ) + yield* ask("List all three markers from our conversation, including the file. Output only the markers.") + yield* ask("Repeat those three markers.") + expect(claude.runs).toHaveLength(1) + const users = claude.runs[0]!.received.filter((message) => message.type === "user") + expect(JSON.stringify(users[0].message.content)).toContain("LILAC-HERON") + expect(JSON.stringify(users[1].message.content)).toContain("LEMON-IBIS") + expect(JSON.stringify(users[1].message.content)).not.toContain("LILAC-HERON") + expect(users[2].message.content).toEqual([{ type: "text", text: "Repeat those three markers." }]) + const record = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(record.history).toHaveLength(12) + expect(record.history![2]!.claudeMessageID).toBe(users[0].uuid) + expect(record.history![8]!.claudeMessageID).toBe(users[1].uuid) + expect(record.history![10]!.claudeMessageID).toBe(users[2].uuid) + // Catch-up's interior messages don't claim independent transcript boundaries. + expect(record.history![5]!.claudeMessageID).toBeUndefined() + yield* test.registry.close() + claude.check() + }), + ) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/claude/tools.test.ts b/packages/core/test/plugin/provider/claude-agent/claude/tools.test.ts new file mode 100644 index 000000000000..8d1b852efce5 --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/claude/tools.test.ts @@ -0,0 +1,364 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Scope } from "effect" +import { TestClock } from "effect/testing" +import { ClaudeSessions } from "@opencode/core/plugin/provider/claude-agent/claude/sessions" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" +import { TestTools } from "../lib/tools" +import { Recorder } from "../lib/recorder" + +describe("Claude tool steps", () => { + it.effect("two tool steps keep one turn alive past the idle timeout and match every result to its call", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools")) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + const steps = [] + for (let i = 0; i < 3; i++) { + // Subagents and unanswered permission prompts can keep OpenCode's tools running this long. + yield* TestClock.adjust(ClaudeSessions.IDLE_MS * 2) + const parts = yield* TestTools.step(test.model, prompt) + steps.push(parts) + TestTools.append(prompt, parts, TestTools.output) + } + expect(steps[0]!.filter((part) => part.type === "tool-call")).toHaveLength(2) + expect(steps[1]!.find((part) => part.type === "tool-call")?.toolName).toBe("confirm") + expect(TestSessions.text(steps[2]!)).toBe("COPPER-OTTER") + expect(claude.runs).toHaveLength(1) + const received = claude.runs[0]!.received + expect(received.filter((message) => message.type === "user")).toHaveLength(1) + expect(received.some((message) => message.request?.subtype === "interrupt")).toBe(false) + const outputs = received + .filter((message) => message.response?.response?.mcp_response) + .map((message) => message.response.response.mcp_response.result) + expect(outputs.map((result) => result.content[0].text)).toEqual(["LILAC-HERON", "LEMON-IBIS", "COPPER-OTTER"]) + const usage = Recorder.load("tools").filter( + (line) => + line.from === "claude" && line.message.type === "stream_event" && line.message.event.type === "message_delta", + ) + for (let i = 0; i < steps.length; i++) { + const recorded = usage[i]! + if (recorded.from !== "claude") throw new Error("Expected Claude usage") + expect(steps[i]!.find((part) => part.type === "finish")?.usage.outputTokens.total).toBe( + recorded.message.event.usage.output_tokens, + ) + } + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("plugin reload hands over the process with its pending handlers; without a reload it shuts down", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools")) + const base = yield* TestSessions.start(FakeClaude.make([])) + const input = { ...base.input, io: { ...base.input.io, spawn: claude.spawn } } + const registry = ClaudeSessions.registry() + yield* Effect.addFinalizer(() => registry.close()) + const scope = yield* Scope.make() + const old = yield* registry.attach(input).pipe(Effect.provideService(Scope.Scope, scope)) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(yield* TestSessions.model(old), prompt), TestTools.output) + yield* Scope.close(scope, Exit.void) + yield* TestClock.adjust(ClaudeSessions.HANDOVER_MS - 1) + const next = yield* Scope.make() + const fresh = yield* TestSessions.model( + yield* registry.attach(input).pipe(Effect.provideService(Scope.Scope, next)), + ) + TestTools.append(prompt, yield* TestTools.step(fresh, prompt), TestTools.output) + expect(TestSessions.text(yield* TestTools.step(fresh, prompt))).toBe("COPPER-OTTER") + expect(claude.runs).toHaveLength(1) + yield* Scope.close(next, Exit.void) + yield* TestClock.adjust(ClaudeSessions.HANDOVER_MS) + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("closing the registry waits for a running tool call to drain, without answering it", () => + Effect.gen(function* () { + const cancel = FakeClaude.replay("tools-waiting-cancel") + const nextUser = cancel.findIndex( + (step, index) => index > 1 && step.type === "exchange" && step.input.type === "user", + ) + const steps = cancel.slice(0, nextUser) + const drain = Promise.withResolvers() + const terminal = steps.findIndex( + (step) => step.type === "exchange" && step.replies.some((message) => message.type === "result"), + ) + const ending = steps[terminal]! + if (ending.type !== "exchange") throw new Error("Missing interrupt result recording") + steps[terminal] = { ...ending, beforeResult: drain.promise } + const claude = FakeClaude.make(steps) + const test = yield* TestSessions.start(claude) + yield* Effect.addFinalizer(() => Effect.sync(() => drain.resolve())) + const prompt = TestTools.prompt() + yield* TestTools.step(test.model, prompt) + let stopped = false + const close = yield* test.registry.close().pipe( + Effect.andThen( + Effect.sync(() => { + stopped = true + }), + ), + Effect.forkChild, + ) + yield* Effect.promise(() => claude.started(steps[terminal]!)) + yield* Effect.yieldNow + expect(stopped).toBe(false) + const call = Recorder.load("tools-waiting-cancel").find( + (line) => line.from === "claude" && line.message.request?.message?.method === "tools/call", + ) + if (!call || call.from !== "claude") throw new Error("Missing recorded MCP call") + expect(claude.runs[0]!.received.some((message) => message.response?.request_id === call.message.request_id)).toBe( + false, + ) + drain.resolve() + yield* Fiber.join(close) + expect(claude.runs[0]!.received.filter((message) => message.request?.subtype === "interrupt")).toHaveLength(1) + claude.check() + }), + ) + + it.effect("a turn left waiting for tool results stops IDLE_MS after OpenCode stops running the session", () => + Effect.gen(function* () { + const cancel = FakeClaude.replay("tools-waiting-cancel") + const nextUser = cancel.findIndex( + (step, index) => index > 1 && step.type === "exchange" && step.input.type === "user", + ) + const claude = FakeClaude.make(cancel.slice(0, nextUser)) + const test = yield* TestSessions.start(claude) + yield* TestTools.step(test.model, TestTools.prompt()) + yield* test.sessions.idle("session-a") + yield* TestClock.adjust(ClaudeSessions.IDLE_MS) + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("a process exit while OpenCode runs tools resumes with their saved results", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("tools-cancel") + const interrupt = steps.findIndex( + (step) => step.type === "exchange" && step.input.request?.subtype === "interrupt", + ) + // The first process exits instead of receiving an orderly interrupt. Keep the recorded resume. + const claude = FakeClaude.make([...steps.slice(0, interrupt), ...steps.slice(interrupt + 1)]) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), TestTools.output) + yield* Effect.promise(() => claude.runs[0]!.exit(1)) + expect(TestSessions.text(yield* TestTools.step(test.model, prompt))).toContain("CANCELLED-OTTER") + const resumed = claude.runs[1]! + const user = resumed.received.find((message) => message.type === "user") + expect(JSON.stringify(user.message.content)).toContain("LILAC-HERON") + expect(JSON.stringify(user.message.content)).toContain("LEMON-IBIS") + expect(resumed.args.some((arg) => arg.startsWith("--resume="))).toBe(true) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("results that do not answer every waiting call stop the turn and rebuild from the full history", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools-cancel")) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), TestTools.output) + const results = prompt.at(-1)! + if (results.role !== "tool") throw new Error("Expected tool results") + // Drop the second lookup's result, so one waiting call has no answer. + results.content.pop() + prompt.push({ + role: "user", + content: [{ type: "text", text: "Do not call any tools. Reply only with CANCELLED-OTTER." }], + }) + expect(TestSessions.text(yield* TestTools.step(test.model, prompt))).toContain("CANCELLED-OTTER") + expect(claude.runs).toHaveLength(2) + expect(claude.runs[0]!.received.filter((message) => message.request?.subtype === "interrupt")).toHaveLength(1) + const rebuilt = claude.runs[1]! + expect(rebuilt.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const user = JSON.stringify(rebuilt.received.find((message) => message.type === "user").message.content) + expect(user).toContain("") + expect(user).toContain("LILAC-HERON") + expect(user).not.toContain("LEMON-IBIS") + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("active-turn steering delivers tool feedback and new input through the SDK queue", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools-continue")) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), () => ({ + type: "execution-denied", + reason: "Cancelled by user.", + })) + prompt.push({ + role: "user", + content: [ + { + type: "text", + text: "The calls were cancelled. Do not call any more tools. Reply only with CONTINUED-OTTER.", + }, + ], + }) + expect(TestSessions.text(yield* TestTools.step(test.model, prompt)).trim()).toBe("CONTINUED-OTTER") + expect(claude.runs).toHaveLength(1) + const messages = claude.runs[0]!.received + expect(messages.some((message) => message.request?.subtype === "interrupt")).toBe(false) + expect(messages.filter((message) => message.type === "user").at(-1).priority).toBe("next") + expect(messages.filter((message) => message.type === "user").at(-1).message.content).toEqual( + prompt.at(-1)!.content, + ) + const results = messages.filter((message) => message.response?.response?.mcp_response?.result) + expect(results).toHaveLength(2) + for (const message of results) + expect(message.response.response.mcp_response.result).toEqual({ + isError: true, + content: [{ type: "text", text: "Cancelled by user." }], + }) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an aborted tool drains the old turn before sending a fresh user message on the same process", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("tools-abort") + const drain = Promise.withResolvers() + const terminal = steps.findIndex( + (step) => step.type === "exchange" && step.replies.some((message) => message.type === "result"), + ) + const ending = steps[terminal]! + if (ending.type !== "exchange") throw new Error("Missing interrupt result recording") + steps[terminal] = { ...ending, beforeResult: drain.promise } + const claude = FakeClaude.make(steps) + const test = yield* TestSessions.start(claude) + yield* Effect.addFinalizer(() => Effect.sync(() => drain.resolve())) + const prompt = TestTools.prompt() + const first = yield* TestTools.step(test.model, prompt) + TestTools.append(prompt, first, () => ({ type: "execution-denied", reason: "Cancelled by user." }), { + "claude-agent": { toolError: true, toolAborted: true }, + }) + prompt.push({ + role: "user", + content: [ + { + type: "text", + text: "The calls were cancelled. Do not call any more tools. Reply only with CONTINUED-OTTER.", + }, + ], + }) + const next = yield* TestTools.step(test.model, prompt).pipe(Effect.forkChild) + yield* Effect.promise(() => claude.started(steps[terminal]!)) + const messages = claude.runs[0]!.received + // The interrupt receipt is insufficient: no new input until the terminal result flushes. + expect(messages.filter((message) => message.type === "user")).toHaveLength(1) + expect(messages.some((message) => message.response?.response?.mcp_response?.result?.content)).toBe(false) + drain.resolve() + expect(TestSessions.text(yield* Fiber.join(next)).trim()).toBe("CONTINUED-OTTER") + expect(claude.runs).toHaveLength(1) + expect(messages.filter((message) => message.request?.subtype === "interrupt")).toHaveLength(1) + const user = messages.filter((message) => message.type === "user").at(-1) + expect(user.priority).toBeUndefined() + expect(JSON.stringify(user.message.content)).toContain("Cancelled by user.") + expect(user.message.content.at(-1)).toEqual(prompt.at(-1)!.content[0]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("tool images and PDFs reach Claude as image and document content", () => + Effect.gen(function* () { + // Only the first process: the recording goes on to change the tool list. + const steps = FakeClaude.replay("tools-media") + const restart = steps.findIndex((step, index) => index > 0 && step.type === "startup") + const claude = FakeClaude.make(steps.slice(0, restart)) + const test = yield* TestSessions.start(claude) + const tools = [ + { ...TestTools.tools[0]!, name: "attachment", inputSchema: { type: "object" as const, properties: {} } }, + ] + const prompt = TestSessions.prompt("Call attachment once. Report the image color and the marker from its PDF.") + TestTools.append(prompt, yield* TestTools.step(test.model, prompt, tools), () => ({ + type: "text", + value: "Read the attached image and PDF.", + })) + const image = yield* Effect.promise(() => Bun.file(new URL("../fixtures/red.png", import.meta.url)).bytes()) + const pdf = yield* Effect.promise(() => Bun.file(new URL("../fixtures/marker.pdf", import.meta.url)).bytes()) + prompt.push({ + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "image/png", data: image }, + { type: "file", mediaType: "application/pdf", data: pdf }, + ], + }) + yield* TestTools.step(test.model, prompt, tools) + const replacement = claude.runs[0]!.received.find( + (message) => message.response?.response?.hookSpecificOutput?.updatedMCPToolOutput, + ) + expect(replacement.response.response.hookSpecificOutput.updatedMCPToolOutput).toEqual([ + { type: "text", text: "Read the attached image and PDF." }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: Buffer.from(image).toString("base64") }, + }, + { + type: "document", + source: { type: "base64", media_type: "application/pdf", data: Buffer.from(pdf).toString("base64") }, + }, + ]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("a last step without tools stops the parked turn and resumes with the saved result", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("max-steps")) + const test = yield* TestSessions.start(claude) + const lookup = [{ ...TestTools.tools[0]!, inputSchema: { type: "object" as const, properties: {} } }] + const prompt = TestSessions.prompt("Call lookup once, then call it again, then report the marker.") + TestTools.append(prompt, yield* TestTools.step(test.model, prompt, lookup), () => ({ + type: "text", + value: "STEP-OTTER", + })) + prompt.push({ role: "user", content: [{ type: "text", text: "MAXIMUM STEPS REACHED. Respond with text only." }] }) + yield* TestTools.step(test.model, prompt, []) + expect(claude.runs).toHaveLength(2) + const final = claude.runs[1]! + expect(final.args.some((arg) => arg.startsWith("--resume="))).toBe(true) + const init = final.received.find((message) => message.request?.subtype === "initialize").request + expect(init.sdkMcpServers).toBeUndefined() + expect(JSON.stringify(final.received.find((message) => message.type === "user").message.content)).toContain( + "STEP-OTTER", + ) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an unavailable OpenCode server fails closed", () => + Effect.gen(function* () { + const turn = FakeClaude.expectTurn() + const init = turn.replies.find((message) => message.type === "system" && message.subtype === "init") + init.mcp_servers = [{ name: "opencode", status: "failed" }] + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + turn, + FakeClaude.expectInterrupt(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const error = yield* TestTools.step(test.model, TestTools.prompt()).pipe(Effect.flip) + expect(String(error.cause)).toContain("OpenCode tool server did not connect") + expect(claude.runs).toHaveLength(1) + yield* test.registry.close() + claude.check() + }), + ) +}) diff --git a/packages/core/test/plugin/provider/claude-agent/fixtures/marker.pdf b/packages/core/test/plugin/provider/claude-agent/fixtures/marker.pdf new file mode 100644 index 000000000000..a06d95a8156b Binary files /dev/null and b/packages/core/test/plugin/provider/claude-agent/fixtures/marker.pdf differ diff --git a/packages/core/test/plugin/provider/claude-agent/fixtures/recordings/billing-error.jsonl b/packages/core/test/plugin/provider/claude-agent/fixtures/recordings/billing-error.jsonl new file mode 100644 index 000000000000..9a1fbb42735a --- /dev/null +++ b/packages/core/test/plugin/provider/claude-agent/fixtures/recordings/billing-error.jsonl @@ -0,0 +1,11 @@ +{"claudeCode":"2.1.302","sdk":"0.3.301","date":"2026-10-17","note":"Trimmed from a real failed compaction that hit a billing error. It can't be re-recorded on demand."} +{"from":"sdk","message":{"type":"user","uuid":"edadf65f-1bca-4f6d-99cb-95dcab4c0854","message":{"role":"user","content":[{"type":"text","text":"You MUST summarize the conversation above into a structured summary that will be given to another agent to resume the work.\n\nSummarize only what the user and the assistant said and did. Leave out instructions and setup the assistant was given rather than told by the user: repository conventions, instruction files such as AGENTS.md, and environment details like the session ID. The next agent receives current versions of all of these separately.\n\nYou MUST use this format for your response (you may omit sections that aren't applicable). Do not include the
  • - - {(base) => {format().format(base())}} - {format().format(model.requests)} - - - {(base) => {format().format(base())}} - {format().format(model.plusRequests)} - + + {(lane) => ( + + {(base) => {format().format(base())}} + {format().format(lane.requests)} + + )} + - - {(base) => {currency().format(base())}} - {Number.isFinite(model.allowance) ? currency().format(model.allowance) : "∞"} - - - {(base) => {currency().format(base())}} - {Number.isFinite(model.plusAllowance) ? currency().format(model.plusAllowance) : "∞"} - + + {(lane) => ( + + {(base) => {currency().format(base())}} + {Number.isFinite(lane.allowance) ? currency().format(lane.allowance) : "∞"} + + )} +