Compare commits

..
Author SHA1 Message Date
David Hill b436244729 feat(app): add about settings page 2026-08-27 17:56:03 -06:00
Luke Parker 84fa689686 fix(desktop): preserve lazy Sentry loading (#45776) 2026-08-27 23:41:09 +00:00
Kit Langton 62d9aa9838 refactor(core): delegate credential activation (#45692) 2026-08-27 19:39:25 -04:00
Kit Langton 02550b36c7 refactor(util): narrow layer visitor options (#45734) 2026-08-27 19:39:18 -04:00
Kit Langton 35231b408e refactor(core): use keyed MCP credential reads (#45708) 2026-08-27 19:39:12 -04:00
Kit Langton 74f5317489 docs(tui): complete session tabs story guidance (#45712) 2026-08-27 19:39:05 -04:00
Kit Langton 81e099a441 fix(core): preserve benchmark directory argument (#45634) 2026-08-27 19:38:58 -04:00
Luke Parker 498436f225 chore(desktop): upgrade Electron to 42.10.1 (#45775) 2026-08-27 23:36:39 +00:00
opencode-agent[bot]andneriousy b20748f821 fix(cli): prevent repeated updates and npm cache growth (#45091)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 01:30:34 +02:00
Kit Langton f0d5b5e073 refactor(core): remove unused response stream state (#45742) 2026-08-27 19:27:21 -04:00
Kit Langton 4f6c4ddf1c test(core): guarantee execution scope cleanup (#45740) 2026-08-27 19:27:05 -04:00
Kit Langton 792f84b59d refactor(core): reuse execute visibility predicate (#45699) 2026-08-27 19:26:55 -04:00
Kit Langton b4bd14864a refactor(core): remove unused responses state (#45659) 2026-08-27 19:26:48 -04:00
Kit Langton 6bbf2e34e7 refactor(core): parse MCP prompt arguments once (#45646) 2026-08-27 19:26:40 -04:00
Luke Parker d432823515 fix(deps): upgrade diff to 8.0.4 (#45772) 2026-08-27 23:24:33 +00:00
opencode-agent[bot]andneriousy bdf2e84812 feat(core): support Azure CLI authentication (#45086)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-27 23:22:22 +00:00
Kit Langton a438d34fcd chore: lint identity pipe callbacks (#45771) 2026-08-27 19:11:03 -04:00
Kit Langton fa7a1ca39e chore: remove duplicate oxlint options (#45719) 2026-08-27 19:10:55 -04:00
Kit Langton 38e4c97aa9 refactor(core): import filesystem search schemas directly (#45684) 2026-08-27 19:10:50 -04:00
Kit Langton 569a39c4ba refactor(core): preserve Copilot handler inference (#45668) 2026-08-27 19:10:44 -04:00
Kit Langton bfb00cbb75 refactor(core): remove unused chat state (#45664) 2026-08-27 19:10:36 -04:00
Kit Langton a067c45cb6 test(schema): target config color contract (#45626) 2026-08-27 19:10:29 -04:00
Kit Langton 0a7baaaed5 refactor(core): reuse shared record guard (#45643) 2026-08-27 19:06:52 -04:00
Luke Parker 454ed81556 fix(desktop): minify production bundles (#45770) 2026-08-27 23:06:28 +00:00
Kit Langton 7fe8bf5b24 refactor(core): remove duplicate config failure (#45625) 2026-08-27 18:52:36 -04:00
Kit Langton 991a0d05b5 refactor(core): remove identity schema pipes (#45624) 2026-08-27 18:52:20 -04:00
opencode-agent[bot]andneriousy 3288a12528 fix(ai): safely replay unsigned Bedrock reasoning (#45765)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 00:51:07 +02:00
Kit Langton 091396742e refactor(core): isolate tool test fixture state (#45452) 2026-08-27 18:48:44 -04:00
Kit Langton ba85531931 refactor(core): remove redundant coordinator test scopes (#45451) 2026-08-27 18:48:20 -04:00
Kit Langton 0440bd96be refactor(core): simplify Effect test setup (#45446) 2026-08-27 18:47:56 -04:00
Kit Langton ace4249711 refactor(core): name session runner test services (#45445) 2026-08-27 18:47:44 -04:00
Kit Langton 7d007670a0 refactor(core): simplify duplicate test layers (#45444) 2026-08-27 18:47:20 -04:00
Kit Langton 98e3fa9d0d test(core): budget real startup in session revert test (#45762) 2026-08-27 18:43:54 -04:00
opencode-agent[bot]andjlongster 5d4ce14dc4 fix(core): skip tracing streamed LLM deltas (#45761)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-27 17:59:43 -04:00
Simon Klee 8381153418 fix(tui): respect selection copy modes (#45758) 2026-08-27 23:53:07 +02:00
Kit Langton 96d84626f8 fix(core): keep project labels stable across clones (#45735)
Keep shared project labels stable across clones while explicitly using each selected checkout for worktree creation and setup. Preserve real directory rename handling and cover the TUI Home and app workspace creation paths.
2026-08-27 17:35:18 -04:00
Kit Langton f607ca4c72 fix(tui): unify attention indicators with unread accent (#45741)
Use the unread accent color for question and permission status indicators by default, preserving semantic tokens and explicit theme overrides.
2026-08-27 17:15:44 -04:00
Kit Langton 39416a0d95 test(ai): align test runners with API boundaries (#45469) 2026-08-27 17:07:02 -04:00
opencode-agent[bot]andiamdavidhill 84a012a0e9 fix(app): show grouped tool counts inline (#45603)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-08-27 21:01:38 +00:00
opencode-agent[bot]andiamdavidhill 16a0996bd4 fix(ui): replace error icon with updated svg (#45604)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-08-28 06:50:56 +10:00
Kit Langton 60d5f83ffd test(ai): reuse executor HTTP fixtures (#45468) 2026-08-27 16:12:48 -04:00
Kit Langton 1455995ac7 refactor(core): use word casing for MCP namespaces (#45618) 2026-08-27 16:11:50 -04:00
Kit Langton 52c04508a2 test(core): share controlled websearch test layer (#45465) 2026-08-27 16:09:06 -04:00
Kit Langton 5a67fcc17e test(httpapi-codegen): share emitted-module fixtures (#45463) 2026-08-27 15:53:46 -04:00
Kit Langton 73b575468e test(server): simplify scoped endpoint fixtures (#45466) 2026-08-27 15:53:30 -04:00
156 changed files with 5128 additions and 3015 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/theme": patch
---
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": patch
"@opencode-ai/app": patch
---
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
-6
View File
@@ -41,11 +41,5 @@
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+6 -16
View File
@@ -442,7 +442,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "42.3.3",
"electron": "42.10.1",
"electron-builder": "26.15.2",
"electron-vite": "6.0.0-beta.1",
"solid-js": "catalog:",
@@ -1134,7 +1134,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"diff": "8.0.4",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
@@ -1711,6 +1711,8 @@
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
@@ -3221,8 +3223,6 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251207.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251207.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251207.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251207.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251207.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251207.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251207.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251207.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-4QcRnzB0pi9rS0AOvg8kWbmuwHv5X7B2EXHbgcms9+56hsZ8SZrZjNgBJb2rUIodJ4kU5mrkj/xlTTT4r9VcpQ=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251207.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-waWJnuuvkXh4WdpbTjYf7pyahJzx0ycesV2BylyHrE9OxU9FSKcD/cRLQYvbq3YcBSdF7sZwRLDBer7qTeLsYA=="],
@@ -3831,7 +3831,7 @@
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
@@ -3885,7 +3885,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="],
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
"electron-builder": ["electron-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A=="],
@@ -4029,8 +4029,6 @@
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -4055,8 +4053,6 @@
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
@@ -4971,8 +4967,6 @@
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -5881,8 +5875,6 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="],
@@ -6809,8 +6801,6 @@
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"zod-to-ts/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+7 -9
View File
@@ -4,7 +4,7 @@
bun,
nodejs,
darwin,
electron_41,
callPackage,
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
@@ -13,7 +13,7 @@
opencode,
}:
let
electron = electron_41;
electron = callPackage ./electron.nix { };
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
@@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: {
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
# Electron derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
@@ -67,12 +67,10 @@ stdenv.mkDerivation (finalAttrs: {
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
substituteInPlace \
packages/desktop/src/main/windows/appearance.ts \
packages/desktop/src/main/service/desktop-cli.ts \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
'';
preBuild = ''
+13
View File
@@ -0,0 +1,13 @@
{ callPackage, path }:
let
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
in
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
# fetchzip hashes the unpacked headers, not the release tarball.
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
}
+1 -1
View File
@@ -69,7 +69,7 @@
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"diff": "8.0.4",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
@@ -374,6 +374,12 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
@@ -382,7 +388,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
messages.push({ role: "assistant", content })
if (content.length > 0) messages.push({ role: "assistant", content })
continue
}
+7 -9
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
@@ -148,15 +148,13 @@ describe("llm route", () => {
}),
)
it.effect("builds models from configured routes", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
test("builds models from configured routes", () => {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
}),
)
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
})
it.effect("does not register duplicate route ids globally", () =>
Effect.gen(function* () {
+114 -167
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,47 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
@@ -107,19 +66,17 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(cause)
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(cause)
},
),
]),
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
),
),
)
@@ -148,15 +105,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
),
),
)
@@ -176,15 +132,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
),
),
)
@@ -200,7 +155,7 @@ describe("RequestExecutor", () => {
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([]))),
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
)
it.effect("reports the request sent by middleware", () =>
@@ -249,11 +204,9 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
),
),
)
@@ -269,7 +222,7 @@ describe("RequestExecutor", () => {
classification: "payload-too-large",
})
expect(error.reason.http?.status).toBe(413)
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
@@ -285,11 +238,9 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(413)
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
),
),
)
@@ -303,7 +254,7 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -317,15 +268,13 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toContain(largeProviderMessage)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
),
),
)
@@ -340,7 +289,7 @@ describe("RequestExecutor", () => {
_tag: "InvalidRequest",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -352,7 +301,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -369,7 +318,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -399,12 +348,10 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("rate limited")
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
),
),
)
@@ -417,7 +364,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -437,20 +384,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
),
),
)
@@ -470,20 +415,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
),
),
)
@@ -496,10 +439,14 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
),
)
@@ -522,12 +469,10 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(status)
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
),
)
@@ -538,20 +483,28 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("preserves response body fields", () =>
@@ -563,11 +516,9 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
),
),
)
@@ -581,9 +532,7 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
),
),
)
@@ -591,9 +540,7 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
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(
Effect.provide(
dynamicResponse((input) =>
@@ -624,7 +571,7 @@ describe("RequestExecutor", () => {
})
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
+17 -15
View File
@@ -44,21 +44,23 @@ describe("Tool.make (dynamic JSON Schema)", () => {
expect(definition?.inputSchema).toEqual(jsonSchema)
})
test("execute receives the raw input untouched", async () => {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = await Effect.runPromise(tool.execute({ hello: "world" }))
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
})
it.effect("execute receives the raw input untouched", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = yield* tool.execute({ hello: "world" })
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
}),
)
})
describe("LLM.generateObject", () => {
@@ -531,6 +531,104 @@ describe("Bedrock Converse route", () => {
}),
)
for (const signature of [undefined, "", " "]) {
for (const cache of ["none", "auto"] as const) {
it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{
type: "reasoning",
text: "Partial thought",
providerMetadata: signature === undefined ? undefined : { bedrock: { signature } },
cache: new CacheHint({ type: "ephemeral" }),
},
]),
Message.user("Continue"),
],
cache,
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "assistant",
content: [{ text: "Partial thought" }, { cachePoint: { type: "default" } }],
})
}),
)
}
}
it.effect("omits empty unsigned reasoning without leaving an empty or cache-only assistant", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{ type: "reasoning", text: "", cache },
{ type: "reasoning", text: " ", providerMetadata: { bedrock: { signature: "" } }, cache },
]),
Message.user([{ type: "text", text: "Continue", cache }]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ text: "Think" }, { text: "Continue" }, { cachePoint: { type: "default" } }],
},
])
}),
)
it.effect("demotes foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "Foreign thought", providerMetadata: { anthropic: { signature: "old" } } },
{
type: "reasoning",
text: "Signed thought",
providerMetadata: { bedrock: { signature: "sig_1" } },
cache,
},
{ type: "reasoning", text: "", encrypted: "sig_2", cache },
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "cmVkYWN0ZWQ=" } }, cache },
{ type: "text", text: "Checking" },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ text: "Foreign thought" },
{ reasoningContent: { reasoningText: { text: "Signed thought", signature: "sig_1" } } },
{ reasoningContent: { reasoningText: { text: "", signature: "sig_2" } } },
{ reasoningContent: { redactedContent: "cmVkYWN0ZWQ=" } },
{ text: "Checking" },
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
],
},
])
}),
)
it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+10 -12
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
@@ -136,17 +136,15 @@ describe("Cloudflare", () => {
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
}),
)
test("defaults AI Gateway id to default when omitted or blank", () => {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
})
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
@@ -376,11 +376,9 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("rejects tuned Gemini models in express mode", () =>
Effect.sync(() => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
}),
)
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
@@ -31,82 +31,71 @@ import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
test("assigns provider-owned metadata namespaces across native routes", () => {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"), "custom"],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
})
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
test("preserves native Together AI and Cerebras provider and route identities", () => {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
})
it.effect("preserves native DeepInfra provider and route identity", () =>
Effect.gen(function* () {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
}),
)
test("preserves native DeepInfra provider and route identity", () => {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
})
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
@@ -161,39 +150,35 @@ describe("native OpenAI-compatible providers", () => {
}),
)
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
Effect.gen(function* () {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
test("normalizes DeepInfra API roots without duplicating the OpenAI path", () => {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
)
}
}),
)
)
}
})
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
test("maps package settings onto native executable models", () => {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
})
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
@@ -91,40 +91,38 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
test("provides model helpers for compatible provider families", () => {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
})
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
+38 -38
View File
@@ -26,6 +26,7 @@ import {
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
import { it } from "./lib/effect.js"
const model = new LanguageModel({
id: ModelID.make("fake-model"),
@@ -101,49 +102,48 @@ describe("AI.Usage", () => {
expect(ProviderShared.sumTokens()).toBeUndefined()
})
test("sseFraming maps decoder failures to AI errors", async () => {
const error = await Effect.runPromise(
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
Stream.runCollect,
Effect.flip,
),
)
it.effect("sseFraming maps decoder failures to AI errors", () =>
Effect.gen(function* () {
const error = yield* ProviderShared.sseFraming(
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
).pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
it.effect("sseFraming ignores retry directives without ending the stream", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect),
)
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
}),
)
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
it.effect("sseFraming preserves event data around retry directives", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect),
)
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
expect(Array.from(frames)).toEqual(["first\nsecond"])
}),
)
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
@@ -153,18 +153,18 @@ describe("AI.Usage", () => {
})
})
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
it.effect("AI errors expose the shared runtime tag", () =>
Effect.gen(function* () {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(yield* Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))).toBe("caught")
}),
)
test("transport errors serialize execution facts", () => {
const reason = new TransportError({
+67 -73
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
@@ -277,65 +277,61 @@ describe("LLMClient tools", () => {
}),
)
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content)
test("models canonical tool files with URIs", () => {
const decode = Schema.decodeUnknownSync(Content)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
}),
)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
})
it.effect("preserves canonical tool file URIs", () =>
Effect.sync(() => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
test("preserves canonical tool file URIs", () => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
}),
)
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
})
it.effect("settles projected URL files as canonical tool results", () =>
Effect.gen(function* () {
@@ -364,24 +360,22 @@ describe("LLMClient tools", () => {
}),
)
it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
Effect.sync(() => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
test("derives typed output schemas and preserves dynamic output schemas", () => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
}),
)
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
})
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
+37 -43
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { AIError } from "../src/schema/index.js"
import { ToolStream } from "../src/protocols/utils/tool-stream.js"
@@ -38,44 +38,40 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
test("exposes cumulative partial string values", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
})
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
})
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
@@ -104,14 +100,12 @@ describe("ToolStream", () => {
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
test("fails appendExisting when the provider skipped the tool start", () => {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
}),
)
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
})
it.effect("uses final input override without losing accumulated deltas", () =>
Effect.gen(function* () {
@@ -25,6 +25,7 @@ for (const viewport of [
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await testInfo.attach("creating-worktree", {
@@ -184,6 +185,7 @@ test("restores the draft after closing and revisiting a pending session that fai
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
@@ -216,7 +218,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
if (path === `/api/worktree/${projectID}`) {
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
@@ -274,7 +279,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
await expect(thinking).toHaveCount(0)
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/Used\s*Read, Grep/,
/^Used\s*2 Read, Grep$/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
])
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used Shell" })
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
@@ -93,7 +93,8 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
await expect(summary).toHaveAccessibleName("Used 1 Patch")
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
@@ -109,7 +110,8 @@ test.describe("regression: session timeline local row state", () => {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -28,7 +28,7 @@ for (const expanded of [false, true]) {
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used Shell" })
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -141,7 +141,7 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used Shell", exact: true })
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
@@ -150,7 +150,7 @@ for (const open of [false, true]) {
"aria-expanded",
String(open),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
@@ -16,8 +16,9 @@ for (const locale of ["de", "ar"] as const) {
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -105,10 +105,10 @@ for (const summaries of [false, true]) {
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toContainText("UsedSkill")
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
@@ -45,10 +45,11 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
@@ -74,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
@@ -113,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
@@ -128,7 +129,10 @@ test("combines follow-up patches into one three-file stack inside Used", async (
),
),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
@@ -158,8 +162,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -1,5 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Session } from "@opencode-ai/schema/session"
import { startTransition } from "solid-js"
@@ -13,6 +12,7 @@ import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { createWorktree } from "@/workspaces/create"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
@@ -192,25 +192,17 @@ async function resolveSessionDirectory(input: {
if (input.worktree === "main") return input.projectDirectory
if (input.worktree !== "create") return input.worktree
return input.serverSDK.api.worktree
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
})
.then(async (created) => {
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
})
.catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
return createWorktree({
api: input.serverSDK.api,
directory: input.projectDirectory,
project: input.data.location.info({ directory: input.projectDirectory })?.project,
branch: input.branch,
}).catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
})
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
+17
View File
@@ -892,6 +892,23 @@ export const dict = {
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.tab.about": "About",
"settings.about.version": "Version {{version}}",
"settings.about.devVersion": "development",
"settings.about.license": "Released under the MIT License",
"settings.about.writtenBy": "Written by",
"settings.about.illustratedBy": "Illustrated by",
"settings.about.and": "and",
"settings.about.otherContributor.one": "{{count}} other",
"settings.about.otherContributor.other": "{{count}} others",
"settings.about.firstPublished": "First published in Missouri, USA",
"settings.about.firstIllustrated": "First illustrated in London, England",
"settings.about.website": "www.opencode.ai",
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.tagline": "AI cant build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
@@ -1,6 +1,6 @@
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
@@ -11,6 +11,7 @@ import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
import { createWorktree } from "@/workspaces/create"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
@@ -55,7 +56,14 @@ export function SessionWorkspaceMenu(props: {
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
const destination =
selection === "create"
? await createWorktree({
api: sdk.api,
directory: props.directory,
project: data.location.info({ directory: props.directory })?.project,
})
: selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
@@ -124,13 +132,3 @@ export function SessionWorkspaceMenu(props: {
</Menu>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
+123
View File
@@ -0,0 +1,123 @@
import { For, createResource, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ExternalLink } from "@/runtime/platform/external-link"
import legal from "./legal.svg"
import anomalyBrush from "./anomaly-brush.svg"
import { AnimatedWordmark } from "./animated-wordmark"
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
const writers = [
"thdxr",
"adamdotdev",
"rekram1-node",
"kitlangton",
"iamdavidhill",
"jayair",
"fwang",
"brendonovich",
"nexxeln",
"hona",
"kommander",
"jlongster",
"vimtor",
"r44vcorp",
"simonklee",
"arvsrn",
] as const
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
export function SettingsAbout(props: { active: boolean }) {
const language = useLanguage()
const platform = usePlatform()
const [otherContributors] = createResource(
() => props.active || undefined,
() => loadOtherContributorCount(platform.fetch ?? fetch),
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
)
return (
<div class="settings-about-content">
<div class="settings-about-intro">
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
<p>{language.t("settings.about.license")}</p>
</div>
<AnimatedWordmark active={props.active} />
<div class="settings-about-credits">
<CreditLine
label={language.t("settings.about.writtenBy")}
names={writers}
and={language.t("settings.about.and")}
tail={
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
{language.plural("settings.about.otherContributor", otherContributors(), {
count: otherContributors(),
})}
</ExternalLink>
}
/>
<CreditLine
label={language.t("settings.about.illustratedBy")}
names={illustrators}
and={language.t("settings.about.and")}
/>
</div>
<div class="settings-about-publication">
<p>{language.t("settings.about.firstPublished")}</p>
<p>{language.t("settings.about.firstIllustrated")}</p>
</div>
<p class="settings-about-faint">
<ExternalLink href="https://opencode.ai">
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
</ExternalLink>
</p>
<div class="settings-about-details">
<p>{language.t("settings.about.description")}</p>
<p>{language.t("settings.about.trademark")}</p>
<p>{language.t("settings.about.typeset")}</p>
</div>
<p>{language.t("settings.about.tagline")}</p>
<img class="settings-about-legal" src={legal} alt="" />
<div class="settings-about-copyright">
<p>{language.t("settings.about.copyright")}</p>
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
</div>
</div>
)
}
function CreditLine(props: {
label: string
names: readonly string[]
and: string
tail?: JSX.Element
}) {
return (
<p>
{props.label}{" "}
<For each={props.names}>
{(name, index) => (
<>
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
<bdi dir="ltr">
<ExternalLink href={profile(name)}>{name}</ExternalLink>
</bdi>
</>
)}
</For>
{props.tail ? <>, {props.and} {props.tail}</> : null}
</p>
)
}
function profile(name: string) {
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
return `https://github.com/${name}`
}
@@ -0,0 +1,80 @@
import { createEffect, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
const choices = ["o", "p", "e", "n", "c", "d"] as const
export function AnimatedWordmark(props: { active: boolean }) {
const [state, setState] = createStore({ letters: [...target] })
const timers = new Set<ReturnType<typeof setTimeout>>()
createEffect(
on(
() => props.active,
(active) => {
timers.forEach(clearTimeout)
timers.clear()
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
setState("letters", [...target])
return
}
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
const last = Math.max(...settles)
setState("letters", starts)
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
const timer = setTimeout(() => {
setState(
"letters",
target.map((letter, index) =>
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
),
)
timers.delete(timer)
}, tick * 75)
timers.add(timer)
})
},
),
)
onCleanup(() => timers.forEach(clearTimeout))
return (
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
<defs>
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
</symbol>
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
</symbol>
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
</symbol>
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
</symbol>
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
</symbol>
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
</symbol>
</defs>
<For each={state.letters}>
{(letter, index) => (
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
)}
</For>
</svg>
)
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
describe("otherContributorCount", () => {
test("subtracts the contributors named in the colophon", () => {
expect(
otherContributorCount(
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
),
).toBe(988)
})
test("falls back when pagination metadata is unavailable", () => {
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
})
})
@@ -0,0 +1,23 @@
const CREDITED_CONTRIBUTORS = 16
let request: Promise<number> | undefined
export const FALLBACK_OTHER_CONTRIBUTORS = 935
export function otherContributorCount(link: string | null) {
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
return total - CREDITED_CONTRIBUTORS
}
export function loadOtherContributorCount(fetcher: typeof fetch) {
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}).then(
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
() => FALLBACK_OTHER_CONTRIBUTORS,
)
return request
}
@@ -0,0 +1,9 @@
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity=".5" fill="#808080">
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+96
View File
@@ -74,6 +74,102 @@
user-select: none;
}
.settings-about {
align-items: center;
padding-inline: 24px;
}
.settings-about-content {
display: flex;
width: 100%;
max-width: 560px;
min-height: 462px;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 24px;
padding-block: 80px 32px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
text-align: center;
}
.settings-about-content p {
margin: 0;
}
.settings-about-intro,
.settings-about-credits,
.settings-about-publication,
.settings-about-details {
display: flex;
flex-direction: column;
}
.settings-about-intro {
gap: 14px;
}
.settings-about-credits,
.settings-about-publication,
.settings-about-details {
gap: 16px;
}
.settings-about-wordmark {
width: 178px;
height: 32px;
flex-shrink: 0;
opacity: 0.2;
color: var(--v2-text-text-muted);
fill: currentColor;
}
.settings-about-letter-shadow {
opacity: 0.3;
}
.settings-about-faint,
.settings-about-credits {
color: var(--v2-text-text-faint);
}
.settings-about-legal {
width: 109.2px;
height: 19.2px;
}
.settings-about-copyright {
display: flex;
width: 100%;
flex-shrink: 0;
flex-direction: column;
align-items: center;
gap: 12px;
}
.settings-about-anomaly-brush {
width: 126.72px;
height: 48.384px;
flex-shrink: 0;
pointer-events: none;
}
.settings-about-content a {
color: inherit;
cursor: pointer;
text-decoration: none;
}
.settings-about-content a:hover,
.settings-about-content a:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.settings-back {
display: flex;
height: 28px;
+19
View File
@@ -13,6 +13,7 @@ import { SettingsServers } from "./servers/servers"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsAbout } from "./about/about"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/shell/state/layout"
@@ -161,6 +162,21 @@ export const SettingsScreen: Component<{
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
{/* Group 4: About */}
<div class="flex flex-col gap-1 w-full">
<Tabs.Trigger value="about">
<svg data-slot="icon-svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<g transform="translate(2 2)">
<path
d="M12 12H0V0H12V12ZM1 1V11H11V1H1ZM6.5791 4.81641V9.37207H5.5791V5.81641H4.46777V4.81641H6.5791ZM6.85645 2.62891V3.62891H5.30078V2.62891H6.85645Z"
fill="currentColor"
/>
</g>
</svg>
{language.t("settings.tab.about")}
</Tabs.Trigger>
</div>
</div>
</div>
<div class="settings-nav-footer">
@@ -203,6 +219,9 @@ export const SettingsScreen: Component<{
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={tab() === "about"} />
</Tabs.Content>
</Tabs>
</div>
)
+10 -4
View File
@@ -1,5 +1,5 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { captureException, isEnabled } from "@sentry/solid"
import type { captureException } from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, onMount, Show } from "solid-js"
@@ -227,6 +227,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
actionError: undefined as string | undefined,
captureException: undefined as typeof captureException | undefined,
})
function ensureFatalErrorRecorded() {
@@ -243,6 +244,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
onMount(() => {
void ensureFatalErrorRecorded().catch(() => undefined)
void import("@sentry/solid")
.then(({ captureException, isEnabled }) => {
if (isEnabled()) setStore("captureException", () => captureException)
})
.catch(() => undefined)
})
async function checkForUpdates() {
@@ -311,15 +317,15 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
{language.t("error.page.action.exportLogs")}
</Button>
</Show>
<Show when={isEnabled}>
{(_) => {
<Show when={store.captureException}>
{(capture) => {
const [reported, setReported] = createSignal(false)
return (
<Button
size="large"
disabled={reported()}
onClick={() => {
captureException(props.error)
capture()(props.error)
setReported(true)
}}
>
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "POST") return Response.json({ directory: "/created" })
return Response.json({ directory: new URL(request.url).searchParams.get("location[directory]"), project })
},
{ preconnect() {} },
),
})
expect(
await createWorktree({
api,
directory: input.directory,
project: input.cached ? project : undefined,
branch: "clone-only",
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
.filter((request) => request.method === "GET")
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
})
test("does not fall back to a shared project when location lookup fails", async () => {
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(new Request(input, init))
return Response.json({ message: "unavailable" }, { status: 503 })
},
{ preconnect() {} },
),
})
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status: 503 },
})
expect(requests.map((request) => request.method)).toEqual(["GET"])
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
directory: string
project?: LocationGetOutput["project"]
branch?: string
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
projectID: project.id,
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
await input.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -0,0 +1,58 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, FileSystem, Layer, Stream } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import assert from "node:assert/strict"
import path from "node:path"
import { Updater } from "../updater"
const latest = { version: "0.0.0-beta-17498" }
const installs: string[] = []
// This fixture runs in its own process; no real update requests or installs occur.
globalThis.fetch = Object.assign(async () => Response.json(latest), { preconnect() {} })
await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const dependencies = Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, fs),
Layer.succeed(
Global.Service,
Global.make({ home: directory, config: directory, cache: path.join(directory, "cache") }),
),
Layer.succeed(AppProcess.Service, {
...ChildProcessSpawner.make(() => Effect.die("Unexpected process spawn")),
runStream: () => Stream.die("Unexpected streaming process"),
run: (command) => {
assert.equal(command._tag, "StandardCommand")
if (command.command === "npm" && command.args[0] === "install") {
assert.ok(command.args.includes("--global"))
installs.push(command.args.at(-1)!)
}
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.from(command.command === "npm" ? "@opencode-ai/cli" : ""),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
})
},
}),
)
yield* Effect.gen(function* () {
const updater = yield* Updater.Service
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
yield* updater.check()
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
latest.version = "0.0.0-beta-17499"
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498", "@opencode-ai/cli@0.0.0-beta-17499"])
}).pipe(Effect.provide(Updater.layer.pipe(Layer.provide(dependencies))))
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)
+2 -2
View File
@@ -5,9 +5,9 @@ const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
export function action(current: string, latest: string, policy: Policy, installed = current): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const currentVersion = parseReleaseVersion(installed)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
+30
View File
@@ -1,8 +1,30 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("remembers successful installs across checks and accepts the next release", async () => {
// Isolate compiled version constants and the update endpoint from other tests.
const child = Bun.spawn(
[
process.execPath,
"--define",
'OPENCODE_VERSION="0.0.0-next-16473"',
"--define",
'OPENCODE_CHANNEL="beta"',
path.join(import.meta.dir, "fixtures/updater.ts"),
],
{ env: { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "" }, stdout: "pipe", stderr: "pipe" },
)
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(code, stdout + stderr).toBe(0)
})
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
@@ -32,6 +54,14 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
})
test("skips an installed update but still accepts the next release", () => {
const current = "0.0.0-next-16473"
const installed = "0.0.0-beta-17498"
expect(action(current, installed, true)).toBe("upgrade")
expect(action(current, installed, true, installed)).toBe("none")
expect(action(current, "0.0.0-beta-17499", true, installed)).toBe("upgrade")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
+15 -11
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -38,6 +38,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installed = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
@@ -112,18 +113,20 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
const commands: Record<Exclude<Method, "bun" | "curl" | "npm">, string[]> = {
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
if (method === "bun" || method === "npm") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
const command =
method === "bun"
? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target]
: ["npm", "install", "--global", "--cache", cache, target]
return yield* run(command, "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
@@ -156,18 +159,19 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const version = yield* latest()
const current = yield* Ref.get(installed)
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(OPENCODE_VERSION, version, policy, current)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installed, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
})
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
+3 -1
View File
@@ -12,7 +12,9 @@ import { AbsolutePath } from "../src/schema"
const args = process.argv.slice(2)
const iterationsIndex = args.indexOf("--iterations")
const iterations = iterationsIndex === -1 ? 10 : Number(args[iterationsIndex + 1])
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== iterationsIndex + 1) ?? process.cwd()
const directory =
args.find((arg, index) => !arg.startsWith("--") && (iterationsIndex === -1 || index !== iterationsIndex + 1)) ??
process.cwd()
if (!Number.isInteger(iterations) || iterations < 1) {
console.error("--iterations must be a positive integer")
-2
View File
@@ -81,8 +81,6 @@ export const testLayer = (initial: Entry[] = []) =>
Effect.gen(function* () {
const current = yield* Ref.get(entries)
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
if (index === -1)
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const entry = current[index]
if (!entry || entry.type !== "document")
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
+1 -4
View File
@@ -1,6 +1,7 @@
export * as ConfigNormalize from "./normalize.js"
import { isDeepStrictEqual } from "node:util"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Option, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
@@ -782,10 +783,6 @@ function isEnabledOnlyMcp(value: unknown) {
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false
const prototype = Object.getPrototypeOf(value)
@@ -87,6 +87,7 @@ export const Plugin = define({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
@@ -151,6 +152,7 @@ function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
+5 -5
View File
@@ -1,11 +1,11 @@
export * as ConfigMCPPlugin from "./mcp.js"
export * as ConfigMcpPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import type { ServerConfig } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { MCP } from "../../mcp/index.js"
import { Mcp } from "../../mcp/index.js"
export const Plugin = define({
id: "opencode.config.mcp",
@@ -18,7 +18,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
events: Stream.Stream<{ readonly type: string }, unknown>,
) {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const loaded = { entries: [] as Entry[] }
yield* events.pipe(
@@ -43,7 +43,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, Mcp.ServerConfig>()
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "path"
import { Clock, Context, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "../location.js"
import { Ripgrep } from "../ripgrep.js"
import { RelativePath } from "../schema.js"
@@ -330,7 +330,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
const toolCalls: Array<{
id: string
type: "function"
function: {
name: string
arguments: string
@@ -560,7 +559,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
toolCalls[index] = {
id: toolCallDelta.id,
type: "function",
function: {
name: toolCallDelta.function.name,
arguments: toolCallDelta.function.arguments ?? "",
@@ -1,9 +1,4 @@
import {
type LanguageModelV3Prompt,
type LanguageModelV3ToolCallPart,
type SharedV3Warning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { type LanguageModelV3Prompt, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types.js"
@@ -116,7 +111,6 @@ export async function convertToOpenAIResponsesInput({
case "assistant": {
const reasoningMessages: Record<string, OpenAIResponsesReasoning> = {}
const toolCallParts: Record<string, LanguageModelV3ToolCallPart> = {}
for (const part of content) {
switch (part.type) {
@@ -129,8 +123,6 @@ export async function convertToOpenAIResponsesInput({
break
}
case "tool-call": {
toolCallParts[part.toolCallId] = part
if (part.providerExecuted) {
break
}
@@ -16,7 +16,7 @@ export const openaiErrorDataSchema = z.object({
export type OpenAIErrorData = z.infer<typeof openaiErrorDataSchema>
export const openaiFailedResponseHandler: any = createJsonErrorResponseHandler({
export const openaiFailedResponseHandler = createJsonErrorResponseHandler({
errorSchema: openaiErrorDataSchema,
errorToMessage: (data) => data.error.message,
})
@@ -792,7 +792,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
const ongoingToolCalls: Record<
number,
| {
toolName: string
toolCallId: string
codeInterpreter?: {
containerId: string
@@ -851,7 +850,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
if (isResponseOutputItemAddedChunk(value)) {
if (value.item.type === "function_call") {
ongoingToolCalls[value.output_index] = {
toolName: value.item.name,
toolCallId: value.item.call_id,
}
@@ -862,7 +860,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
})
} else if (value.item.type === "web_search_call") {
ongoingToolCalls[value.output_index] = {
toolName: webSearchToolName ?? "web_search",
toolCallId: value.item.id,
}
@@ -873,7 +870,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
})
} else if (value.item.type === "computer_call") {
ongoingToolCalls[value.output_index] = {
toolName: "computer_use",
toolCallId: value.item.id,
}
@@ -884,7 +880,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
})
} else if (value.item.type === "code_interpreter_call") {
ongoingToolCalls[value.output_index] = {
toolName: "code_interpreter",
toolCallId: value.item.id,
codeInterpreter: {
containerId: value.item.container_id,
+1 -10
View File
@@ -719,16 +719,7 @@ const layer = Layer.effect(
}),
})
}),
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
if (!credential) return
const active = resolveConnections(
state.get().integrations.get(credential.integrationID),
yield* credentials.list(credential.integrationID),
)[0]
if (active?.type === "credential" && active.id === credentialID) return
yield* credentials.activate(credentialID)
}),
activate: Effect.fn("Integration.connection.activate")((credentialID) => credentials.activate(credentialID)),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
+2 -2
View File
@@ -23,7 +23,7 @@ import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
@@ -84,7 +84,7 @@ const locationServiceNodes = [
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
Mcp.node,
Permission.node,
Tool.node,
ToolOutput.node,
+3 -3
View File
@@ -1,4 +1,4 @@
export * as MCPClient from "./client.js"
export * as McpClient from "./client.js"
import path from "node:path"
import { pathToFileURL } from "node:url"
@@ -29,7 +29,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
@@ -222,7 +222,7 @@ export const connect = Effect.fnUntraced(function* (
const exit = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server,
command,
args,
+32 -34
View File
@@ -1,4 +1,4 @@
export * as MCP from "./index.js"
export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
@@ -16,7 +16,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
import { Location } from "../location.js"
import { waitForAbort } from "@opencode-ai/util/process"
import { State } from "../state.js"
import type { MCPClient } from "./client.js"
import type { McpClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
@@ -114,7 +114,7 @@ type ServerEntry = {
status: Status
readonly startup: Latch.Latch
scope?: Scope.Closeable
client?: MCPClient.Connection
client?: McpClient.Connection
tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
@@ -235,8 +235,8 @@ export const layer = (options?: Options) =>
method: { id: methodID, type: "oauth", label: name },
authorize: () =>
Effect.gen(function* () {
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* McpOAuth.authorize({ name, config: remote, methodID })
}),
})
})
@@ -254,7 +254,7 @@ export const layer = (options?: Options) =>
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const base = {
@@ -270,19 +270,17 @@ export const layer = (options?: Options) =>
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
const integrationID = entry.integrationID
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await Effect.runPromise(credentials.list(integrationID))
const match = stored.find((credential) => credential.id === credentialID)
return match && match.value.type === "oauth" ? match.value : undefined
const stored = await Effect.runPromise(credentials.get(credentialID))
return stored?.value.type === "oauth" ? stored.value : undefined
}
return MCPOAuth.provider({
return McpOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
@@ -303,22 +301,22 @@ export const layer = (options?: Options) =>
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return MCPOAuth.toTokens(oauth)
return McpOAuth.toTokens(oauth)
},
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
const value = MCPOAuth.toCredential({
const value = McpOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
},
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
@@ -330,7 +328,7 @@ export const layer = (options?: Options) =>
const elicitation = {
create: (input: {
readonly server: string
readonly params: MCPClient.ElicitationParams
readonly params: McpClient.ElicitationParams
readonly signal: AbortSignal
}) =>
Effect.gen(function* () {
@@ -355,7 +353,7 @@ export const layer = (options?: Options) =>
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.map(
(state): MCPClient.ElicitationResult => ({
(state): McpClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
}),
),
@@ -375,13 +373,13 @@ export const layer = (options?: Options) =>
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.map((state): MCPClient.ElicitationResult => {
Effect.map((state): McpClient.ElicitationResult => {
if (state.status !== "answered") return { action: "cancel" }
return {
action: "accept",
content: Object.fromEntries(
Object.entries(state.answer).map(
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
typeof value === "object" ? [key, Array.from(value)] : [key, value],
),
),
@@ -395,9 +393,9 @@ export const layer = (options?: Options) =>
if (!formID) return
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
} satisfies McpClient.ElicitationHandler
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
new Tool({
server,
name: def.name,
@@ -407,7 +405,7 @@ export const layer = (options?: Options) =>
outputSchema: def.outputSchema,
})
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
@@ -422,7 +420,7 @@ export const layer = (options?: Options) =>
),
})
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
@@ -431,7 +429,7 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
@@ -440,14 +438,14 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, entry, def))
}),
)
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.prompts().pipe(
Effect.orElseSucceed(() => []),
Effect.map((defs) => {
@@ -459,7 +457,7 @@ export const layer = (options?: Options) =>
// Runs a connection callback under the server lock, dropping it if the connection is no longer
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
const whenLive =
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
<E>(effect: Effect.Effect<void, E>) =>
fork(
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
@@ -468,7 +466,7 @@ export const layer = (options?: Options) =>
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
live(
@@ -491,7 +489,7 @@ export const layer = (options?: Options) =>
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
}
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
const fields = { server, logger: message.logger, level: message.level, data: message.data }
switch (message.level) {
case "debug":
@@ -518,10 +516,10 @@ export const layer = (options?: Options) =>
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(
const result = yield* McpClient.connect(
name,
entry.config,
location.directory,
@@ -555,7 +553,7 @@ export const layer = (options?: Options) =>
entry.scope = undefined
const error = Cause.squash(result.cause)
entry.status =
error instanceof MCPClient.NeedsAuthError
error instanceof McpClient.NeedsAuthError
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
@@ -938,4 +936,4 @@ function toElicitationField(key: string, property: ElicitationProperty, required
}
}
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
+3 -3
View File
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { McpTool } from "../tool/mcp.js"
import { MCP } from "./index.js"
import { Mcp } from "./index.js"
import { Instructions } from "../instructions/index.js"
const Summary = Schema.Struct({
@@ -63,7 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return Service.of({
load: Effect.fn("McpInstructions.load")(function* (selection) {
@@ -110,4 +110,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPOAuth from "./oauth.js"
export * as McpOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPStdio from "./stdio.js"
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
+2 -2
View File
@@ -13,7 +13,7 @@ import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
@@ -197,7 +197,7 @@ export const node = makeLocationNode({
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
Location.node,
Reference.node,
Skill.node,
+13 -17
View File
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,20 +12,18 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
const loaded = { prompts: [] as Mcp.Prompt[] }
yield* bus.subscribe(Mcp.PromptsChanged).pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
Effect.forkScoped({ startImmediately: true }),
)
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
@@ -60,14 +58,12 @@ export const Plugin = define({
description: prompt.description,
execute: (input) =>
Effect.gen(function* () {
const args = parseArguments(input.prompt.text)
const result = yield* mcp.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.prompt.text)[index] ?? "",
]),
(prompt.arguments ?? []).map((argument, index) => [argument.name, args[index] ?? ""]),
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
+4 -4
View File
@@ -3,7 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -16,7 +16,7 @@ import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
@@ -41,7 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const skill = yield* Skill.Service
@@ -295,7 +295,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
+8 -8
View File
@@ -18,7 +18,7 @@ import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
@@ -44,7 +44,7 @@ import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
@@ -78,7 +78,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -114,7 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -158,7 +158,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Mcp.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -209,7 +209,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Mcp.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -234,8 +234,8 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
VcsGitPlugin.Plugin,
AgentPlugin.Plugin,
@@ -1,4 +1,4 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
export * as McpCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
+263 -16
View File
@@ -1,11 +1,49 @@
import { Effect } from "effect"
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Form } from "@opencode-ai/schema/form"
import { AppProcess } from "@opencode-ai/util/process"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import { configuredSettings } from "./configured.js"
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeProfile = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
)
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
accessToken: Schema.NonEmptyString,
expires_on: Schema.optional(Schema.Number),
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
const decodeAccounts = Schema.decodeUnknownEffect(
Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })),
)
const Deployments = Schema.Array(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.NonEmptyString,
}),
}),
)
const decodeDeployments = Schema.decodeUnknownEffect(Deployments)
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
if (useChat && sdk.chat) return sdk.chat(modelID)
if (sdk.responses) return sdk.responses(modelID)
@@ -18,33 +56,185 @@ export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const form = iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
const available = Boolean(which("az"))
// Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands.
const signedIn = available
? yield* Effect.tryPromise(() =>
readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"),
).pipe(
Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))),
Effect.map((profile) => profile.subscriptions.length > 0),
Effect.orElseSucceed(() => false),
)
: false
const accounts =
!resolveResourceName(configured) &&
typeof configured?.baseURL !== "string" &&
!process.env.AZURE_RESOURCE_GROUP &&
signedIn
? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
Effect.orElseSucceed(() => []),
)
: []
const form = (select = false) =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
...(select && accounts.length > 0
? {
options: accounts.map((account) => ({
value: account.name,
label: account.name,
description: account.resourceGroup,
})),
custom: true,
}
: {}),
},
])
})
yield* ctx.integration.transform((draft) => {
draft.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
draft.method.update({
integrationID: Provider.ID.azure,
method: {
type: "key",
label: "API key",
form,
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(true),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
loaded.deployments = yield* discover(resourceName)
yield* ctx.catalog.reload()
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
),
),
})
})
const discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) {
return yield* Effect.gen(function* () {
const group = process.env.AZURE_RESOURCE_GROUP
const account = group
? { name: resource, resourceGroup: group }
: (accounts.length > 0
? accounts
: yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
)
).find((item) => item.name.toLowerCase() === resource.toLowerCase())
if (!account)
return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`))
return yield* command([
"cognitiveservices",
"account",
"deployment",
"list",
"--name",
account.name,
"--resource-group",
account.resourceGroup,
"--output",
"json",
"--only-show-errors",
]).pipe(Effect.flatMap(decodeDeployments))
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Azure model discovery failed", {
resource,
error: error instanceof Error ? error.message : String(error),
}).pipe(Effect.as(undefined)),
),
)
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
loaded.deployments = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
loaded.deployments = resource ? yield* discover(resource) : undefined
})
yield* load()
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")
continue
const resourceName = resolveResourceName(item.provider.settings)
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
if (resourceName)
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
@@ -55,6 +245,31 @@ export const AzurePlugin = define({
: {}),
}
})
if (item.provider.id === Provider.ID.azure && loaded.deployments) {
// Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot.
const existing = Array.from(item.models.values())
const found = new Map<Model.ID, Model.Info>()
loaded.deployments.forEach((deployment) => {
if (deployment.properties.provisioningState !== "Succeeded") return
const model = existing.find(
(model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(),
)
if (!model) return
const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id
found.set(id, {
...model,
id,
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
modelID: Model.ID.make(deployment.name),
})
})
for (const id of Array.from(item.models.keys())) {
if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of found) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
@@ -67,6 +282,38 @@ export const AzurePlugin = define({
}
}
})
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const url = new URL(evt.request.url)
const scope =
url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", `Bearer ${current.access}`)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
+3 -3
View File
@@ -6,7 +6,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Session } from "../session.js"
export interface Interface {
@@ -38,7 +38,7 @@ export interface Interface {
readonly mcp: {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Mcp.ServerInfo[] }, unknown>
}
}
}
@@ -130,7 +130,7 @@ export const providerLayerWithCell = (cell: Cell) =>
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
+10 -3
View File
@@ -41,6 +41,7 @@ export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
@@ -110,11 +111,17 @@ const layer = Layer.effect(
.get()
.pipe(Effect.orDie)
yield* upsertProject(db, project).pipe(Effect.orDie)
if (previous && previous.canonical !== project.canonical) {
// Clones share a project ID; only replace a canonical directory that is gone.
if (
previous &&
previous.canonical !== project.canonical &&
!(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true)))
) {
const row = yield* db
.select()
.from(ProjectTable)
.update(ProjectTable)
.set({ worktree: project.canonical })
.where(eq(ProjectTable.id, project.id))
.returning()
.get()
.pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
+2 -5
View File
@@ -51,11 +51,8 @@ export function upsertProject(
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
set: { vcs: vcs ?? null },
setWhere: vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
})
.run()
}
@@ -378,7 +378,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
)
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
const publish = Effect.fnUntraced(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
yield* startAssistant()
@@ -535,6 +535,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
})
const publishTraced = Effect.fn("SessionRunner.publishLLMEvent")(publish)
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
@@ -573,7 +575,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return {
publish,
publish: (event: LLMEvent) => {
if (event.type === "text-delta" || event.type === "reasoning-delta" || event.type === "tool-input-delta")
return publish(event)
return publishTraced(event)
},
progress,
toolExecution,
flush,
+1 -2
View File
@@ -211,8 +211,7 @@ const layer = Layer.effect(
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeEnabled = !whollyDisabled("execute", rules)
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
+4 -4
View File
@@ -6,7 +6,7 @@ import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } fro
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Permission } from "../permission.js"
import { Tool } from "../tool.js"
@@ -26,12 +26,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const lock = Semaphore.makeUnsafe(1)
let discovered: MCP.Tool[] = []
let discovered: Mcp.Tool[] = []
// Register once after initial discovery; only subsequent updates need a debounced reload.
const initial = yield* lock
@@ -134,5 +134,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
deps: [Tool.node, Mcp.node, Bus.node, Permission.node],
})
+5 -5
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
}).pipe((schema) => schema)
})
export const Entry = Schema.Union([
Disabled,
@@ -15,7 +15,7 @@ export const Entry = Schema.Union([
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
]).pipe((schema) => schema)
])
// Keep this list aligned with the builtin servers in opencode's LSP runtime.
// Custom servers must declare extensions because the runtime cannot infer them.
@@ -73,8 +73,8 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter<
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
})
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
.check(requiresExtensionsForCustomServers)
.pipe((schema) => schema)
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).check(
requiresExtensionsForCustomServers,
)
export type Info = Schema.Schema.Type<typeof Info>
+3 -3
View File
@@ -267,20 +267,20 @@ const layer = Layer.effect(
}),
)
const project = yield* db
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
if (command && project) {
if (command) {
const windows = process.platform === "win32"
yield* processService
.run(
ChildProcess.make(windows ? command : "bash", windows ? [] : ["-lc", command], {
cwd: result.directory,
env: {
OPENCODE_WORKTREE_BASE: project.worktree,
OPENCODE_WORKTREE_BASE: sourceDirectory,
OPENCODE_WORKTREE_PATH: result.directory,
},
extendEnv: true,
+3 -5
View File
@@ -19,7 +19,7 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@@ -43,7 +43,7 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
[MCP.node, emptyMcpLayer],
[Mcp.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
@@ -431,9 +431,7 @@ function sourceCases() {
{
name: "updated",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first"))),
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
+1 -1
View File
@@ -22,7 +22,7 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
[Environment.node, transformEnvironmentFiles(transformFiles)],
]),
)
}
+3 -7
View File
@@ -1,6 +1,4 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer } from "effect"
@@ -40,10 +38,8 @@ export const recordingEnvironmentLayer = (spawns: Array<ChildProcess.Command>) =
export type EnvironmentFilesTransform = (files: Environment.Files) => Partial<Environment.Files>
export function transformEnvironmentFiles(
location: Layer.Layer<Location.Service>,
transform: EnvironmentFilesTransform = () => ({}),
) {
// Wrap real host filesystem operations without constructing workspace services.
export function transformEnvironmentFiles(transform: EnvironmentFilesTransform = () => ({})) {
return Layer.effect(
Environment.Service,
Effect.gen(function* () {
@@ -53,5 +49,5 @@ export function transformEnvironmentFiles(
files: { ...current.files, ...transform(current.files) },
})
}),
).pipe(Layer.provide(AppNodeBuilder.build(Environment.node, [[Location.node, location]])))
).pipe(Layer.provide(hostEnvironmentLayer))
}
+4 -4
View File
@@ -1,13 +1,13 @@
import { Effect, Layer, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./location"
export const emptyMcpLayer = Layer.succeed(
MCP.Service,
MCP.Service.of({
Mcp.Service,
Mcp.Service.of({
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
servers: () => Effect.succeed([]),
@@ -20,7 +20,7 @@ export const emptyMcpLayer = Layer.succeed(
instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]),
prompt: () => Effect.undefined,
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.undefined,
}),
)
+8
View File
@@ -1,6 +1,7 @@
import fs from "fs/promises"
import { tmpdir as osTmpdir } from "os"
import path from "path"
import { Effect } from "effect"
export const tmpdir = async (prefix = "opencode-core-test-") => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix)))
@@ -12,6 +13,13 @@ export const tmpdir = async (prefix = "opencode-core-test-") => {
}
}
export const withTempDir = <A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
body,
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
async function remove(dir: string, retries = 30): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true })
+53 -63
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { it } from "../lib/effect"
@@ -177,73 +177,63 @@ describe("Instructions", () => {
}),
)
it.effect("hashes objects independently of key order", () =>
Effect.sync(() => {
expect(Instructions.hash({ a: 1, b: { x: true, y: false } })).toBe(
Instructions.hash({ b: { y: false, x: true }, a: 1 }),
)
}),
)
test("hashes objects independently of key order", () => {
expect(Instructions.hash({ a: 1, b: { x: true, y: false } })).toBe(
Instructions.hash({ b: { y: false, x: true }, a: 1 }),
)
})
it.effect("renders sources in composition order", () =>
Effect.sync(() => {
const instructions = Instructions.combine([
source({ key: "core/date", value: "date" }),
source({ key: "core/location", value: "location" }),
])
test("renders sources in composition order", () => {
const instructions = Instructions.combine([
source({ key: "core/date", value: "date" }),
source({ key: "core/location", value: "location" }),
])
expect(Instructions.renderInitial(instructions, { "core/date": "date", "core/location": "location" })).toBe(
"date\n\nlocation",
)
}),
)
expect(Instructions.renderInitial(instructions, { "core/date": "date", "core/location": "location" })).toBe(
"date\n\nlocation",
)
})
it.effect("rejects duplicate source keys", () =>
Effect.sync(() => {
expect(() =>
Instructions.combine([source({ key: "core/date", value: "one" }), source({ key: "core/date", value: "two" })]),
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
}),
)
test("rejects duplicate source keys", () => {
expect(() =>
Instructions.combine([source({ key: "core/date", value: "one" }), source({ key: "core/date", value: "two" })]),
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
})
it.effect("rejects empty model-visible renderings", () =>
Effect.sync(() => {
const instructions = source({ key: "core/empty", value: "value", initial: () => "" })
test("rejects empty model-visible renderings", () => {
const instructions = source({ key: "core/empty", value: "value", initial: () => "" })
expect(() => Instructions.renderInitial(instructions, { "core/empty": "value" })).toThrow(
"Instruction source core/empty rendered an empty initial",
)
}),
)
expect(() => Instructions.renderInitial(instructions, { "core/empty": "value" })).toThrow(
"Instruction source core/empty rendered an empty initial",
)
})
it.effect("diffs list values by key", () =>
Effect.sync(() => {
const previous = [
{ name: "effect", description: "Build with Effect" },
{ name: "retired", description: "Old" },
]
const current = [
{ name: "effect", description: "Build with Effect v4" },
{ name: "writing", description: "Write prose" },
]
test("diffs list values by key", () => {
const previous = [
{ name: "effect", description: "Build with Effect" },
{ name: "retired", description: "Old" },
]
const current = [
{ name: "effect", description: "Build with Effect v4" },
{ name: "writing", description: "Write prose" },
]
expect(
Instructions.diffByKey(
previous,
current,
(value) => value.name,
(before, after) => before.description !== after.description,
),
).toEqual({
added: [{ name: "writing", description: "Write prose" }],
removed: [{ name: "retired", description: "Old" }],
changed: [
{
previous: { name: "effect", description: "Build with Effect" },
current: { name: "effect", description: "Build with Effect v4" },
},
],
})
}),
)
expect(
Instructions.diffByKey(
previous,
current,
(value) => value.name,
(before, after) => before.description !== after.description,
),
).toEqual({
added: [{ name: "writing", description: "Write prose" }],
removed: [{ name: "retired", description: "Old" }],
changed: [
{
previous: { name: "effect", description: "Build with Effect" },
current: { name: "effect", description: "Build with Effect v4" },
},
],
})
})
})
+1 -2
View File
@@ -1,6 +1,5 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { Cause, Effect, Exit, Layer, Scope } from "effect"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
+44
View File
@@ -0,0 +1,44 @@
export * as TestWebSearch from "./websearch"
import { Context, Deferred, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
export interface Interface extends WebSearch.Interface {
readonly queries: readonly WebSearch.Input[]
/** Waits for query arrivals, not provider execution or query completion. */
readonly wait: (count: number) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
// No providers are installed: tests register local executors through transform.
// The normal Bus and KV implementations use the default in-memory database.
export const layer = Layer.effectContext(
Effect.gen(function* () {
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const websearch = Context.get(context, WebSearch.Service)
const queries: WebSearch.Input[] = []
let started = yield* Deferred.make<void>()
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
queries.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(() => wait(count))),
)
const test = Service.of({
...websearch,
queries,
wait,
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
queries.push({ ...input })
const previous = started
started = yield* Deferred.make<void>()
yield* Deferred.succeed(previous, undefined)
return yield* websearch.query(input)
}),
})
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
}),
)
+2 -2
View File
@@ -31,7 +31,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -998,7 +998,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
yield* supervisor.flush
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
+8 -8
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { Permission } from "@opencode-ai/core/permission"
import { McpTool } from "@opencode-ai/core/tool/mcp"
@@ -17,15 +17,15 @@ const selection = (permissions: Permission.Ruleset = []) => {
}
const instructions = (server: string, text: string) =>
new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
new Mcp.ServerInstructions({ server: Mcp.ServerName.make(server), instructions: text })
const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.ServerName.make(server), name })
const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
AppNodeBuilder.build(McpInstructions.node, [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
instructions: () => Effect.succeed(catalog()),
tools: () => Effect.succeed(tools()),
}),
@@ -113,7 +113,7 @@ describe("McpInstructions", () => {
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
() => [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })],
),
),
),
@@ -125,7 +125,7 @@ describe("McpInstructions", () => {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
tools = [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })]
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
expect(changed.text).toBe(
[
+2 -2
View File
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test"
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Integration } from "@opencode-ai/core/integration"
import { MCPOAuth } from "@opencode-ai/core/mcp/oauth"
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
import { Effect } from "effect"
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
@@ -12,7 +12,7 @@ const authorize = (redirect_uri?: string) =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* MCPOAuth.authorize({
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
+61 -61
View File
@@ -16,7 +16,7 @@ import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Config } from "@opencode-ai/core/config"
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { ConfigMcpPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -27,9 +27,9 @@ import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpClient } from "@opencode-ai/core/mcp/client"
import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -180,7 +180,7 @@ function resourceServer(
function resourceMcpLayer(
server: string | typeof ConfigMCP.Server.Type,
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
options?: MCP.Options,
options?: Mcp.Options,
overrides?: {
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
@@ -193,10 +193,10 @@ function resourceMcpLayer(
return Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* ConfigMCPPlugin.register(bus.subscribe())
yield* ConfigMcpPlugin.register(bus.subscribe())
}),
).pipe(
Layer.provideMerge(MCP.layer(options)),
Layer.provideMerge(Mcp.layer(options)),
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
@@ -267,13 +267,13 @@ function resourceMcpLayer(
}
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
const mcp = Layer.mock(MCP.Service, {
const mcp = Layer.mock(Mcp.Service, {
tools: () =>
Effect.succeed([
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "search",
description: "Search",
inputSchema: { type: "object", properties: {} },
@@ -283,28 +283,28 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
codemode: false,
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "fail",
codemode: false,
description: "Always fails",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "media",
codemode: false,
description: "Returns text and an image",
@@ -315,15 +315,15 @@ const mcp = Layer.mock(MCP.Service, {
Effect.sync(() => {
calls += 1
if (input.name === "fail")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: true,
content: [{ type: "text", text: "search index unavailable" }],
})
if (input.name === "media")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [
@@ -332,14 +332,14 @@ const mcp = Layer.mock(MCP.Service, {
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
structured: { ok: true },
@@ -358,7 +358,7 @@ const permissions = Layer.mock(Permission.Service, {
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[MCP.node, mcp],
[Mcp.node, mcp],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
@@ -367,12 +367,12 @@ const it = testEffect(
describe("MCP errors", () => {
test("expose useful messages", () => {
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(new Mcp.NotFoundError({ server: Mcp.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
new Mcp.ToolCallError({ server: Mcp.ServerName.make("demo"), tool: "search", message: "failed" }).message,
).toBe("failed")
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
expect(new McpClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new McpClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
})
})
@@ -488,7 +488,7 @@ test("spawns local MCP servers through the location environment", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
const connection = yield* McpClient.connect("environment", config, import.meta.dir)
yield* connection.tools()
}),
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
@@ -513,7 +513,7 @@ test("reports a local MCP server as failed when the location has no execution pl
await Effect.runPromise(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.tools()
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
expect(status).toEqual({
@@ -528,7 +528,7 @@ test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "not-started",
command: process.execPath,
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
@@ -551,7 +551,7 @@ test("joins concurrent stdio transport closes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "concurrent-close",
command: "unused",
args: [],
@@ -605,7 +605,7 @@ test("closes a stdio process that finishes spawning after close", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "close-during-spawn",
command: "unused",
args: [],
@@ -816,7 +816,7 @@ for (const entry of [
})
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
expect(error).toBeInstanceOf(MCPClient.ConnectError)
expect(error).toBeInstanceOf(McpClient.ConnectError)
expect(server.state.initializations).toBe(entry.attempts)
expect(server.state.urls).toHaveLength(entry.attempts)
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
@@ -956,7 +956,7 @@ test("accepts empty MCP elicitations without creating forms", async () => {
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const forms = yield* Form.Service
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
expect(yield* forms.list()).toEqual([])
@@ -976,7 +976,7 @@ test("acknowledges completed MCP URL elicitations without returning internal con
const server = yield* resourceServer({ resources: false, urlElicitation: true })
const created = yield* Deferred.make<Form.Info>()
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const forms = yield* Form.Service
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
@@ -1006,7 +1006,7 @@ test("loads and reads MCP resources", async () => {
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect(yield* service.resourceCatalog()).toEqual({
resources: [
{
@@ -1053,12 +1053,12 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
Effect.scoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(published).toContain(McpEvent.StatusChanged.type)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
yield* service.add(
"dynamic",
new ConfigMCP.Local({
@@ -1101,7 +1101,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
yield* service.remove("dynamic")
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
expect(yield* service.tools()).toEqual([])
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
}).pipe(
Effect.provide(
resourceMcpLayer(
@@ -1125,7 +1125,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
"manages live MCP servers entirely through scoped transforms",
() =>
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* Effect.scoped(
Effect.gen(function* () {
@@ -1168,7 +1168,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
}),
)
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
expect(yield* service.tools()).toEqual([])
}),
)
@@ -1177,7 +1177,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
@@ -1220,7 +1220,7 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
yield* service.transform((draft) =>
draft.update("resources", (server) => {
@@ -1263,7 +1263,7 @@ test("reconciles only changed MCP server config", async () => {
} satisfies Payload<typeof Event.Updated>)
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.tools()
expect(server.state.toolLists).toBe(1)
expect(server.state.initializations).toBe(1)
@@ -1330,7 +1330,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
Effect.scoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
// Whatever order the racing operations land in, the resulting state must be consistent.
yield* Effect.all(
@@ -1373,8 +1373,8 @@ test("serializes concurrent MCP lifecycle operations", async () => {
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string, description = name) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
new Mcp.Tool({
server: Mcp.ServerName.make(server),
name,
description,
codemode: false,
@@ -1498,13 +1498,13 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
Effect.succeed(
new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
@@ -1541,12 +1541,12 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
tools: () =>
Effect.sync(() => [
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: `read_${++reads}`,
codemode: false,
inputSchema: { type: "object", properties: {} },
+3 -3
View File
@@ -4,7 +4,7 @@ import { Command } from "@opencode-ai/core/command"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/schema/session"
@@ -24,8 +24,8 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
[MCP.node, emptyMcpLayer],
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
[Mcp.node, emptyMcpLayer],
[Location.node, locationLayer],
]),
)
+5 -3
View File
@@ -5,6 +5,7 @@ import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FileSystem } from "@opencode-ai/core/filesystem"
@@ -14,7 +15,7 @@ import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -56,6 +57,7 @@ const permissionLayer = Layer.succeed(
export const PluginTestLayer = LayerNode.compile(
LayerNode.group([
AppProcess.node,
FileSystem.node,
FSUtil.node,
Location.node,
@@ -72,7 +74,7 @@ export const PluginTestLayer = LayerNode.compile(
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
PluginRuntime.node,
Permission.node,
PluginHooks.node,
@@ -89,7 +91,7 @@ export const PluginTestLayer = LayerNode.compile(
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Config.testLayer()],
[MCP.node, emptyMcpLayer],
[Mcp.node, emptyMcpLayer],
[Generate.node, generateLayer],
[Permission.node, permissionLayer],
],
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { MCPCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
import { McpCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
import type { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, type Types } from "effect"
import { it } from "../lib/effect"
@@ -51,7 +51,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
)
const base = host()
yield* MCPCodeModeExclusionPlugin.Plugin.effect(
yield* McpCodeModeExclusionPlugin.Plugin.effect(
host({
mcp: {
...base.mcp,
@@ -1,14 +1,22 @@
import { chmod } from "node:fs/promises"
import { Agent } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Effect, Schedule } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { AppProcess } from "@opencode-ai/util/process"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -47,6 +55,70 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
)
}
function withAzureCommands<A, E, R>(
run: (args: readonly string[]) => unknown,
fx: () => Effect.Effect<A, E, R>,
deploymentDelay = 0,
signedIn = true,
) {
return Effect.gen(function* () {
const processes = yield* AppProcess.Service
const directory = (yield* Location.Service).directory
const executable = `${directory}/${process.platform === "win32" ? "az.cmd" : "az"}`
yield* Effect.promise(() =>
Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"),
)
yield* Effect.promise(() => chmod(executable, 0o755))
if (signedIn) {
yield* Effect.promise(() => Bun.write(`${directory}/azure-cli/azureProfile.json`, '{"subscriptions":[{}]}'))
}
const fake = AppProcess.Service.of({
...processes,
run: (command) => {
if (command._tag !== "StandardCommand") return processes.run(command)
const value = run(command.args)
if (value instanceof Error) {
return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value }))
}
const result = Effect.succeed({
command: `az ${command.args.join(" ")}`,
exitCode: 0,
stdout: Buffer.from(JSON.stringify(value)),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
})
if (deploymentDelay > 0 && command.args.includes("deployment")) {
return Effect.sleep(`${deploymentDelay} millis`).pipe(Effect.andThen(result))
}
return result
},
})
return yield* withEnv(
{
PATH: `${directory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
AZURE_CONFIG_DIR: `${directory}/azure-cli`,
},
() => fx().pipe(Effect.provideService(AppProcess.Service, fake)),
)
})
}
const azureCredential = Effect.gen(function* () {
const credentials = yield* Credential.Service
return yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("azure-cli"),
access: "stored-token",
refresh: "azure-cli",
expires: Date.now() + 60 * 60 * 1000,
metadata: { resourceName: "test-resource" },
}),
})
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
@@ -82,6 +154,344 @@ describe("AzurePlugin", () => {
),
)
it.effect("hides Azure CLI authentication when the Azure CLI is not installed", () =>
withEnv({ PATH: "/nonexistent" }, () =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(false)
}),
),
)
it.live("registers Azure CLI authentication alongside API keys", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
() => [],
() =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
expect(integration?.methods).toContainEqual({
id: Integration.MethodID.make("azure-cli"),
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: [
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
],
})
}),
),
),
)
it.live("does not invoke Azure CLI at startup without a cached Azure login", () => {
const commands: string[] = []
return withEnv(
{
AZURE_RESOURCE_NAME: undefined,
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined,
AZURE_RESOURCE_GROUP: undefined,
},
() =>
withAzureCommands(
(args) => {
commands.push(args.join(" "))
return []
},
() =>
Effect.gen(function* () {
yield* addPlugin()
expect(commands).toEqual([])
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(true)
}),
0,
false,
),
)
})
it.live("lists Azure CLI resources and keeps manual resource entry available", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
() => [
{ name: "first-resource", resourceGroup: "first-group" },
{ name: "second-resource", resourceGroup: "second-group" },
],
() =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
const method = integration?.methods.find((item) => item.type === "oauth")
expect(method?.form?.[0]).toMatchObject({
title: "Enter Azure Resource Name",
options: [
{ value: "first-resource", label: "first-resource", description: "first-group" },
{ value: "second-resource", label: "second-resource", description: "second-group" },
],
custom: true,
})
}),
),
),
)
it.live("connects with the Azure CLI and accepts legacy token expiration", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}
}
return []
},
() =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("azure")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("azure-cli"),
answer: { resourceName: "test-resource" },
})
yield* Effect.gen(function* () {
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
if (status.status !== "complete") return yield* Effect.fail(new Error("Azure CLI authorization pending"))
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
const credential = (yield* (yield* Credential.Service).list(integrationID))[0]?.value
expect(credential).toMatchObject({
type: "oauth",
access: "legacy-cli-token",
metadata: { resourceName: "test-resource" },
})
}),
),
),
)
it.live("finishes deployment discovery before Azure authorization completes", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
return {
accessToken: "azure-token",
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
}
}
if (args.includes("deployment")) {
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
]
}
return [{ name: "test-resource", resourceGroup: "test-group" }]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("azure")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("azure-cli"),
answer: { resourceName: "test-resource" },
})
yield* Effect.gen(function* () {
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
if (status.status !== "complete") {
return yield* Effect.fail(
new Error(
`Azure CLI authorization ${status.status}${"message" in status ? `: ${status.message}` : ""}`,
),
)
}
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
}),
50,
),
),
)
for (const batched of [false, true]) {
it.effect(
`discovers deployments with an existing connection (${batched ? "batched startup" : "ready catalog"})`,
() =>
withEnv({ AZURE_RESOURCE_GROUP: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("deployment")) {
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-staging",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-pending",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Creating" },
},
]
}
return [{ name: "test-resource", resourceGroup: "test-group" }]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* azureCredential
const startup = Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
model.name = "GPT-5 Mini"
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* addPlugin()
})
yield* batched ? State.batch(startup) : startup
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-staging")))?.modelID).toBe(
Model.ID.make("gpt-staging"),
)
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
}),
),
),
)
}
it.effect("keeps existing models when Azure deployment discovery is unavailable", () =>
withAzureCommands(
() => new Error("management access denied"),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
}),
),
)
it.effect("skips subscription discovery when the resource group is configured", () =>
withEnv({ AZURE_RESOURCE_GROUP: "restricted-group" }, () =>
withAzureCommands(
(args) => {
expect(args).toContain("deployment")
expect(args).toContain("restricted-group")
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
}),
),
),
)
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
const scope = args[args.indexOf("--scope") + 1]
return { accessToken: `${scope}-token`, expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000) }
}
return []
},
() =>
Effect.gen(function* () {
yield* azureCredential
yield* addPlugin()
const hooks = yield* PluginHooks.Service
const model = Model.Ref.make({ providerID: Provider.ID.azure, id: Model.ID.make("gpt-5-mini") })
const azure = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_azure"),
agent: Agent.ID.make("build"),
model,
request: new Request("https://test-resource.openai.azure.com/openai/v1/responses", {
headers: { "api-key": "stored-token", "x-keep": "yes" },
}),
})
expect(azure.request.headers.get("authorization")).toBe(
"Bearer https://cognitiveservices.azure.com/.default-token",
)
expect(azure.request.headers.has("api-key")).toBe(false)
expect(azure.request.headers.get("x-keep")).toBe("yes")
const foundry = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_foundry"),
agent: Agent.ID.make("build"),
model,
request: new Request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
headers: { "x-api-key": "stored-token" },
}),
})
expect(foundry.request.headers.get("authorization")).toBe("Bearer https://ai.azure.com/.default-token")
expect(foundry.request.headers.has("x-api-key")).toBe(false)
}),
),
)
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
+38
View File
@@ -352,6 +352,44 @@ describe("Project.resolve", () => {
}),
)
it.live("keeps the canonical project directory when opening another clone", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const main = path.join(tmp.path, "repo")
const clone = path.join(tmp.path, "other-clone")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(main)
await initRepo(main, { commit: true, remote: "git@github.com:owner/repo.git" })
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet()
await $`git worktree add ${linked} -b linked`.cwd(main).quiet()
})
const project = yield* Project.Service
const bus = yield* Bus.Service
const initial = yield* project.resolve(abs(main))
const updates: Project.Info[] = []
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
Effect.forkScoped({ startImmediately: true }),
)
for (const directory of [clone, linked, main, clone]) {
const resolved = yield* project.resolve(abs(directory))
expect(resolved.id).toBe(initial.id)
expect(resolved.directory).toBe(abs(directory))
expect(resolved.canonical).toBe(abs(directory === clone ? clone : main))
expect((yield* project.list()).find((item) => item.id === initial.id)?.canonical).toBe(abs(main))
}
yield* Effect.yieldNow
expect(updates).toEqual([])
}),
)
it.live("returns git global for repo with no commits and no remote", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
+27
View File
@@ -92,6 +92,33 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("Session.create", () => {
liveIt.live("preserves the project canonical directory when creating a session in another clone", () =>
withTmp((directory) =>
Effect.gen(function* () {
const main = AbsolutePath.make(path.join(directory, "repo"))
const clone = AbsolutePath.make(path.join(directory, "other-clone"))
yield* Effect.promise(async () => {
await $`git init -q ${main}`.cwd(directory)
await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -qm root`
.cwd(main)
.quiet()
await $`git remote add origin git@github.com:owner/repo.git`.cwd(main)
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone)
})
const sessions = yield* Session.Service
const projects = yield* Project.Service
const first = yield* sessions.create({ location: Location.Ref.make({ directory: main }) })
const second = yield* sessions.create({ location: Location.Ref.make({ directory: clone }) })
expect(second.projectID).toBe(first.projectID)
expect((yield* projects.list()).find((project) => project.id === first.projectID)?.canonical).toBe(main)
expect((yield* sessions.get(first.id)).location.directory).toBe(main)
expect((yield* sessions.get(second.id)).location.directory).toBe(clone)
}),
),
)
liveIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -85,6 +85,7 @@ describe("SessionExecution lifecycle", () => {
const completedRunning = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
sessionID === completed
? Deferred.succeed(completedRunning, undefined).pipe(Effect.andThen(Deferred.await(release)))
@@ -245,6 +246,7 @@ describe("SessionExecution lifecycle", () => {
const bothDraining = yield* Deferred.make<void>()
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => {
drained.push(sessionID)
@@ -69,7 +69,6 @@ const testLayer = AppNodeBuilder.build(
ReadToolFileSystem.node,
readToolNode,
Tool.node,
Tool.node,
PluginHooks.node,
SessionInstructions.node,
Global.node,
@@ -120,7 +119,7 @@ const seedSynthetic = (sessionID: Session.ID, paths: string[]) =>
})
describe("SessionInstructions", () => {
it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
it.live("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
@@ -170,7 +169,7 @@ describe("SessionInstructions", () => {
}),
)
it.effect("does not re-inject paths already recorded in durable session history", () =>
it.live("does not re-inject paths already recorded in durable session history", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
@@ -197,7 +196,7 @@ describe("SessionInstructions", () => {
}),
)
it.effect(
it.live(
"discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read",
() =>
Effect.gen(function* () {
@@ -233,7 +232,7 @@ describe("SessionInstructions", () => {
}),
)
it.effect("re-injects nested instructions dropped from history by compaction", () =>
it.live("re-injects nested instructions dropped from history by compaction", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
@@ -264,7 +263,7 @@ describe("SessionInstructions", () => {
}),
)
it.effect("listing the Location root directory injects no instructions", () =>
it.live("listing the Location root directory injects no instructions", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
@@ -286,7 +285,7 @@ describe("SessionInstructions", () => {
}),
)
it.effect("loads instructions directly without a read", () =>
it.live("loads instructions directly without a read", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory

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