diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7498bc24c9..180c6b77c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -90,11 +90,18 @@ jobs: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - name: Build + - name: Build legacy CLI + if: github.ref_name != 'v2' + run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} + env: + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + OPENCODE_RELEASE: ${{ needs.version.outputs.release }} + GH_REPO: ${{ needs.version.outputs.repo }} + GH_TOKEN: ${{ steps.committer.outputs.token }} + + - name: Build preview CLI id: build - run: | - ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} - ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} + run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }} @@ -102,6 +109,7 @@ jobs: GH_TOKEN: ${{ steps.committer.outputs.token }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: github.ref_name != 'v2' with: name: opencode-cli path: | @@ -109,6 +117,7 @@ jobs: packages/opencode/dist/opencode-linux* - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: github.ref_name != 'v2' with: name: opencode-cli-windows path: packages/opencode/dist/opencode-windows* @@ -491,11 +500,13 @@ jobs: registry-url: "https://registry.npmjs.org" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: github.ref_name != 'v2' with: name: opencode-cli path: packages/opencode/dist - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: github.ref_name != 'v2' with: name: opencode-cli-windows path: packages/opencode/dist diff --git a/bun.lock b/bun.lock index c52d9e6b17..3ea6f31f5d 100644 --- a/bun.lock +++ b/bun.lock @@ -960,6 +960,20 @@ "solid-js": "^1.9.0", }, }, + "packages/updates": { + "name": "@opencode-ai/updates", + "version": "1.18.4", + "dependencies": { + "jose": "6.0.11", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "wrangler": "4.110.0", + }, + }, "packages/util": { "name": "@opencode-ai/util", "version": "1.18.3", @@ -2119,6 +2133,8 @@ "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], + "@opencode-ai/updates": ["@opencode-ai/updates@workspace:packages/updates"], + "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], @@ -6587,6 +6603,8 @@ "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], + "@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], + "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="], @@ -7857,6 +7875,16 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@opencode-ai/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@opencode-ai/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@opencode-ai/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "@opencode-ai/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="], + + "@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -8705,6 +8733,72 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@opencode-ai/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "@opencode-ai/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="], + + "@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="], + + "@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="], + + "@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="], + + "@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="], + "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], "@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -8989,6 +9083,44 @@ "@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="], @@ -9139,6 +9271,8 @@ "@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], "@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 26d127eb0e..f5b9ea67d9 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -27,6 +27,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" +const MEDIA_MIMES = new Set([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES]) export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" export const PATH = "/messages" @@ -56,6 +57,17 @@ const AnthropicImageBlock = Schema.Struct({ }) type AnthropicImageBlock = Schema.Schema.Type +const AnthropicDocumentBlock = Schema.Struct({ + type: Schema.tag("document"), + source: Schema.Struct({ + type: Schema.tag("base64"), + media_type: Schema.Literal("application/pdf"), + data: Schema.String, + }), + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicDocumentBlock = Schema.Schema.Type + const AnthropicThinkingBlock = Schema.Struct({ type: Schema.tag("thinking"), thinking: Schema.String, @@ -101,13 +113,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({ }) type AnthropicServerToolResultBlock = Schema.Schema.Type -// Anthropic accepts either a plain string or an ordered array of text/image -// blocks inside `tool_result.content`. The array form is required when a tool -// returns image bytes (screenshot, image search, etc.) so they can be passed -// to the model as proper image inputs instead of being JSON-stringified into -// the prompt — which silently inflates context by megabytes and can push the -// conversation over the model's token limit. -const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock]) +// Anthropic accepts either a plain string or an ordered array of text, image, and +// document blocks inside `tool_result.content`. The array form keeps media as native +// model input instead of JSON-stringifying base64 into prompt text. +const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock]) const AnthropicToolResultBlock = Schema.Struct({ type: Schema.tag("tool_result"), @@ -117,7 +126,12 @@ const AnthropicToolResultBlock = Schema.Struct({ cache_control: Schema.optional(AnthropicCacheControl), }) -const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock]) +const AnthropicUserBlock = Schema.Union([ + AnthropicTextBlock, + AnthropicImageBlock, + AnthropicDocumentBlock, + AnthropicToolResultBlock, +]) type AnthropicUserBlock = Schema.Schema.Type const AnthropicAssistantBlock = Schema.Union([ AnthropicTextBlock, @@ -319,12 +333,17 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock }) -const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) { - const media = yield* ProviderShared.validateMedia( - "Anthropic Messages", - part, - new Set(ProviderShared.IMAGE_MIMES), - ) +const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) { + const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES) + if (media.mime === "application/pdf") + return { + type: "document" as const, + source: { + type: "base64" as const, + media_type: "application/pdf" as const, + data: media.base64, + }, + } satisfies AnthropicDocumentBlock return { type: "image" as const, source: { @@ -335,25 +354,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me } satisfies AnthropicImageBlock }) -// Tool results may carry structured text/images. Keep media as provider-native +// Tool results may carry structured text, images, and documents. Keep media as provider-native // content instead of JSON-stringifying base64 into a prompt string. const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( item: ToolContent, ) { if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock - const media = yield* ProviderShared.validateToolFile( - "Anthropic Messages", - item, - new Set(ProviderShared.IMAGE_MIMES), - ) - return { - type: "image" as const, - source: { - type: "base64" as const, - media_type: media.mime, - data: media.base64, - }, - } satisfies AnthropicImageBlock + return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }) }) const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) { @@ -445,7 +452,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( continue } if (part.type === "media") { - content.push(yield* lowerImage(part)) + content.push(yield* lowerMedia(part)) continue } return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"]) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index b94f23d62f..801fb8a98a 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -52,6 +52,7 @@ const BedrockToolResultContentItem = Schema.Union([ Schema.Struct({ text: Schema.String }), Schema.Struct({ json: Schema.Unknown }), BedrockMedia.ImageBlock, + BedrockMedia.DocumentBlock, ]) const BedrockToolResultBlock = Schema.Struct({ @@ -283,8 +284,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent data: item.uri, filename: item.name, }) - if (!("image" in media)) - return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results") content.push(media) } return content diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 82a69b059e..6cba2bc7f9 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -41,9 +41,11 @@ const GeminiInlineDataPart = Schema.Struct({ data: Schema.String, }), }) +type GeminiInlineDataPart = Schema.Schema.Type const GeminiFunctionCallPart = Schema.Struct({ functionCall: Schema.Struct({ + id: Schema.optional(Schema.String), name: Schema.String, args: Schema.Unknown, }), @@ -52,8 +54,10 @@ const GeminiFunctionCallPart = Schema.Struct({ const GeminiFunctionResponsePart = Schema.Struct({ functionResponse: Schema.Struct({ + id: Schema.optional(Schema.String), name: Schema.String, response: Schema.Unknown, + parts: Schema.optional(Schema.Array(GeminiInlineDataPart)), }), }) @@ -197,8 +201,13 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => { : undefined } +const functionCallId = (providerMetadata: ProviderMetadata | undefined) => { + const google = providerMetadata?.google + return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined +} + const lowerToolCall = (part: ToolCallPart) => ({ - functionCall: { name: part.name, args: part.input }, + functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input }, thoughtSignature: thoughtSignature(part.providerMetadata), }) @@ -255,6 +264,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR if (part.result.type !== "content") { parts.push({ functionResponse: { + id: functionCallId(part.providerMetadata), name: part.name, response: { name: part.name, @@ -266,20 +276,23 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR } const content: ReadonlyArray = part.result.value const text = content.filter((item) => item.type === "text").map((item) => item.text) + const media: GeminiInlineDataPart[] = [] + for (const item of content) { + if (item.type === "text") continue + const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES) + media.push({ inlineData: { mimeType: value.mime, data: value.base64 } }) + } parts.push({ functionResponse: { + id: functionCallId(part.providerMetadata), name: part.name, response: { name: part.name, content: text.join("\n"), }, + parts: media.length > 0 ? media : undefined, }, }) - for (const item of content) { - if (item.type === "text") continue - const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES) - parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } }) - } } contents.push({ role: "user", parts }) } @@ -441,6 +454,10 @@ const step = (state: ParserState, event: GeminiEvent) => { if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + const metadata = { + ...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }), + ...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }), + } lifecycle = Lifecycle.reasoningEnd( lifecycle, events, @@ -453,9 +470,7 @@ const step = (state: ParserState, event: GeminiEvent) => { id, name: part.functionCall.name, input, - providerMetadata: part.thoughtSignature - ? googleMetadata({ thoughtSignature: part.thoughtSignature }) - : undefined, + providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined, }), ) hasToolCalls = true diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 3b35bb88d8..e423137fca 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -25,6 +25,7 @@ import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"]) export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/chat/completions" @@ -70,15 +71,18 @@ const OpenAIChatMessage = Schema.Union([ role: Schema.Literal("user"), content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), }), - Schema.Struct({ - role: Schema.Literal("assistant"), - content: Schema.NullOr(Schema.String), - tool_calls: optionalArray(OpenAIChatAssistantToolCall), - reasoning_content: Schema.optional(Schema.String), - reasoning: Schema.optional(Schema.String), - reasoning_text: Schema.optional(Schema.String), - reasoning_details: optionalArray(Schema.Unknown), - }), + Schema.StructWithRest( + Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.NullOr(Schema.String), + tool_calls: optionalArray(OpenAIChatAssistantToolCall), + reasoning_content: Schema.optional(Schema.String), + reasoning: Schema.optional(Schema.String), + reasoning_text: Schema.optional(Schema.String), + reasoning_details: Schema.optional(Schema.Unknown), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), ]).pipe(Schema.toTaggedUnion("role")) type OpenAIChatMessage = Schema.Schema.Type @@ -145,14 +149,17 @@ const OpenAIChatToolCallDelta = Schema.Struct({ }) type OpenAIChatToolCallDelta = Schema.Schema.Type -const OpenAIChatDelta = Schema.Struct({ - content: optionalNull(Schema.String), - reasoning_content: optionalNull(Schema.String), - reasoning: optionalNull(Schema.String), - reasoning_text: optionalNull(Schema.String), - reasoning_details: optionalNull(Schema.Array(Schema.Unknown)), - tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), -}) +const OpenAIChatDelta = Schema.StructWithRest( + Schema.Struct({ + content: optionalNull(Schema.String), + reasoning_content: optionalNull(Schema.String), + reasoning: optionalNull(Schema.String), + reasoning_text: optionalNull(Schema.String), + reasoning_details: optionalNull(Schema.Unknown), + tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) const OpenAIChatChoice = Schema.Struct({ delta: optionalNull(OpenAIChatDelta), @@ -179,7 +186,7 @@ export interface ParserState { readonly usage?: Usage readonly finishReason?: FinishReason readonly lifecycle: Lifecycle.State - readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text" + readonly reasoningField?: string readonly reasoningDetails: Array readonly reasoningDetailsObserved: boolean readonly reasoningEmitted: boolean @@ -227,7 +234,7 @@ const openAICompatibleReasoningContent = (native: unknown) => const reasoningField = (part: ReasoningPart) => { const field = part.providerMetadata?.openai?.reasoningField - if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field + return typeof field === "string" ? field : undefined } const reasoningDetails = (parts: ReadonlyArray, native: unknown) => { @@ -259,6 +266,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( message: OpenAIChatRequestMessage, + configuredField?: string, ) { const content: TextPart[] = [] const reasoning: ReasoningPart[] = [] @@ -285,24 +293,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible) const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails)) const field = (() => { - if (reasoning.length === 0) return + if (configuredField !== undefined) return configuredField + if (reasoning.length === 0) return undefined if (observedField !== undefined) return observedField if (nativeReasoning !== undefined) return "reasoning_content" if (!fullyStructured) return "reasoning_content" })() - const reasoningContent = (() => { + const reasoningText = (() => { + if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text if (reasoning.length === 0) return nativeReasoning - if (field === "reasoning_content") return text + return text })() - return { + const result = { role: "assistant" as const, content: content.length === 0 ? null : ProviderShared.joinText(content), tool_calls: toolCalls.length === 0 ? undefined : toolCalls, - reasoning_content: reasoningContent, - reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined, - reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined, reasoning_details: details, } + if (field === undefined || reasoningText === undefined) return result + return { ...result, [field]: reasoningText } }) const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) { @@ -328,9 +337,12 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m return { messages, images } }) -const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( + message: OpenAIChatRequestMessage, + reasoningField?: string, +) { if (message.role === "user") return [yield* lowerUserMessage(message)] - if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] + if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)] return (yield* lowerToolMessages(message)).messages }) @@ -368,7 +380,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: continue } flushImages() - messages.push(...(yield* lowerMessage(message))) + messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField))) } flushImages() return messages @@ -386,6 +398,11 @@ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LL const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. + const reasoningField = request.model.compatibility?.reasoningField + if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField)) + return yield* ProviderShared.invalidRequest( + `OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`, + ) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { @@ -446,10 +463,18 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { }) } -const reasoningDelta = (delta: Schema.Schema.Type | null | undefined) => { - if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const - if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const - if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const +const reasoningDelta = ( + delta: Schema.Schema.Type | null | undefined, + configuredField?: string, +) => { + if (!delta) return undefined + const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"]) + for (const field of fields) { + if (field === undefined) continue + const text = delta[field] + if (typeof text === "string" && text.length > 0) return { field, text } + } + return undefined } const detailText = (details: ReadonlyArray) => { @@ -518,7 +543,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => let lifecycle = state.lifecycle - const reasoning = reasoningDelta(delta) + const reasoning = reasoningDelta(delta, state.reasoningField) const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined) const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) @@ -635,12 +660,12 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIChatEvent), - initial: () => ({ + initial: (request) => ({ tools: ToolStream.empty(), pendingTools: {}, toolCallEvents: [], lifecycle: Lifecycle.initial(), - reasoningField: undefined, + reasoningField: request.model.compatibility?.reasoningField, reasoningDetails: [], reasoningDetailsObserved: false, reasoningEmitted: false, diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index f8e11b3cc2..87ca8c75e1 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -11,6 +11,7 @@ import { type FinishReason, type JsonSchema, type LLMRequest, + type MediaPart, type ProviderMetadata, type ReasoningPart, type TextPart, @@ -28,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream" import { OpenAIImage } from "./utils/openai-image" const ADAPTER = "openai-responses" +const MEDIA_MIMES = new Set([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES]) export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/responses" @@ -42,7 +44,17 @@ const OpenAIResponsesInputImage = Schema.Struct({ type: Schema.tag("input_image"), image_url: Schema.String, }) -const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) +const OpenAIResponsesInputFile = Schema.Struct({ + type: Schema.tag("input_file"), + filename: Schema.String, + file_data: Schema.String, + mime_type: Schema.optional(Schema.String), +}) +const OpenAIResponsesInputContent = Schema.Union([ + OpenAIResponsesInputText, + OpenAIResponsesInputImage, + OpenAIResponsesInputFile, +]) type OpenAIResponsesInputContent = Schema.Schema.Type const OpenAIResponsesOutputText = Schema.Struct({ @@ -68,9 +80,13 @@ const OpenAIResponsesItemReference = Schema.Struct({ }) // `function_call_output.output` accepts either a plain string or an ordered -// array of content items so tools can return images in addition to text. +// array of content items so tools can return images and files in addition to text. // https://platform.openai.com/docs/api-reference/responses/object -const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) +const OpenAIResponsesFunctionCallOutputContent = Schema.Union([ + OpenAIResponsesInputText, + OpenAIResponsesInputImage, + OpenAIResponsesInputFile, +]) const OpenAIResponsesFunctionCallOutput = Schema.Union([ Schema.String, @@ -343,42 +359,58 @@ const hostedToolItemID = (part: ToolResultPart) => { : undefined } -const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ( - part: LLMRequest["messages"][number]["content"][number], -) { - if (part.type === "text") return { type: "input_text" as const, text: part.text } - if (part.type === "media") { - const media = yield* ProviderShared.validateMedia( - "OpenAI Responses", - part, - new Set(ProviderShared.IMAGE_MIMES), - ) - return { type: "input_image" as const, image_url: media.dataUrl } +const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) { + const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES) + if (media.mime === "application/pdf") { + // xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data. + if (provider === "xai") + return { + type: "input_file" as const, + filename: part.filename ?? "document.pdf", + file_data: media.base64, + mime_type: media.mime, + } + return { + type: "input_file" as const, + filename: part.filename ?? "document.pdf", + file_data: media.dataUrl, + } } - return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) -}) - -// Tool results may carry structured text/images. Keep media as provider-native -// content instead of JSON-stringifying base64 into a prompt string. -const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* ( - item: ToolContent, -) { - if (item.type === "text") return { type: "input_text" as const, text: item.text } - const media = yield* ProviderShared.validateToolFile( - "OpenAI Responses", - item, - new Set(ProviderShared.IMAGE_MIMES), - ) return { type: "input_image" as const, image_url: media.dataUrl } }) -const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) { +const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ( + part: LLMRequest["messages"][number]["content"][number], + provider: string, +) { + if (part.type === "text") return { type: "input_text" as const, text: part.text } + if (part.type === "media") return yield* lowerMedia(part, provider) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) +}) + +// Tool results may carry structured text, images, and files. Keep media as provider-native +// content instead of JSON-stringifying base64 into a prompt string. +const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* ( + item: ToolContent, + provider: string, +) { + if (item.type === "text") return { type: "input_text" as const, text: item.text } + return yield* lowerMedia( + { type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, + provider, + ) +}) + +const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* ( + part: ToolResultPart, + provider: string, +) { // Text/json/error results are encoded as a plain string for backward // compatibility with existing cassettes and provider expectations. if (part.result.type !== "content") return ProviderShared.toolResultText(part) // Preserve the narrowed array element type when compiled through a consumer package. const content: ReadonlyArray = part.result.value - return yield* Effect.forEach(content, lowerToolResultContentItem) + return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider)) }) const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { @@ -401,7 +433,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (message.role === "user") { - input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) }) + input.push({ + role: "user", + content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)), + }) continue } @@ -460,7 +495,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ const content: ReadonlyArray = part.result.value input.push({ role: "user", - content: yield* Effect.forEach(content, lowerToolResultContentItem), + content: yield* Effect.forEach(content, (item) => + lowerToolResultContentItem(item, request.model.provider), + ), }) } if (itemID) hostedToolReferences.add(itemID) @@ -483,7 +520,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ input.push({ type: "function_call_output", call_id: part.id, - output: yield* lowerToolResultOutput(part), + output: yield* lowerToolResultOutput(part, request.model.provider), }) } } diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 173dc511bb..478088b081 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) => export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const -export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const +export const PDF_MIMES = ["application/pdf"] as const +export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024 export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024 diff --git a/packages/ai/src/protocols/utils/bedrock-media.ts b/packages/ai/src/protocols/utils/bedrock-media.ts index 6fda6c4fbb..6a12966b7b 100644 --- a/packages/ai/src/protocols/utils/bedrock-media.ts +++ b/packages/ai/src/protocols/utils/bedrock-media.ts @@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = { "text/markdown": "md", } as const satisfies Record -const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({ +const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({ document: { format, - name: part.filename ?? `document.${format}`, + name, source: { bytes }, }, }) @@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`) const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS] if (documentFormat) { + if (!part.filename) + return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename") const media = yield* ProviderShared.validateMedia( "Bedrock Converse", part, new Set(Object.keys(DOCUMENT_FORMATS)), ) - return documentBlock(part, documentFormat, media.base64) + return documentBlock(part.filename, documentFormat, media.base64) } return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`) }) diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 6d11333b53..b7ef932527 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -168,6 +168,7 @@ export type ModelToolSchemaCompatibility = Schema.Schema.Type("LLM.ModelCompatibility")({ toolSchema: Schema.optional(ModelToolSchemaCompatibility), + reasoningField: Schema.optional(Schema.String), }) {} export namespace ModelCompatibility { diff --git a/packages/ai/src/tool-runtime.ts b/packages/ai/src/tool-runtime.ts index d69bbb9d47..c483950c12 100644 --- a/packages/ai/src/tool-runtime.ts +++ b/packages/ai/src/tool-runtime.ts @@ -68,10 +68,29 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, events: settlement.result.type === "error" ? [ - LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }), - LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }), + LLMEvent.toolError({ + id: call.id, + name: call.name, + message: String(settlement.result.value), + error, + providerMetadata: call.providerMetadata, + }), + LLMEvent.toolResult({ + id: call.id, + name: call.name, + result: settlement.result, + providerMetadata: call.providerMetadata, + }), ] - : [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })], + : [ + LLMEvent.toolResult({ + id: call.id, + name: call.name, + result: settlement.result, + output: settlement.output, + providerMetadata: call.providerMetadata, + }), + ], } } diff --git a/packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json b/packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json new file mode 100644 index 0000000000..69281239e5 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:anthropic", + "protocol:anthropic-messages", + "tool", + "tool-result" + ], + "name": "pdf/anthropic-tool-result", + "recordedAt": "2026-07-22T18:15:39.002Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json b/packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json new file mode 100644 index 0000000000..372474e11f --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:anthropic", + "protocol:anthropic-messages", + "user-input" + ], + "name": "pdf/anthropic-user-input", + "recordedAt": "2026-07-22T18:15:37.979Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json b/packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json new file mode 100644 index 0000000000..3225e019d6 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "tool", + "tool-result" + ], + "name": "pdf/bedrock-tool-result", + "recordedAt": "2026-07-22T18:15:52.400Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_pdf_1\",\"content\":[{\"text\":\"PDF read successfully\"},{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAqgAAAFLa0GiGCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInJvbGUiOiJhc3Npc3RhbnQifXIDPnsAAADIAAAAV0lIuCQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUSJ9z2HHHgAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJJRC0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIn2+8d8RAAAAywAAAFcO6ML0CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVlcifSIeZ+kAAACzAAAAV8dKafoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyJ9HAStJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAAA+AAAAE6MAqhiCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MzkyMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIiwidXNhZ2UiOnsiaW5wdXRUb2tlbnMiOjIyNDEsIm91dHB1dFRva2VucyI6Niwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjIyNDd9fcd35Hw=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json b/packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json new file mode 100644 index 0000000000..53f04e5777 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "user-input" + ], + "name": "pdf/bedrock-user-input", + "recordedAt": "2026-07-22T18:15:48.408Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json b/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json new file mode 100644 index 0000000000..1ac7bc5f6e --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:google", + "protocol:gemini", + "tool", + "tool-result" + ], + "name": "pdf/gemini-tool-result", + "recordedAt": "2026-07-22T18:21:59.606Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json b/packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json new file mode 100644 index 0000000000..dbe73618a0 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:google", + "protocol:gemini", + "user-input" + ], + "name": "pdf/gemini-user-input", + "recordedAt": "2026-07-22T18:20:55.140Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCH\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 2,\"totalTokenCount\": 127,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 8,\"totalTokenCount\": 133,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EokECoYEARFNMg8L4fpLqaX8tIQZcvw2vLt3WsFjGqpuJGgna0/AGczwuzndRcf3LGIEaliCf4ijVOb1AG4/VPBh1kMzfjeAyHhvWIe4yQVoBwI7BjpFyLie+SnGTXQXKKy5ygRqRLFsV6DcAixNXXBHJw2x/2Nhtriryqs4fhWrL/P7ppHC10sMnTwN6Mw5x20NKwgT+rrw6lvYmQe9rdQsBJ6Zmp0GpPlwZZiAgzvwPfVoNwHSGb54xe/T9wjISjwWNgpedhbsIBDRZFDwruS4x57KBKeMPO69GLfeMP8PJ7rpR0HgT7nRbrl/OdykG/jqSMTvoRSqxawsD+Yr/DukgGatyfB5Ic+X4RhD07URpkGTAu/cakBtzhSmM/hpzKU9m/cId1UCjopLTtonUqSAKkroPdp8kIYw0MI2OZCNVwbDrdClUPmjRKfcTkcC2jNj1rS+WDFbm+mo+SP3rDSvvCdyJuiXHGKiM2EhbYnu42aHVC6w7eAe4Gv3Fq/0faW47r0ihbiAohFB9XUA+fD07g83EjIuc9Q6BRVTTcBfoRkrR/yFZKt3qwPq02W6rPD13/1wAnMtabNcxePMMGk7Dlxwng9yPS0NEge2KD+miOj9SC4aTvOTq2451tfK1x3UZqqb205zGOPbjizhH/CA/PGkG84hdkAG4mrUK0rEHqeWwRXDsxpyfto=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 530,\"candidatesTokenCount\": 8,\"totalTokenCount\": 653,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json b/packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json new file mode 100644 index 0000000000..42363b5ec8 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:openai", + "protocol:openai-responses", + "tool", + "tool-result" + ], + "name": "pdf/openai-tool-result", + "recordedAt": "2026-07-22T18:15:36.438Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0cd823aeeb11ef0e016a6108c703b88193872d736c48d60e2c\",\"object\":\"response\",\"created_at\":1784744135,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Read the attached PDF.\",\"name\":\"read_pdf\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0cd823aeeb11ef0e016a6108c703b88193872d736c48d60e2c\",\"object\":\"response\",\"created_at\":1784744135,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Read the attached PDF.\",\"name\":\"read_pdf\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"The\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"ztkMbqje3EBwS\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" verification\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"mEL\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" code\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"l7H1kZM2CjW\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" from\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"zoQfdh40bG4\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"5fXB3VBJUbAr\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" PDF\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"BpJQrXS6gY3c\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"xmGyHaXtMBy6i\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\":\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"oJeWezxDkzq3MRx\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" OR\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"LpS12bymKrO3H\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"CH\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"EvCpePUNmEXVil\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ID\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"IS5u0zHGO1f3DZ\",\"output_index\":0,\"sequence_number\":14}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"sQxTFLJOfQQxxil\",\"output_index\":0,\"sequence_number\":15}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"739\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"iIJRJFEo0kyEU\",\"output_index\":0,\"sequence_number\":16}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"1\",\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"obfuscation\":\"lwMVw41zdSRsVxm\",\"output_index\":0,\"sequence_number\":17}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":18,\"text\":\"The verification code from the PDF is: ORCHID-7391\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"The verification code from the PDF is: ORCHID-7391\"},\"sequence_number\":19}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"The verification code from the PDF is: ORCHID-7391\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":20}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0cd823aeeb11ef0e016a6108c703b88193872d736c48d60e2c\",\"object\":\"response\",\"created_at\":1784744135,\"status\":\"completed\",\"background\":false,\"completed_at\":1784744136,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_0cd823aeeb11ef0e016a6108c83f8081938114e89ac5d99c7e\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"The verification code from the PDF is: ORCHID-7391\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Read the attached PDF.\",\"name\":\"read_pdf\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":107,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":16,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":123},\"user\":null,\"metadata\":{}},\"sequence_number\":21}\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/openai-user-input.json b/packages/ai/test/fixtures/recordings/pdf/openai-user-input.json new file mode 100644 index 0000000000..ab2b4a9dd7 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/openai-user-input.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:openai", + "protocol:openai-responses", + "user-input" + ], + "name": "pdf/openai-user-input", + "recordedAt": "2026-07-22T18:15:34.867Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_098ccdfefefe7eee016a6108c55ad48194a250d21618f41130\",\"object\":\"response\",\"created_at\":1784744134,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_098ccdfefefe7eee016a6108c55ad48194a250d21618f41130\",\"object\":\"response\",\"created_at\":1784744134,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"OR\",\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"obfuscation\":\"OT7X1BjsfOw0Y7\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"CH\",\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"obfuscation\":\"XpcQS0WDba72lQ\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ID\",\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"obfuscation\":\"IfrmlWWbGSpVPM\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"obfuscation\":\"Jpf9w1TEeBMJR6X\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"739\",\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"obfuscation\":\"J1beY5zFoug8n\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"1\",\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"obfuscation\":\"kG1Isnrwy5QR4QB\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":10,\"text\":\"ORCHID-7391\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"ORCHID-7391\"},\"sequence_number\":11}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"ORCHID-7391\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":12}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_098ccdfefefe7eee016a6108c55ad48194a250d21618f41130\",\"object\":\"response\",\"created_at\":1784744134,\"status\":\"completed\",\"background\":false,\"completed_at\":1784744134,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_098ccdfefefe7eee016a6108c6b6e48194bdd91a4de2cb7bbe\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"ORCHID-7391\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":44,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":7,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":51},\"user\":null,\"metadata\":{}},\"sequence_number\":13}\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json b/packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json new file mode 100644 index 0000000000..71833b93a5 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:xai", + "protocol:openai-responses", + "tool", + "tool-result" + ], + "name": "pdf/xai-tool-result", + "recordedAt": "2026-07-22T18:15:43.608Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.x.ai/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: response.created\ndata: {\"sequence_number\":0,\"type\":\"response.created\",\"response\":{\"created_at\":0,\"completed_at\":null,\"id\":\"dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"max_output_tokens\":40,\"model\":\"grok-4.5\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Read the attached PDF.\",\"name\":\"read_pdf\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"top_p\":0.949999988079071,\"usage\":null,\"user\":null,\"incomplete_details\":null,\"status\":\"in_progress\",\"store\":false,\"metadata\":{\"system_fingerprint\":\"fp_a39489019fa99b6e\"},\"background\":false,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"top_logprobs\":0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"prompt_cache_key\":null,\"max_tool_calls\":null,\"safety_identifier\":null,\"error\":null,\"instructions\":null}}\n\nevent: response.in_progress\ndata: {\"sequence_number\":1,\"type\":\"response.in_progress\",\"response\":{\"created_at\":0,\"completed_at\":null,\"id\":\"dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"max_output_tokens\":40,\"model\":\"grok-4.5\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Read the attached PDF.\",\"name\":\"read_pdf\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"top_p\":0.949999988079071,\"usage\":null,\"user\":null,\"incomplete_details\":null,\"status\":\"in_progress\",\"store\":false,\"metadata\":{\"system_fingerprint\":\"fp_a39489019fa99b6e\"},\"background\":false,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"top_logprobs\":0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"prompt_cache_key\":null,\"max_tool_calls\":null,\"safety_identifier\":null,\"error\":null,\"instructions\":null}}\n\nevent: response.output_item.added\ndata: {\"sequence_number\":2,\"type\":\"response.output_item.added\",\"item\":{\"content\":[],\"id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"role\":\"assistant\",\"type\":\"message\",\"status\":\"in_progress\"},\"output_index\":0}\n\nevent: response.content_part.added\ndata: {\"sequence_number\":3,\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"logprobs\":[],\"annotations\":[]}}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":4,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"OR\",\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":5,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"CH\",\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":6,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ID\",\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":7,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":8,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"739\",\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":9,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"1\",\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"logprobs\":[]}\n\nevent: response.output_text.done\ndata: {\"sequence_number\":10,\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"text\":\"ORCHID-7391\",\"logprobs\":[]}\n\nevent: response.content_part.done\ndata: {\"sequence_number\":11,\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"ORCHID-7391\",\"logprobs\":[],\"annotations\":[]}}\n\nevent: response.output_item.done\ndata: {\"sequence_number\":12,\"type\":\"response.output_item.done\",\"item\":{\"content\":[{\"type\":\"output_text\",\"text\":\"ORCHID-7391\",\"logprobs\":[],\"annotations\":[]}],\"id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"role\":\"assistant\",\"type\":\"message\",\"status\":\"completed\"},\"output_index\":0}\n\nevent: response.completed\ndata: {\"sequence_number\":13,\"type\":\"response.completed\",\"response\":{\"created_at\":0,\"completed_at\":1784744143,\"id\":\"dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"max_output_tokens\":40,\"model\":\"grok-4.5\",\"object\":\"response\",\"output\":[{\"content\":[{\"type\":\"output_text\",\"text\":\"ORCHID-7391\",\"logprobs\":[],\"annotations\":[]}],\"id\":\"msg_dab13d84-6060-9eb7-b898-cbe7a3475ad7\",\"role\":\"assistant\",\"type\":\"message\",\"status\":\"completed\"}],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Read the attached PDF.\",\"name\":\"read_pdf\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"top_p\":0.949999988079071,\"usage\":{\"input_tokens\":1592,\"input_tokens_details\":{\"cached_tokens\":128},\"output_tokens\":10,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":1602,\"num_sources_used\":0,\"num_server_side_tools_used\":0,\"cost_in_usd_ticks\":30264000,\"context_details\":{\"input_tokens\":1592,\"output_tokens\":11}},\"user\":null,\"incomplete_details\":null,\"status\":\"completed\",\"store\":false,\"metadata\":{\"system_fingerprint\":\"fp_a39489019fa99b6e\"},\"background\":false,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"top_logprobs\":0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"prompt_cache_key\":null,\"max_tool_calls\":null,\"safety_identifier\":null,\"error\":null,\"instructions\":null}}\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/pdf/xai-user-input.json b/packages/ai/test/fixtures/recordings/pdf/xai-user-input.json new file mode 100644 index 0000000000..c3831f6e06 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/pdf/xai-user-input.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:pdf", + "pdf", + "provider:xai", + "protocol:openai-responses", + "user-input" + ], + "name": "pdf/xai-user-input", + "recordedAt": "2026-07-22T18:15:42.429Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.x.ai/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "event: response.created\ndata: {\"sequence_number\":0,\"type\":\"response.created\",\"response\":{\"created_at\":0,\"completed_at\":null,\"id\":\"c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"max_output_tokens\":40,\"model\":\"grok-4.5\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":0.949999988079071,\"usage\":null,\"user\":null,\"incomplete_details\":null,\"status\":\"in_progress\",\"store\":false,\"metadata\":{\"system_fingerprint\":\"fp_a39489019fa99b6e\"},\"background\":false,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"top_logprobs\":0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"prompt_cache_key\":null,\"max_tool_calls\":null,\"safety_identifier\":null,\"error\":null,\"instructions\":null}}\n\nevent: response.in_progress\ndata: {\"sequence_number\":1,\"type\":\"response.in_progress\",\"response\":{\"created_at\":0,\"completed_at\":null,\"id\":\"c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"max_output_tokens\":40,\"model\":\"grok-4.5\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":0.949999988079071,\"usage\":null,\"user\":null,\"incomplete_details\":null,\"status\":\"in_progress\",\"store\":false,\"metadata\":{\"system_fingerprint\":\"fp_a39489019fa99b6e\"},\"background\":false,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"top_logprobs\":0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"prompt_cache_key\":null,\"max_tool_calls\":null,\"safety_identifier\":null,\"error\":null,\"instructions\":null}}\n\nevent: response.output_item.added\ndata: {\"sequence_number\":2,\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"summary\":[],\"type\":\"reasoning\",\"status\":\"in_progress\"},\"output_index\":0}\n\nevent: response.reasoning_summary_part.added\ndata: {\"sequence_number\":3,\"type\":\"response.reasoning_summary_part.added\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"part\":{\"text\":\"\",\"type\":\"summary_text\"},\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":4,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"The\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":5,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" user\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":6,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" wants\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":7,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" me\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":8,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" to\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":9,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" return\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":10,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" only\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":11,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" the\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":12,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" verification\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":13,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" code\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":14,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" from\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":15,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" the\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":16,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\" PDF\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.delta\ndata: {\"sequence_number\":17,\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\".\\n\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0}\n\nevent: response.reasoning_summary_text.done\ndata: {\"sequence_number\":18,\"type\":\"response.reasoning_summary_text.done\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"summary_index\":0,\"text\":\"The user wants me to return only the verification code from the PDF.\\n\"}\n\nevent: response.reasoning_summary_part.done\ndata: {\"sequence_number\":19,\"type\":\"response.reasoning_summary_part.done\",\"item_id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":0,\"part\":{\"text\":\"The user wants me to return only the verification code from the PDF.\\n\",\"type\":\"summary_text\"},\"summary_index\":0}\n\nevent: response.output_item.done\ndata: {\"sequence_number\":20,\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"summary\":[{\"text\":\"The user wants me to return only the verification code from the PDF.\\n\",\"type\":\"summary_text\"}],\"type\":\"reasoning\",\"status\":\"completed\"},\"output_index\":0}\n\nevent: response.output_item.added\ndata: {\"sequence_number\":21,\"type\":\"response.output_item.added\",\"item\":{\"content\":[],\"id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"role\":\"assistant\",\"type\":\"message\",\"status\":\"in_progress\"},\"output_index\":1}\n\nevent: response.content_part.added\ndata: {\"sequence_number\":22,\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"logprobs\":[],\"annotations\":[]}}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":23,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"OR\",\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":24,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"CH\",\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":25,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ID\",\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":26,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":27,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"739\",\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"logprobs\":[]}\n\nevent: response.output_text.delta\ndata: {\"sequence_number\":28,\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"1\",\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"logprobs\":[]}\n\nevent: response.output_text.done\ndata: {\"sequence_number\":29,\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"text\":\"ORCHID-7391\",\"logprobs\":[]}\n\nevent: response.content_part.done\ndata: {\"sequence_number\":30,\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"text\":\"ORCHID-7391\",\"logprobs\":[],\"annotations\":[]}}\n\nevent: response.output_item.done\ndata: {\"sequence_number\":31,\"type\":\"response.output_item.done\",\"item\":{\"content\":[{\"type\":\"output_text\",\"text\":\"ORCHID-7391\",\"logprobs\":[],\"annotations\":[]}],\"id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"role\":\"assistant\",\"type\":\"message\",\"status\":\"completed\"},\"output_index\":1}\n\nevent: response.completed\ndata: {\"sequence_number\":32,\"type\":\"response.completed\",\"response\":{\"created_at\":0,\"completed_at\":1784744142,\"id\":\"c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"max_output_tokens\":40,\"model\":\"grok-4.5\",\"object\":\"response\",\"output\":[{\"id\":\"rs_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"summary\":[{\"text\":\"The user wants me to return only the verification code from the PDF.\\n\",\"type\":\"summary_text\"}],\"type\":\"reasoning\",\"status\":\"completed\"},{\"content\":[{\"type\":\"output_text\",\"text\":\"ORCHID-7391\",\"logprobs\":[],\"annotations\":[]}],\"id\":\"msg_c69d4e2b-243a-909a-84b6-ee953321ef6c\",\"role\":\"assistant\",\"type\":\"message\",\"status\":\"completed\"}],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":0.949999988079071,\"usage\":{\"input_tokens\":1522,\"input_tokens_details\":{\"cached_tokens\":128},\"output_tokens\":119,\"output_tokens_details\":{\"reasoning_tokens\":109},\"total_tokens\":1641,\"num_sources_used\":0,\"num_server_side_tools_used\":0,\"cost_in_usd_ticks\":35404000,\"context_details\":{\"input_tokens\":1522,\"output_tokens\":119}},\"user\":null,\"incomplete_details\":null,\"status\":\"completed\",\"store\":false,\"metadata\":{\"system_fingerprint\":\"fp_a39489019fa99b6e\"},\"background\":false,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"top_logprobs\":0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"prompt_cache_key\":null,\"max_tool_calls\":null,\"safety_identifier\":null,\"error\":null,\"instructions\":null}}\n\n" + } + } + ] +} diff --git a/packages/ai/test/lib/tool-runtime.ts b/packages/ai/test/lib/tool-runtime.ts index 28ebc47c71..55fce8e001 100644 --- a/packages/ai/test/lib/tool-runtime.ts +++ b/packages/ai/test/lib/tool-runtime.ts @@ -59,7 +59,12 @@ export const runTools = (options: RunOptions) => ...request.messages, Message.assistant(state.assistantContent), ...dispatched.map(([call, dispatched]) => - Message.tool({ id: call.id, name: call.name, result: dispatched.result }), + Message.tool({ + id: call.id, + name: call.name, + result: dispatched.result, + providerMetadata: call.providerMetadata, + }), ), ], }) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 69a27e6228..30b3c1b6b8 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -235,9 +235,9 @@ describe("Anthropic Messages route", () => { }), ) - // Regression: screenshot/read tool results must stay structured so base64 - // image data is not JSON-stringified into `tool_result.content`. - it.effect("lowers image tool-result content as structured image blocks", () => + // Regression: read tool results must stay structured so base64 media data is + // not JSON-stringified into `tool_result.content`. + it.effect("lowers media tool-result content as structured blocks", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ @@ -253,6 +253,7 @@ describe("Anthropic Messages route", () => { result: [ { type: "text", text: "Image read successfully" }, { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, + { type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" }, ], }), ], @@ -263,6 +264,7 @@ describe("Anthropic Messages route", () => { expect(expectToolResult(prepared.body).content).toEqual([ { type: "text", text: "Image read successfully" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, + { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } }, ]) }), ) @@ -292,7 +294,7 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("rejects non-image media in tool-result content with a clear error", () => + it.effect("rejects unsupported media in tool-result content with a clear error", () => Effect.gen(function* () { const error = yield* LLMClient.prepare( LLM.request({ @@ -756,7 +758,7 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("continues a conversation with user image content", () => + it.effect("continues a conversation with user media content", () => Effect.gen(function* () { const response = yield* LLMClient.generate( LLM.request({ @@ -766,6 +768,7 @@ describe("Anthropic Messages route", () => { Message.user([ { type: "text", text: "What is in this image?" }, { type: "media", mediaType: "image/png", data: "AAECAw==" }, + { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" }, ]), ], }), @@ -781,6 +784,7 @@ describe("Anthropic Messages route", () => { content: [ { type: "text", text: "What is in this image?" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, + { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } }, ], }, ], diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index a550d1eb90..59ca67333b 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -549,10 +549,12 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_doc", model, + cache: "none", messages: [ Message.user([ + { type: "text", text: "Summarize these documents." }, { type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" }, - { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" }, + { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" }, ]), ], }), @@ -563,10 +565,9 @@ describe("Bedrock Converse route", () => { { role: "user", content: [ - // Filename round-trips when supplied. + { text: "Summarize these documents." }, { document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }, - // Falls back to a stable placeholder when filename is missing. - { document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } }, + { document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } }, ], }, ], @@ -574,6 +575,96 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("requires names for document media", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("document media requires a filename") + }), + ) + + it.effect("passes named document-only messages through for provider validation", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + cache: "none", + messages: [ + Message.user({ + type: "media", + mediaType: "application/pdf", + data: "UERGREFUQQ==", + filename: "report.pdf", + }), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "user", + content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }], + }, + ]) + }), + ) + + it.effect("lowers document media in tool results", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]), + Message.tool({ + id: "call_1", + name: "read", + result: { + type: "content", + value: [ + { type: "text", text: "Read successfully" }, + { + type: "file", + uri: "data:application/pdf;base64,UERGREFUQQ==", + mime: "application/pdf", + name: "report", + }, + ], + }, + }), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }], + }, + { + role: "user", + content: [ + { + toolResult: { + toolUseId: "call_1", + status: "success", + content: [ + { text: "Read successfully" }, + { document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } }, + ], + }, + }, + ], + }, + ]) + }), + ) + it.effect("rejects unsupported image media types", () => Effect.gen(function* () { const error = yield* LLMClient.prepare( diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 1dc253c0ea..5195d372c9 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -70,6 +70,7 @@ describe("Gemini route", () => { Message.user([ { type: "text", text: "What is in this image?" }, { type: "media", mediaType: "image/png", data: "AAECAw==" }, + { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" }, ]), Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), @@ -81,7 +82,11 @@ describe("Gemini route", () => { contents: [ { role: "user", - parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }], + parts: [ + { text: "What is in this image?" }, + { inlineData: { mimeType: "image/png", data: "AAECAw==" } }, + { inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } }, + ], }, { role: "model", @@ -90,7 +95,12 @@ describe("Gemini route", () => { { role: "user", parts: [ - { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } }, + { + functionResponse: { + name: "lookup", + response: { name: "lookup", content: '{"forecast":"sunny"}' }, + }, + }, ], }, ], @@ -110,7 +120,7 @@ describe("Gemini route", () => { }), ) - it.effect("continues image tool results as inline vision input without base64 text", () => + it.effect("continues media tool results as inline model input without base64 text", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ @@ -125,6 +135,7 @@ describe("Gemini route", () => { value: [ { type: "text", text: "Image read successfully" }, { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }, + { type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" }, ], }, }), @@ -141,9 +152,12 @@ describe("Gemini route", () => { functionResponse: { name: "read", response: { name: "read", content: "Image read successfully" }, + parts: [ + { inlineData: { mimeType: "image/png", data: "AAECAw==" } }, + { inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } }, + ], }, }, - { inlineData: { mimeType: "image/png", data: "AAECAw==" } }, ], }, ]) @@ -174,8 +188,13 @@ describe("Gemini route", () => { { role: "user", parts: [ - { functionResponse: { name: "read", response: { name: "read", content: "" } } }, - { inlineData: { mimeType: "image/jpeg", data: "/9j/" } }, + { + functionResponse: { + name: "read", + response: { name: "read", content: "" }, + parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }], + }, + }, ], }, ]) @@ -372,7 +391,10 @@ describe("Gemini route", () => { parts: [ { text: "thinking", thought: true }, { text: "", thought: true, thoughtSignature: "thought_sig" }, - { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" }, + { + functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } }, + thoughtSignature: "tool_sig", + }, ], }, finishReason: "STOP", @@ -398,7 +420,10 @@ describe("Gemini route", () => { id: "reasoning-0", providerMetadata: { google: { thoughtSignature: "thought_sig" } }, }) - expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } }) + expect(toolCall).toMatchObject({ + id: "tool_0", + providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } }, + }) expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( response.events.findIndex((event) => event.type === "tool-call"), ) @@ -416,6 +441,13 @@ describe("Gemini route", () => { providerMetadata: toolCall?.providerMetadata, }), ]), + Message.tool({ + id: "tool_0", + name: "lookup", + result: "done", + resultType: "text", + providerMetadata: toolCall?.providerMetadata, + }), ], }), ) @@ -424,7 +456,22 @@ describe("Gemini route", () => { role: "model", parts: [ { text: "thinking", thought: true, thoughtSignature: "thought_sig" }, - { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" }, + { + functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } }, + thoughtSignature: "tool_sig", + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + id: "provider_call", + name: "lookup", + response: { name: "lookup", content: "done" }, + }, + }, ], }, ]) @@ -498,7 +545,7 @@ describe("Gemini route", () => { content: { role: "model", parts: [ - { functionCall: { name: "lookup", args: { query: "weather" } } }, + { functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } }, { functionCall: { name: "lookup", args: { query: "news" } } }, ], }, @@ -513,7 +560,13 @@ describe("Gemini route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerMetadata: { google: { functionCallId: "tool_0" } }, + }, { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, ]) expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts index 5ac810db61..a8b40346cf 100644 --- a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts +++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMResponse } from "../../src" +import { LLM, LLMEvent, LLMResponse, Model } from "../../src" import { OpenAIChat } from "../../src/protocols/openai-chat" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenRouter from "../../src/providers/openrouter" @@ -11,22 +11,28 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop const cases = [ { name: "OpenRouter", - model: OpenRouter.configure({ - apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", - providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, - }).model("anthropic/claude-sonnet-4.6"), + model: Model.update( + OpenRouter.configure({ + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", + providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, + }).model("anthropic/claude-sonnet-4.6"), + { compatibility: { reasoningField: "reasoning" } }, + ), requires: ["OPENROUTER_API_KEY"], cassette: "openrouter-reasoning", structured: true, }, { name: "Vercel AI Gateway", - model: OpenAICompatible.configure({ - provider: "vercel-ai-gateway", - baseURL: "https://ai-gateway.vercel.sh/v1", - apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture", - http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } }, - }).model("anthropic/claude-sonnet-4.6"), + model: Model.update( + OpenAICompatible.configure({ + provider: "vercel-ai-gateway", + baseURL: "https://ai-gateway.vercel.sh/v1", + apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture", + http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } }, + }).model("anthropic/claude-sonnet-4.6"), + { compatibility: { reasoningField: "reasoning" } }, + ), requires: ["AI_GATEWAY_API_KEY"], cassette: "vercel-ai-gateway-reasoning", structured: true, diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 591e5f0be0..50bc34df15 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -92,6 +92,45 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("writes reasoning to a configured custom field on every assistant message", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "thinking", + providerMetadata: { openai: { reasoningField: "reasoning" } }, + }, + { type: "text", text: "Hello" }, + ]), + Message.assistant("Done"), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "assistant", content: "Hello", vendor_reasoning: "thinking" }, + { role: "assistant", content: "Done", vendor_reasoning: "" }, + ]) + }), + ) + + it.effect("rejects reasoning fields that conflict with assistant message fields", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: Model.update(model, { compatibility: { reasoningField: "content" } }), + messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("reserved field content") + }), + ) + it.effect("maps OpenAI provider options to Chat options", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -570,6 +609,35 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("parses and replays a configured custom reasoning field", () => + Effect.gen(function* () { + const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }) + const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { choices: [{ delta: { vendor_reasoning: "thinking" } }] }, + { choices: [{ delta: { content: "Hello" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("thinking") + expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ + openai: { reasoningField: "vendor_reasoning" }, + }) + + const replay = yield* LLMClient.prepare( + LLM.request({ model: custom, messages: [response.message] }), + ) + expect(replay.body.messages).toEqual([ + { role: "assistant", content: "Hello", vendor_reasoning: "thinking" }, + ]) + }), + ) + it.effect("preserves and replays reasoning details alongside scalar reasoning", () => Effect.gen(function* () { const details = [ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index e345cd59b1..11466a6896 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -5,6 +5,7 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" +import * as XAI from "../../src/providers/xai" import * as OpenAIResponses from "../../src/protocols/openai-responses" import * as ProviderShared from "../../src/protocols/shared" import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios" @@ -16,6 +17,8 @@ const model = OpenAIResponses.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4.1-mini" }) +const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5") + const request = LLM.request({ id: "req_1", model, @@ -524,7 +527,77 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("rejects non-image media in tool-result content with a clear error", () => + it.effect("lowers PDF tool-result content as structured input_file array", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_pdf", + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]), + Message.tool({ + id: "call_1", + name: "read", + resultType: "content", + result: [ + { + type: "file", + uri: "data:application/pdf;base64,JVBERi0xLjQ=", + mime: "application/pdf", + name: "report.pdf", + }, + ], + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toEqual([ + { + type: "input_file", + filename: "report.pdf", + file_data: "data:application/pdf;base64,JVBERi0xLjQ=", + }, + ]) + }), + ) + + it.effect("uses xAI inline file encoding for PDF tool results", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: xaiModel, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]), + Message.tool({ + id: "call_1", + name: "read", + resultType: "content", + result: [ + { + type: "file", + uri: "data:application/pdf;base64,JVBERi0xLjQ=", + mime: "application/pdf", + name: "report.pdf", + }, + ], + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toEqual([ + { + type: "input_file", + filename: "report.pdf", + file_data: "JVBERi0xLjQ=", + mime_type: "application/pdf", + }, + ]) + }), + ) + + it.effect("rejects unsupported media in tool-result content with a clear error", () => Effect.gen(function* () { const error = yield* LLMClient.prepare( LLM.request({ @@ -1526,20 +1599,64 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("lowers user image content", () => + it.effect("lowers user image and PDF content", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ id: "req_media", model, - messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [ + Message.user([ + { type: "media", mediaType: "image/png", data: "AAECAw==" }, + { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" }, + ]), + ], }), ) expect(prepared.body.input).toEqual([ { role: "user", - content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }], + content: [ + { type: "input_image", image_url: "data:image/png;base64,AAECAw==" }, + { + type: "input_file", + filename: "report.pdf", + file_data: "data:application/pdf;base64,JVBERi0xLjQ=", + }, + ], + }, + ]) + }), + ) + + it.effect("uses xAI inline file encoding for user PDFs", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: xaiModel, + messages: [ + Message.user({ + type: "media", + mediaType: "application/pdf", + data: "data:application/pdf;base64,JVBERi0xLjQ=", + filename: "report.pdf", + }), + ], + }), + ) + + expect(prepared.body.input).toEqual([ + { + role: "user", + content: [ + { + type: "input_file", + filename: "report.pdf", + file_data: "JVBERi0xLjQ=", + mime_type: "application/pdf", + }, + ], }, ]) }), @@ -1551,11 +1668,11 @@ describe("OpenAI Responses route", () => { LLM.request({ id: "req_media", model, - messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })], }), ).pipe(Effect.flip) - expect(error.message).toContain("OpenAI Responses does not support media type application/pdf") + expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar") }), ) diff --git a/packages/ai/test/provider/pdf.recorded.test.ts b/packages/ai/test/provider/pdf.recorded.test.ts new file mode 100644 index 0000000000..c3827a651c --- /dev/null +++ b/packages/ai/test/provider/pdf.recorded.test.ts @@ -0,0 +1,207 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src" +import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers" +import { LLMClient } from "../../src/route" +import { Tool } from "../../src/tool" +import { runTools } from "../lib/tool-runtime" +import { recordedTests } from "../recorded-test" + +const CODE = "ORCHID-7391" +const PDF = + "JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK" + +const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }) +const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }) +const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" }) +const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }) +const bedrock = AmazonBedrock.configure({ + apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture", + region: process.env.AWS_REGION ?? "us-east-1", +}) + +const targets: ReadonlyArray<{ + readonly id: string + readonly name: string + readonly provider: string + readonly protocol: string + readonly requires: string + readonly filename: string + readonly maxTokens: number + readonly model: Model +}> = [ + { + id: "openai", + name: "OpenAI Responses gpt-4o-mini", + provider: "openai", + protocol: "openai-responses", + requires: "OPENAI_API_KEY", + filename: "verification.pdf", + maxTokens: 40, + model: openai.responses("gpt-4o-mini"), + }, + { + id: "anthropic", + name: "Anthropic Haiku 4.5", + provider: "anthropic", + protocol: "anthropic-messages", + requires: "ANTHROPIC_API_KEY", + filename: "verification.pdf", + maxTokens: 40, + model: anthropic.model("claude-haiku-4-5-20251001"), + }, + { + id: "gemini", + name: "Gemini 3.5 Flash", + provider: "google", + protocol: "gemini", + requires: "GOOGLE_API_KEY", + filename: "verification.pdf", + maxTokens: 256, + model: google.model("gemini-3.5-flash"), + }, + { + id: "xai", + name: "xAI Grok 4.5", + provider: "xai", + protocol: "openai-responses", + requires: "XAI_API_KEY", + filename: "verification.pdf", + maxTokens: 40, + model: xai.responses("grok-4.5"), + }, + { + id: "bedrock", + name: "Bedrock Claude Haiku 4.5", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: "AWS_BEDROCK_API_KEY", + filename: "verification", + maxTokens: 40, + model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"), + }, +] + +const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] }) +const prompt = "Return only the verification code from the PDF." +const readPdf = ToolDefinition.make({ + name: "read_pdf", + description: "Read the attached PDF.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, +}) +const readPdfRuntime = Tool.make({ + description: readPdf.description, + parameters: Schema.Struct({ path: Schema.String }), + success: Schema.String, + execute: () => Effect.succeed("PDF read successfully"), + toModelOutput: () => [ + { type: "text", text: "PDF read successfully" }, + { + type: "file", + uri: `data:application/pdf;base64,${PDF}`, + mime: "application/pdf", + name: "verification.pdf", + }, + ], +}) + +const expectCode = (response: LLMResponse) => { + expect(response.finishReason).toBe("stop") + expect(response.text.toUpperCase()).toContain(CODE) +} + +describe("PDF recorded", () => { + for (const target of targets) { + recorded.effect.with( + `reads a user PDF with ${target.name}`, + { + id: `${target.id}-user-input`, + provider: target.provider, + protocol: target.protocol, + requires: [target.requires], + tags: ["user-input"], + }, + Effect.gen(function* () { + expectCode( + yield* LLMClient.generate( + LLM.request({ + id: `recorded_pdf_${target.id}_user_input`, + model: target.model, + cache: "none", + generation: { maxTokens: target.maxTokens, temperature: 0 }, + messages: [ + Message.user([ + { type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename }, + { type: "text", text: prompt }, + ]), + ], + }), + ), + ) + }), + ) + + recorded.effect.with( + `reads a PDF tool result with ${target.name}`, + { + id: `${target.id}-tool-result`, + provider: target.provider, + protocol: target.protocol, + requires: [target.requires], + tags: ["tool", "tool-result"], + }, + Effect.gen(function* () { + if (target.id === "gemini") { + const events = Array.from( + yield* runTools({ + request: LLM.request({ + id: "recorded_pdf_gemini_tool_result", + model: target.model, + system: + "Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.", + prompt: "Use read_pdf with path verification.pdf and return the verification code.", + cache: "none", + generation: { maxTokens: target.maxTokens, temperature: 0 }, + }), + tools: { read_pdf: readPdfRuntime }, + }).pipe(Stream.runCollect), + ) + expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) + expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE) + return + } + + expectCode( + yield* LLMClient.generate( + LLM.request({ + id: `recorded_pdf_${target.id}_tool_result`, + model: target.model, + system: "Read the PDF returned by the tool and follow the user's response format exactly.", + cache: "none", + generation: { maxTokens: target.maxTokens, temperature: 0 }, + messages: [ + Message.user(prompt), + Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]), + Message.tool({ + id: "call_pdf_1", + name: readPdf.name, + resultType: "content", + result: [ + { type: "text", text: "PDF read successfully" }, + { + type: "file", + uri: `data:application/pdf;base64,${PDF}`, + mime: "application/pdf", + name: target.filename, + }, + ], + }), + ], + tools: [readPdf], + }), + ), + ) + }), + ) + } +}) diff --git a/packages/ai/test/tool-runtime.test.ts b/packages/ai/test/tool-runtime.test.ts index c03a18bd8a..e6e9788719 100644 --- a/packages/ai/test/tool-runtime.test.ts +++ b/packages/ai/test/tool-runtime.test.ts @@ -183,6 +183,51 @@ describe("LLMClient tools", () => { }), ) + it.effect("preserves provider metadata on dispatched tool results", () => + Effect.gen(function* () { + const tool = Tool.make({ + description: "Return text.", + parameters: Schema.Struct({}), + success: Schema.String, + execute: () => Effect.succeed("hello"), + }) + const providerMetadata = { google: { functionCallId: "provider_call" } } + const dispatched = yield* ToolRuntime.dispatch( + { tool }, + LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }), + ) + + expect(dispatched.events).toEqual([ + LLMEvent.toolResult({ + id: "call_1", + name: "tool", + result: { type: "text", value: "hello" }, + output: { structured: "hello", content: [{ type: "text", text: "hello" }] }, + providerMetadata, + }), + ]) + + const failed = yield* ToolRuntime.dispatch( + {}, + LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }), + ) + expect(failed.events).toEqual([ + LLMEvent.toolError({ + id: "call_2", + name: "missing", + message: "Unknown tool: missing", + providerMetadata, + }), + LLMEvent.toolResult({ + id: "call_2", + name: "missing", + result: { type: "error", value: "Unknown tool: missing" }, + providerMetadata, + }), + ]) + }), + ) + it.effect("uses the narrow default projection for encoded typed success", () => Effect.gen(function* () { const text = Tool.make({ diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ace585e6a..acf8ec75dd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,7 +11,6 @@ "bin" ], "exports": { - "./daemon": "./src/daemon.ts", "./run": "./src/run/index.ts", "./server-process": "./src/server-process.ts" }, diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index fd0f5cdb18..7312483dc9 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import pkg from "../package.json" import { Script } from "@opencode-ai/script" import { fileURLToPath } from "url" +import { UpdateArtifact } from "../../../script/update-artifact" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) @@ -81,3 +82,10 @@ await publishDistribution({ binary: "opencode2-node", packagePrefix: "@opencode-ai/cli-node-", }) +await UpdateArtifact.publish({ + channel: Script.channel, + name: "cli", + distribution: "npm", + version: Script.version, + metadata: {}, +}) diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 11bf4c11c5..da8bfe168b 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -198,8 +198,8 @@ export async function streamTurn(input: { toolCallId: event.data.callID, toolName: current.name, input: current.input, - structured: current.structured, - content: current.content, + structured: event.data.metadata ?? current.structured, + content: event.data.content ?? current.content, error: event.data.error.message, cwd: input.cwd, }), diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index ff68dd93ef..f20f31c9bc 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -38,7 +38,7 @@ import type { SetSessionModeRequest, SetSessionModeResponse, } from "@agentclientprotocol/sdk" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_VERSION } from "../version" import { SessionMessage } from "@opencode-ai/schema/session-message" import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option" import { promptContentToParts } from "./content" @@ -176,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, }, authMethods: [authMethod], - agentInfo: { name: "OpenCode", version: InstallationVersion }, + agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, } }, authenticate: async (params) => { diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a287c3c9ec..c0b914fc62 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDescription("Session ID to continue"), Flag.optional, ), + prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), }, commands: [ Spec.make("acp", { description: "Start an Agent Client Protocol server" }), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 325f2c2b3a..f83a0ef7cb 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode-ai/util/npm" +import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) => const runPromise = Effect.runPromiseWith(context) const service = server.service yield* run({ + app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL }, server: { endpoint: server.endpoint, service: service @@ -53,7 +55,11 @@ export default Runtime.handler(Commands, (input) => } : undefined, }, - args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + args: { + continue: input.continue, + sessionID: Option.getOrUndefined(input.session), + prompt: Option.getOrUndefined(input.prompt), + }, config: { path: config.path, get: () => runPromise(config.get()), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5140909cc2..ecfbd50282 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,13 +1,12 @@ #!/usr/bin/env bun import { NodeRuntime, NodeServices } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Observability } from "@opencode-ai/util/observability" -import { Client } from "@opencode-ai/util/client" import { Updater } from "./services/updater" -import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version" +import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" @@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, { }) Effect.logInfo("cli starting", { - version: InstallationVersion, - channel: InstallationChannel, - local: InstallationLocal, + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, + local: OPENCODE_LOCAL, args: process.argv.slice(2), }).pipe( - Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), + Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })), Effect.annotateLogs({ role: "cli" }), Effect.provide(Config.layer), Effect.provide(Updater.layer), @@ -74,7 +73,10 @@ Effect.logInfo("cli starting", { Observability.layer({ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, - }).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))), + client: process.env.OPENCODE_CLIENT ?? "cli", + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, + }), ), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index ff578099d7..049f08a24e 100644 --- a/packages/cli/src/mini.ts +++ b/packages/cli/src/mini.ts @@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { MiniFrontendInput } from "@opencode-ai/tui/mini" import { setTimeout } from "node:timers/promises" -import { waitForCatalogReady } from "./services/catalog" import { readStdin } from "./util/io" import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host" import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target" @@ -214,63 +213,7 @@ function parseModel(value?: string) { } function prepareTarget(requestedAgent?: string): SessionTargetPreparation { - return async (input) => { - if (input.model) - await waitForCatalogReady({ - sdk: input.client, - directory: input.location.directory, - workspace: input.location.workspaceID, - model: { providerID: input.model.providerID, modelID: input.model.id }, - signal: input.signal, - }) - return { - model: input.model, - agent: requestedAgent - ? await validateAgent( - input.client, - input.location.directory, - input.location.workspaceID, - requestedAgent, - input.signal, - ) - : input.agent, - } - } -} - -async function validateAgent( - sdk: OpenCodeClient, - directory: string, - workspace: string | undefined, - name?: string, - signal?: AbortSignal, -) { - if (!name) return - const deadline = Date.now() + 5_000 - let agents: Awaited> | undefined - while (Date.now() < deadline && !signal?.aborted) { - agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => { - if (signal && error instanceof ClientError && error.reason === "Transport") throw error - return undefined - }) - const agent = agents?.data.find((item) => item.id === name) - if (agent?.mode === "subagent") { - warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) - return - } - if (agent) return name - await setTimeout(25, undefined, { signal }).catch(() => {}) - } - if (signal?.aborted) return - if (!agents) { - warning("failed to list agents. Falling back to default agent") - return - } - warning(`agent "${name}" not found. Falling back to default agent`) -} - -function warning(message: string) { - process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`) + return async (input) => ({ model: input.model, agent: requestedAgent ?? input.agent }) } function fail(message: string): never { diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index 102294f64b..41859d3b64 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -211,10 +211,17 @@ export async function runNonInteractivePrompt(input: Input) { } if (!promoted && event.type === "session.execution.failed") { prePromotionError = event.data.error + if (finalizing) return continue } + if ( + !promoted && + finalizing && + (event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted") + ) + return if (!promoted) continue - if (finalizing) continue + if (finalizing && !event.type.startsWith("session.execution.")) continue if (event.type === "session.step.started") { const part = { @@ -391,6 +398,8 @@ export async function runNonInteractivePrompt(input: Input) { const key = toolKey(event.data.assistantMessageID, event.data.callID) const current = tools.get(key) ?? fallbackTool(event) const error = event.data.error.message + const structured = event.data.metadata ?? current.structured + const content = event.data.content ?? current.content const tool: SessionMessageAssistantTool = { type: "tool", id: event.data.callID, @@ -401,8 +410,8 @@ export async function runNonInteractivePrompt(input: Input) { state: { status: "error", input: current.input, - structured: current.structured, - content: current.content, + structured, + content, error: event.data.error, result: event.data.result, }, @@ -432,14 +441,14 @@ export async function runNonInteractivePrompt(input: Input) { renderedTools.add(key) if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue if (!emit("tool_use", time, { part })) { - if (toolOutputText(current.tool, current.content).trim()) + if (toolOutputText(current.tool, content).trim()) await input.renderTool({ ...tool, state: { status: "completed", input: current.input, - structured: current.structured, - content: current.content, + structured, + content, result: event.data.result, }, }) @@ -618,7 +627,10 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message) } } - return projected.found + return { + found: projected.found, + responded: projected.messages.some((message) => message.type === "assistant"), + } } const interrupt = () => { @@ -708,9 +720,18 @@ export async function runNonInteractivePrompt(input: Input) { const waiting = input.client.session.wait({ sessionID: input.sessionID }) await Promise.race([waiting, completed.then(() => waiting)]) finalizing = true - controller.abort() - const found = await reconcile() - if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) { + const projected = await reconcile() + if ( + !projected.responded && + !interrupted && + !permissionRejected && + !formCancelled && + !emittedError && + !prePromotionError + ) { + await completed + } + if (!projected.found && !interrupted && !permissionRejected && !formCancelled && !emittedError) { const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" } emittedError = true process.exitCode = 1 diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index 496bdfa86d..4342bb329f 100644 --- a/packages/cli/src/run/run.ts +++ b/packages/cli/src/run/run.ts @@ -5,7 +5,6 @@ import { open } from "node:fs/promises" import path from "node:path" import { readStdin } from "../util/io" import { ServerConnection } from "../services/server-connection" -import { waitForCatalogReady } from "../services/catalog" import { parseSessionTargetModel, resolveSessionTarget } from "../session-target" import { toolInlineInfo } from "@opencode-ai/tui/mini/tool" import { runNonInteractivePrompt } from "./noninteractive" @@ -95,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End prepare: async (next) => { const selected = next.model ?? - (await client.model - .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) - .then((result) => result.data)) + (options.variant + ? await client.model + .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) + .then((result) => result.data) + : undefined) const model = selected ? { providerID: selected.providerID, @@ -107,25 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End : undefined if ((options.variant ?? explicit?.variant) && !model) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) - if (model) { - await waitForCatalogReady({ - sdk: client, - directory: next.location.directory, - workspace: next.location.workspaceID, - model: { providerID: model.providerID, modelID: model.id }, - }) - const available = await client.model.list({ - location: { directory: next.location.directory, workspace: next.location.workspaceID }, - }) - if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id)) - throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id) - } - return { - model, - agent: input.agent - ? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent) - : next.agent, - } + return { model, agent: next.agent } }, }).catch((error) => { if (!(error instanceof RunTargetError)) throw error @@ -190,28 +173,6 @@ export function parseRunModel(value?: string) { } } -async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) { - if (!name) return - const agents = await client.agent - .list({ location: { directory, workspace } }) - .then((result) => result.data) - .catch(() => undefined) - if (!agents) { - warning("failed to list agents. Falling back to default agent") - return - } - const agent = agents.find((item) => item.id === name) - if (!agent) { - warning(`agent "${name}" not found. Falling back to default agent`) - return - } - if (agent.mode === "subagent") { - warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) - return - } - return name -} - async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise { const file = path.resolve(directory, input) const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index ecfacc66ac..273b11a0da 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node" import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version" import { AppProcess } from "@opencode-ai/util/process" import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" @@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { const instanceID = randomUUID() const server = yield* start( { - client: process.env.OPENCODE_CLIENT ?? "cli", + app: { + name: process.env.OPENCODE_CLIENT ?? "cli", + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, + }, hostname, port, password, @@ -77,11 +81,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { database: { path: process.env.OPENCODE_DB ?? - (["latest", "beta", "prod"].includes(InstallationChannel) || + (["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) || process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" ? "opencode.db" - : `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`), + : `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`), }, models: { url: process.env.OPENCODE_MODELS_URL, @@ -173,7 +177,7 @@ const register = Effect.fnUntraced(function* ( yield* fs.makeDirectory(path.dirname(file), { recursive: true }) const info = { id, - version: InstallationVersion, + version: OPENCODE_VERSION, url: HttpServer.formatAddress(address), pid: process.pid, password, diff --git a/packages/cli/src/services/catalog.ts b/packages/cli/src/services/catalog.ts deleted file mode 100644 index ddf8252c31..0000000000 --- a/packages/cli/src/services/catalog.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise" - -// Location plugins initialize asynchronously, so explicit model selection must -// wait for that exact model before prompt admission. The execution path owns -// the authoritative error if readiness times out. -export async function waitForCatalogReady(input: { - sdk: OpenCodeClient - directory: string - workspace?: string - model: { providerID: string; modelID: string } - timeoutMs?: number - signal?: AbortSignal -}) { - const deadline = Date.now() + (input.timeoutMs ?? 5_000) - while (Date.now() < deadline && !input.signal?.aborted) { - const models = await input.sdk.model - .list( - { location: { directory: input.directory, workspace: input.workspace } }, - { signal: input.signal }, - ) - .then((result) => result.data) - .catch((error) => { - if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error - return undefined - }) - if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return - await wait(25, input.signal) - } -} - -function wait(delay: number, signal?: AbortSignal) { - if (!signal) return new Promise((resolve) => setTimeout(resolve, delay)) - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, delay) - signal.addEventListener("abort", done, { once: true }) - function done() { - clearTimeout(timer) - signal?.removeEventListener("abort", done) - resolve() - } - }) -} diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 70bd2fcda2..6f854eefb2 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -1,6 +1,6 @@ import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_VERSION } from "../version" import { Effect, Redacted } from "effect" import { Env } from "../env" import { ServiceConfig } from "./service-config" @@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), catch: (cause) => connectError(endpoint, cause), }) - if (health.version !== InstallationVersion) + if (health.version !== OPENCODE_VERSION) process.stderr.write( - `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, + `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`, ) return { endpoint } satisfies Resolved } diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index b96bb578c8..57c3102993 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -1,5 +1,5 @@ import { Global } from "@opencode-ai/util/global" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version" import { Hash } from "@opencode-ai/util/hash" import { Service } from "@opencode-ai/client/effect/service" import { Effect, FileSystem, Option, Schema } from "effect" @@ -24,26 +24,26 @@ type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) -export function filename(channel = InstallationChannel) { +export function filename(channel = OPENCODE_CHANNEL) { if (channel === "latest" || channel === "next") return "service.json" return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` } -export function defaultPort(channel = InstallationChannel) { +export function defaultPort(channel = OPENCODE_CHANNEL) { if (channel === "latest" || channel === "next") return 0xc0de if (channel === "local") return 0xc0df return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000) } -export function legacyFilename(channel = InstallationChannel) { +export function legacyFilename(channel = OPENCODE_CHANNEL) { if (channel === "latest" || channel === "local") return return `service-${Hash.fast(channel)}.json` } export function versionBelongsToChannel( version: string | undefined, - channel = InstallationChannel, - installedVersion = InstallationVersion, + channel = OPENCODE_CHANNEL, + installedVersion = OPENCODE_VERSION, ) { if (version === undefined) return false if (version === installedVersion) return true @@ -55,8 +55,8 @@ export function versionBelongsToChannel( export const migrateRegistration = Effect.fnUntraced(function* ( legacy: string, file: string, - channel = InstallationChannel, - installedVersion = InstallationVersion, + channel = OPENCODE_CHANNEL, + installedVersion = OPENCODE_VERSION, ) { const fs = yield* FileSystem.FileSystem const text = yield* fs.readFileString(legacy).pipe(Effect.option) @@ -92,7 +92,7 @@ const paths = Effect.gen(function* () { legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined, legacyRegistrationFiles: [ ...(legacy ? [path.join(global.state, legacy)] : []), - ...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []), + ...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []), ], configFile: path.join(global.config, name), } @@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () { yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file)) return { file, - version: InstallationVersion, + version: OPENCODE_VERSION, command: [...selfCommand(), "serve", "--service"], } }) diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index 87a4c46ed7..9deb28bae8 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -3,7 +3,7 @@ // version-mismatched background service before the TUI attaches. import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core" import { render, useTerminalDimensions } from "@opentui/solid" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_VERSION } from "../version" import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" import { go } from "@opencode-ai/tui/logo" @@ -356,12 +356,12 @@ function UpdateFooter(props: { ] as const) : []), ["to", colors.muted], - [InstallationVersion, colors.accent], + [OPENCODE_VERSION, colors.accent], ) const completedHeader = phrase( ["OpenCode", colors.muted, true], ["updated to", colors.muted], - [InstallationVersion, colors.accent], + [OPENCODE_VERSION, colors.accent], ) const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted]) const outcomeStatus = () => diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index a1b10b699a..08b5cd3b7d 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,10 +1,6 @@ import { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" -import { - InstallationChannel, - InstallationLocal, - InstallationVersion, -} from "@opencode-ai/util/installation/version" +import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" import { Context, Duration, Effect, FileSystem, Layer } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" @@ -52,7 +48,7 @@ export const layer = Layer.effect( const fs = yield* FileSystem.FileSystem const global = yield* Global.Service const appProcess = yield* AppProcess.Service - const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") + const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") const readPolicy = Effect.fnUntraced(function* () { const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) => @@ -99,8 +95,8 @@ export const layer = Layer.effect( const response = yield* Effect.tryPromise({ try: () => fetch( - `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`, - { headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) }, + `https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, + { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) }, ), catch: (cause) => new Error("Failed to check for updates", { cause }), }) @@ -138,13 +134,13 @@ export const layer = Layer.effect( const check = Effect.fn("cli.updater.check")(function* () { if ( - InstallationLocal || + OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "") ) return yield* Effect.logInfo("update check skipped", { - reason: InstallationLocal ? "local-install" : "disabled", - version: InstallationVersion, - channel: InstallationChannel, + reason: OPENCODE_LOCAL ? "local-install" : "disabled", + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, }) const policy = yield* readPolicy() if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" }) @@ -152,15 +148,15 @@ export const layer = Layer.effect( return yield* Effect.gen(function* () { const version = yield* latest() yield* Effect.logInfo("update check", { - current: InstallationVersion, + current: OPENCODE_VERSION, latest: version, }) - const next = action(InstallationVersion, version, policy) + const next = action(OPENCODE_VERSION, version, policy) if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" }) 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: InstallationVersion, to: version, method: detected }) + yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected }) }) }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause }))) diff --git a/packages/cli/src/session-target.ts b/packages/cli/src/session-target.ts index 40412473ac..975f4829b2 100644 --- a/packages/cli/src/session-target.ts +++ b/packages/cli/src/session-target.ts @@ -74,7 +74,7 @@ export async function resolveSessionTarget(input: { session, location, model: prepared.model, - agent: prepared.agent, + agent: prepared.agent ?? session.agent, resume: selected !== undefined, } } diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000000..04c3fb3f39 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,8 @@ +declare const OPENCODE_VERSION: string +declare const OPENCODE_CHANNEL: string + +const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" +const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" + +export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL } +export const OPENCODE_LOCAL = channel === "local" diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 9de17cf4e1..1f9935f5c8 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -214,7 +214,7 @@ describe("acp event behavior", () => { }), ) send( - durableEvent("session.tool.progress", { + ephemeralEvent("session.tool.progress", { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_ok", @@ -251,7 +251,7 @@ describe("acp event behavior", () => { }), ) send( - durableEvent("session.tool.progress", { + ephemeralEvent("session.tool.progress", { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_fail", @@ -265,6 +265,8 @@ describe("acp event behavior", () => { assistantMessageID: "msg_tools", callID: "call_fail", error: { type: "tool.error", message: "not found" }, + metadata: { bytes: 0 }, + content: [{ type: "text", text: "opening" }], executed: true, }), ) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 6af4b66d26..a0e32c4aa8 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => { const service = yield* Config.Service return yield* service.update((draft) => { draft.prompt = { paste: "compact" } - draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true } + draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true } }) }), ) @@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => { expect(config).toEqual({ animations: true, prompt: { paste: "compact" }, - mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }, + mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }, }) expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment") } finally { diff --git a/packages/cli/test/drive/mini-interactive.drive.mjs b/packages/cli/test/drive/mini-interactive.drive.mjs index e745c481b4..02d0772ef6 100644 --- a/packages/cli/test/drive/mini-interactive.drive.mjs +++ b/packages/cli/test/drive/mini-interactive.drive.mjs @@ -16,7 +16,30 @@ export default defineScript({ const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli")) const session = `mini-stage2-${process.pid}` const snapshots = path.join(artifacts, "mini-stage2") - yield* Effect.promise(() => mkdir(snapshots, { recursive: true })) + const explicitDirectory = path.join(artifacts, "explicit-model") + yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true })))) + /** @param {string} directory @param {string | undefined} model */ + const mini = (directory, model) => [ + "env", + `PWD=${directory}`, + `OPENCODE_PASSWORD=${registration.password}`, + `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`, + `OPENCODE_TEST_HOME=${artifacts}`, + `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`, + `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`, + `XDG_DATA_HOME=${path.join(artifacts, "logs")}`, + `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`, + "OPENCODE_DISABLE_AUTOUPDATE=1", + "OPENCODE_DIRECT_TRACE=1", + process.execPath, + "--conditions=browser", + `--preload=${preload}`, + path.join(root, "packages/cli/src/index.ts"), + "mini", + "--server", + registration.url, + ...(model ? ["--model", model] : []), + ] yield* llm.queue( Llm.toolCall({ @@ -42,26 +65,7 @@ export default defineScript({ "-y", "30", "--", - "env", - `PWD=${path.join(artifacts, "files")}`, - `OPENCODE_PASSWORD=${registration.password}`, - `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`, - `OPENCODE_TEST_HOME=${artifacts}`, - `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`, - `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`, - `XDG_DATA_HOME=${path.join(artifacts, "logs")}`, - `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`, - "OPENCODE_DISABLE_AUTOUPDATE=1", - "OPENCODE_DIRECT_TRACE=1", - process.execPath, - "--conditions=browser", - `--preload=${preload}`, - path.join(root, "packages/cli/src/index.ts"), - "mini", - "--server", - registration.url, - "--model", - "simulation/gpt-sim-model", + ...mini(path.join(artifacts, "files"), undefined), ]), ), ) @@ -72,7 +76,16 @@ export default defineScript({ if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission") - yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000)) + yield* Effect.promise(() => waitForPane(session, "Default model", 15_000)) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Commands")) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Switch model")) + yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Select model")) + yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000)) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything...")) yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])) yield* Effect.sleep(100) yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"])) @@ -134,7 +147,7 @@ export default defineScript({ yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"])) yield* Effect.promise(() => waitForPane(session, "$ sleep 10")) yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"])) - const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt")) + const armed = yield* Effect.promise(() => waitForPane(session, "esc again")) yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)) yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"])) const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000)) @@ -144,7 +157,7 @@ export default defineScript({ if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn") }) yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) - yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit")) + yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+")) yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) yield* Effect.promise(() => waitForDeadPane(session)) const status = yield* Effect.promise(() => paneDeadStatus(session)) @@ -153,6 +166,75 @@ export default defineScript({ if (!exited.includes("Continue") || !exited.includes("opencode mini -s")) throw new Error("Mini exit splash was not rendered before teardown") yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)) + + yield* Effect.promise(() => tmux(["clear-history", "-t", session])) + yield* Effect.promise(() => + tmux([ + "respawn-pane", + "-k", + "-t", + session, + "--", + ...mini(explicitDirectory, "simulation/gpt-sim-model"), + ]), + ) + const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000)) + yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel)) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) + yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+")) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) + yield* Effect.promise(() => waitForDeadPane(session)) + if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0) + throw new Error("Explicit-model Mini did not exit cleanly") + + yield* Effect.promise(async () => { + for (const failure of [ + { + args: ["--model", "simulation/definitely-missing"], + capture: "08-unavailable-model.txt", + expected: "Model unavailable: simulation/definitely-missing", + }, + { + args: ["--agent", "definitely-missing"], + capture: "09-unavailable-agent.txt", + expected: 'Agent not found: "definitely-missing"', + }, + ]) { + const child = Bun.spawn( + [ + process.execPath, + path.join(root, "packages/cli/src/index.ts"), + "run", + "--server", + registration.url, + ...failure.args, + "optimistic selection check", + ], + { + cwd: path.join(root, "packages/cli"), + env: { + ...process.env, + PWD: path.join(artifacts, "files"), + OPENCODE_PASSWORD: registration.password, + OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"), + OPENCODE_DISABLE_AUTOUPDATE: "1", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + await Bun.write(path.join(snapshots, failure.capture), stdout + stderr) + if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`) + if (!stderr.includes(failure.expected)) + throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`) + } + }) }) yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true)))) @@ -188,6 +270,19 @@ function captureVisiblePane(session) { return tmux(["capture-pane", "-p", "-t", session]) } +/** @param {string} session @param {string} text @param {number} [timeout] */ +async function waitForVisiblePane(session, text, timeout = 5_000) { + const deadline = Date.now() + timeout + let last = "" + while (Date.now() < deadline) { + last = await captureVisiblePane(session) + if (last.includes(text)) return last + if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`) + await Bun.sleep(50) + } + throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`) +} + /** @param {string} session */ async function paneAlive(session) { return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0" diff --git a/packages/cli/test/import-boundaries.test.ts b/packages/cli/test/import-boundaries.test.ts index f0eef3cbd8..8b189ce2c0 100644 --- a/packages/cli/test/import-boundaries.test.ts +++ b/packages/cli/test/import-boundaries.test.ts @@ -18,10 +18,12 @@ describe("CLI frontend import boundaries", () => { test("exposes only the intentional package entrypoints", async () => { const run = await import("@opencode-ai/cli/run") const mini = await import("@opencode-ai/tui/mini") + const tool = await import("@opencode-ai/tui/mini/tool") const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json() expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"]) expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"]) + expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"]) expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([]) }) diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 50af6e205f..34cf2a2cd2 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { ClientError, OpenCode } from "@opencode-ai/client/promise" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_VERSION } from "../src/version" import path from "node:path" import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini" import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run" @@ -31,14 +31,14 @@ describe("mini command", () => { const initial = Bun.serve({ port: 0, fetch() { - return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }) }, }) const replacement = Bun.serve({ port: 0, fetch(request) { authorization.push(request.headers.get("authorization")) - return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }) }, }) const controller = new AbortController() @@ -121,11 +121,12 @@ describe("mini command", () => { expect(result.stdout).toContain("run Run OpenCode with a message") }) - test("exposes run without legacy attach or command modes", async () => { + test("exposes run without legacy interactive, attach, or command modes", async () => { const result = await cli(["run", "--help"]) expect(result.exitCode).toBe(0) expect(result.stdout).toContain("--server string") + expect(result.stdout).not.toContain("--interactive") expect(result.stdout).not.toContain("--variant") expect(result.stdout).not.toContain("--attach") expect(result.stdout).not.toContain("--command") @@ -138,32 +139,47 @@ describe("mini command", () => { expect(result.stderr).not.toContain("You must provide a message") }) - test("preserves a run failure exit code", async () => { - let modelRequests = 0 + test("passes explicit selections to session creation without catalog preflight", async () => { + const requests: string[] = [] + let session: unknown const server = Bun.serve({ port: 0, - fetch(request) { + async fetch(request) { const url = new URL(request.url) + requests.push(url.pathname) if (url.pathname === "/api/health") - return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }) if (url.pathname === "/api/location") return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } }) - if (url.pathname === "/api/model") { - modelRequests++ - return Response.json({ - location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } }, - data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [], - }) + if (url.pathname === "/api/session") { + session = await request.json() + return new Response("boom", { status: 500 }) } return new Response(undefined, { status: 404 }) }, }) try { - const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"]) + const result = await cli([ + "run", + "--server", + server.url.toString(), + "--model", + "definitely/missing", + "--agent", + "definitely-missing", + "hi", + ]) expect(result.exitCode).toBe(1) - expect(result.stderr).toContain("Model unavailable: definitely/missing") + expect(result.stderr).toContain("UnexpectedStatus") + expect(session).toMatchObject({ + agent: "definitely-missing", + model: { providerID: "definitely", id: "missing" }, + }) + expect(requests).not.toContain("/api/model") + expect(requests).not.toContain("/api/agent") + expect(requests).not.toContain("/api/location/wait") } finally { server.stop(true) } @@ -197,6 +213,7 @@ describe("mini command", () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain("--server string") + expect(result.stdout).toContain("--prompt string") expect(result.stdout).not.toContain("SUBCOMMANDS") }) diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index f27869a991..f0a680061f 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -130,7 +130,6 @@ function failedTool(inputID: string): V2Event[] { id: "evt_failed_tool_progress", created: 3, type: "session.tool.progress", - durable: { aggregateID: "ses_1", seq: 3, version: 1 }, data: { sessionID: "ses_1", assistantMessageID: "msg_failed_tool", @@ -149,6 +148,8 @@ function failedTool(inputID: string): V2Event[] { assistantMessageID: "msg_failed_tool", callID: "call_failed_tool", error: { type: "unknown", message: "tool failed" }, + metadata: { checkpoint: 1 }, + content: [{ type: "text", text: "partial output" }], executed: true, }, }, @@ -156,6 +157,53 @@ function failedTool(inputID: string): V2Event[] { ] } +function successfulGrep(inputID: string): V2Event[] { + const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle" + return [ + prompted(inputID), + { + id: "evt_grep_input", + created: 1, + type: "session.tool.input.started", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_grep", + callID: "call_grep", + name: "grep", + }, + }, + { + id: "evt_grep_called", + created: 2, + type: "session.tool.called", + durable: { aggregateID: "ses_1", seq: 2, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_grep", + callID: "call_grep", + input: { pattern: "needle" }, + executed: true, + }, + }, + { + id: "evt_grep_success", + created: 3, + type: "session.tool.success", + durable: { aggregateID: "ses_1", seq: 3, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_grep", + callID: "call_grep", + structured: { matches: 2 }, + content: [{ type: "text", text }], + executed: false, + }, + }, + settled(), + ] +} + // Runs one non-interactive prompt against a mocked SDK. `turn` produces the // live events the prompt admission triggers, keyed by the generated message ID. async function run(input: { @@ -169,6 +217,7 @@ async function run(input: { renderToolError?: (part: SessionMessageAssistantTool) => Promise messages?: (inputID: string) => SessionMessageInfo[] wait?: () => Promise + terminalDelay?: number }) { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] @@ -183,7 +232,10 @@ async function run(input: { }) continue } - if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0) + if (value.type.startsWith("session.execution.")) { + if (input.terminalDelay) await Bun.sleep(input.terminalDelay) + setTimeout(wait.resolve, 0) + } yield value } })() @@ -251,7 +303,7 @@ async function capture(input: Parameters[0]) { }) try { await run(input) - return { stdout: stdout.join(""), stderr: stderr.join("") } + return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode } } finally { process.exitCode = exitCode ?? 0 stdoutWrite.mockRestore() @@ -264,6 +316,32 @@ afterEach(() => { }) describe("runNonInteractivePrompt", () => { + test("keeps formatted tool output and compact structured metadata in JSON", async () => { + const output = await capture({ format: "json", turn: successfulGrep }) + const events = output.stdout + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + type: "tool_use", + part: { + tool: "grep", + state: { + status: "completed", + output: expect.stringContaining("Found 2 matches"), + metadata: { + structured: { matches: 2 }, + content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }], + }, + }, + }, + }) + expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 }) + expect(events[0].part.state.metadata.result).toBeUndefined() + }) + test("uses session.wait then reconciles projected output without a terminal event", async () => { const idle = Promise.withResolvers() let done = false @@ -319,6 +397,26 @@ describe("runNonInteractivePrompt", () => { error: { type: "provider.transport", message: "instructions unavailable" }, }), ]) + expect(output.exitCode).toBe(1) + }) + + test("waits for a terminal failure when idle wins before projection", async () => { + for (const promotedBeforeFailure of [true, false]) { + const output = await capture({ + format: "json", + turn: (messageID) => [ + ...(promotedBeforeFailure ? [prompted(messageID)] : []), + executionFailed("selection unavailable"), + ], + messages: (messageID) => + promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [], + wait: () => Promise.resolve(), + terminalDelay: 10, + }) + + expect(output.exitCode).toBe(1) + expect(output.stdout).toContain("selection unavailable") + } }) test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => { @@ -413,14 +511,14 @@ describe("runNonInteractivePrompt", () => { ], }) - expect(output).toEqual({ stdout: "", stderr: "" }) + expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 }) }) - test("renders native failed tool output before the terminal error", async () => { + test("renders a native terminal failure snapshot when live progress was missed", async () => { const rendered: SessionMessageAssistantTool[] = [] const failed: SessionMessageAssistantTool[] = [] await capture({ - turn: failedTool, + turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"), renderTool: (part) => { rendered.push(part) return Promise.resolve() diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts index c5407f7f32..f87854a7a1 100644 --- a/packages/cli/test/server-connection.test.ts +++ b/packages/cli/test/server-connection.test.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_VERSION } from "../src/version" import { expect, test } from "bun:test" import { Effect, FileSystem, Scope } from "effect" import fs from "node:fs/promises" @@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed fetch() { return Response.json({ healthy: true, - version: InstallationVersion, + version: OPENCODE_VERSION, pid: process.pid, }) }, @@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed registration, JSON.stringify({ id, - version: InstallationVersion, + version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, }), diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 801fe5ad77..3e29d6e443 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { Service, type Info } from "@opencode-ai/client/effect/service" import { Global } from "@opencode-ai/util/global" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { OPENCODE_VERSION } from "../src/version" import { expect, test } from "bun:test" import { Effect, Schema } from "effect" import fs from "node:fs/promises" @@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async () ) const stale = { id: "stale", - version: InstallationVersion, + version: OPENCODE_VERSION, url: "http://127.0.0.1:1", pid: process.pid, password: "stale", @@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a fetch() { requests.count += 1 if (requests.count === 2) recognizing.resolve() - return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 }) + return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 }) }, }) const registration = path.join(root, "state", "opencode", "service-local.json") @@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a registration, JSON.stringify({ id: "stale", - version: InstallationVersion, + version: OPENCODE_VERSION, url: "http://127.0.0.1:1", pid: 2_147_483_647, password: "stale", @@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a await Bun.sleep(8_000) const info = { id: "incumbent", - version: InstallationVersion, + version: OPENCODE_VERSION, url: `http://127.0.0.1:${listener.port}`, pid: process.pid, password: "incumbent", diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts index fef8faf72a..ea34aada7f 100644 --- a/packages/cli/test/session-target.test.ts +++ b/packages/cli/test/session-target.test.ts @@ -78,6 +78,15 @@ describe("session target resolver", () => { expect(order).toEqual(["prepare", "create"]) }) + test("uses the agent resolved by the server for a fresh Session", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(client.location, "get").mockResolvedValue(location("/project")) + spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" }) + + const target = await resolveSessionTarget({ client, prepare }) + expect(target.agent).toBe("review") + }) + test("does not retry an ambiguous Session creation", async () => { const client = OpenCode.make({ baseUrl: "https://opencode.test" }) spyOn(client.location, "get").mockResolvedValue(location("/project")) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 6664c1424c..6c869b0e2e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -163,6 +163,8 @@ export type SessionMessageProviderState7 = { [x: string]: any } export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number } +export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {}) + export type ModelCapabilities = { tools: boolean; input: Array; output: Array } export type ModelVariant = { @@ -1094,7 +1096,7 @@ export type TuiCommandExecute = { | "prompt.clear" | "prompt.submit" | "agent.cycle" - | string + | (string & {}) } } @@ -1385,23 +1387,7 @@ export type SessionToolCalled = { } } -export type SessionToolFailed = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.tool.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: LocationRef - data: { - sessionID: string - assistantMessageID: string - callID: string - error: SessionStructuredError - result?: any - executed: boolean - resultState?: SessionMessageProviderState7 - } -} +export type ModelCompatibility = { reasoningField?: ModelReasoningField } export type ModelCost = { tier?: { type: "context"; size: number } @@ -1847,22 +1833,6 @@ export type SessionMessageToolStateError = { result?: JsonValue } -export type SessionToolProgress = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.tool.progress" - durable: { aggregateID: string; seq: number; version: 1 } - location?: LocationRef - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { [x: string]: any } - content: Array - } -} - export type SessionToolSuccess = { id: string created: number @@ -1882,6 +1852,41 @@ export type SessionToolSuccess = { } } +export type SessionToolFailed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.failed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + error: SessionStructuredError + content?: [LLMToolContent, ...Array] + metadata?: { [x: string]: any } + result?: any + executed: boolean + resultState?: SessionMessageProviderState7 + } +} + +export type SessionToolProgress = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.progress" + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { [x: string]: any } + content: Array + } +} + export type SessionMessageCompaction = | SessionMessageCompactionRunning | SessionMessageCompactionCompleted @@ -1893,6 +1898,7 @@ export type ModelInfo = { providerID: string family?: string name: string + compatibility?: ModelCompatibility package?: string settings?: { [x: string]: JsonValue } headers?: { [x: string]: string } @@ -2219,7 +2225,6 @@ export type SessionEventDurable = | SessionToolInputStarted | SessionToolInputEnded | SessionToolCalled - | SessionToolProgress | SessionToolSuccess | SessionToolFailed | SessionRetryScheduled diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 467f6e0f5b..0d989595ec 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -29,7 +29,8 @@ ultimate source of truth. ## Values and literals - [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings. -- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams. +- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous + iterators, and synchronous generators. - [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and `undefined` are no-ops, while arrays are rejected. - [x] Template literals with interpolation. @@ -57,7 +58,9 @@ ultimate source of truth. - [ ] Hoist function declarations accepted directly in switch cases. - [x] Computed object destructuring keys such as `const { [field]: value } = record`. - [x] Object destructuring from arrays, such as `const { length } = values`. -- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams. +- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous + iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion + or binding/default failure. ## Statements and control flow @@ -65,7 +68,8 @@ ultimate source of truth. - [x] `if`/`else` and conditional expressions. - [x] `switch`, including default clauses and fallthrough. - [x] `for`, `while`, and `do...while`. -- [x] `for...of` over arrays, strings, Maps, Sets, and URLSearchParams. +- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined + synchronous generators. Abrupt completion invokes the iterator's optional `return()`. - [x] `for...in` over own keys of plain objects, arrays, and tool references. - [x] Unlabeled `break` and `continue`. - [x] `try`, `catch`, optional catch bindings, and `finally`. @@ -75,7 +79,7 @@ ultimate source of truth. `Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in - JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset. + JavaScript; only their `next()` results are awaited. Confined sync and async generators are iterable here. ## Functions and callbacks @@ -104,7 +108,27 @@ ultimate source of truth. - [ ] User-defined constructor calls. - [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions. - [ ] Classes and private fields. -- [ ] Generator functions and `yield`. +- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies, + `next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering, + `try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync + iterator but preserves values supplied by a manually implemented async iterator. Generator values are opaque + runtime references. +- [x] Synchronous generators and custom synchronous iterators are consumed stepwise by array/argument spread, array + destructuring, `Array.from`, Map/Set/URLSearchParams construction, `Object.fromEntries`, Object/Map `groupBy`, + Promise combinators, `AggregateError`, and `Math.sumPrecise`. Mapper/grouping callbacks interleave with iterator + steps; synchronous consumers preserve yielded promise objects rather than awaiting them. Async generators are + rejected by every synchronous consumer. +- [x] Synchronous iterator acquisition and result validation follow `IteratorClose` boundaries: consumer errors and + intentional early stops invoke `return()`, acquisition/`next()` failures do not, and an original consumer error + wins over a cleanup failure. Async iterator consumption remains limited to `for await...of` and async `yield*`. +- [x] Portable generator protocol coverage is adapted from pinned Test262 cases for suspended-start, suspended-yield, + and completed states; sync and async `next`/`return`/`throw`; finally yields and completion overrides; rejected + yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results; + and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite + deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions, + proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests + asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These + are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged. ## Expressions and operators @@ -112,8 +136,8 @@ ultimate source of truth. - [x] Optional property access and optional calls. - [x] Function/tool calls and spread arguments. - [x] Sequence expressions (the comma operator). -- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its - continuation one reaction turn. +- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every + `await` still defers its continuation one reaction turn. - [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. @@ -128,10 +152,11 @@ ultimate source of truth. ## Promises and tools - [x] Tool calls start eagerly and return supervised, run-once CodeMode promises. -- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program. +- [x] Direct `await`, repeated awaits, and recursive thenable assimilation when a promise or thenable is returned from + a function/program. - [x] `Promise.resolve` and `Promise.reject`. -- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing - promises and plain values. +- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over finite collections, custom synchronous + iterators, and synchronous generators containing promises and plain values. - [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings. - [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records. - [x] `Promise.race` settles from the first result without cancelling losers at settlement time. @@ -154,11 +179,14 @@ ultimate source of truth. the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`. - [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject callables that settle the promise exactly once (they may escape the executor and settle later); an executor - throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the - promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including - `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data - boundary. -- [ ] Thenable assimilation; objects with a callable `then` field remain plain data. + throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and + resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are + accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot + cross the data boundary. +- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators, + constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive + first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields + and a JavaScript `this` receiver remain outside the supported object/function model. - [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the last definition supplied for a canonical path wins. - [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys. @@ -175,14 +203,16 @@ ultimate source of truth. - [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged; primitive wrapper objects (`Object(1)`) are rejected explicitly. - [x] Computed property names and object spread. -- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`. +- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with + synchronous iterator support for `fromEntries`. - [x] `Object.keys` over arrays and tool references. - [x] Object identity is preserved by in-CodeMode Object helpers. - [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked. - [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and cannot be created, read, or written in CodeMode; tool path segments with those names remain supported. - [x] `Object.is` for supported data values. -- [x] `Object.groupBy` over supported collection iterables, with string-key coercion and null-prototype results. +- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion + and null-prototype results. ## Arrays @@ -190,7 +220,7 @@ ultimate source of truth. array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like JavaScript, and host results normalize holes to `null`. - [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with - `(value, index)` arguments. + `(value, index)` arguments and stepwise synchronous iterator consumption. - [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`. - [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and `lastIndexOf`. @@ -245,7 +275,8 @@ ultimate source of truth. for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw, and unknown `Promise` statics keep their descriptive error. -- [x] `Math.sumPrecise` over supported collection iterables, rejecting non-number elements without coercion. +- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number + elements without coercion. - [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`. ## JSON and console @@ -297,10 +328,10 @@ ultimate source of truth. ## Map and Set -- [x] Static `Map.groupBy` over supported collection iterables, preserving key identity. -- [x] `new Map()` from entry arrays or another Map. +- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity. +- [x] `new Map()` from synchronous iterables of entries. - [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`. -- [x] `new Set()` from arrays, strings, or another Set. +- [x] `new Set()` from synchronous iterables. - [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`. - [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set. - [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration. @@ -316,7 +347,7 @@ ultimate source of truth. - [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash`. - [x] Writable URL fields except `origin`. -- [x] `new URLSearchParams()` from query strings, data objects, pairs, Maps, and URLSearchParams. +- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams. - [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`, `entries`, `toString`, and `size`. - [x] URL values serialize to their href; URLSearchParams serialize to `{}`. @@ -326,7 +357,7 @@ ultimate source of truth. - [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with or without `new`. - [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by - an all-rejected `Promise.any`. + an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators. - [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. - [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. - [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index ed621dc7f7..14962ce384 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -1,9 +1,10 @@ +import { Effect } from "effect" import type { Diagnostic } from "../codemode.js" import { ToolError } from "../tool-error.js" import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js" import { containsRuntimeReference } from "./references.js" -import { spreadItems } from "../stdlib/collections.js" +import { type SyncIteratorRunner } from "./iterator.js" import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js" export const normalizeError = (error: unknown): Diagnostic => { @@ -79,15 +80,27 @@ export const caughtErrorValue = (thrown: unknown): unknown => { return createErrorValue(name, normalizeError(thrown).message) } -export const constructErrorValue = (name: string, args: Array, node: AstNode): SafeObject => { - if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) - const errors = spreadItems(args[0]) - if (errors === undefined) { - throw new InterpreterRuntimeError( - "new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).", - node, - ).as("TypeError") - } - // Error values must not alias caller-owned arrays. - return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1])) -} +export const constructErrorValue = (name: string, args: Array): SafeObject => + createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) + +export const constructAggregateErrorValue = ( + runner: SyncIteratorRunner, + args: Array, + node: AstNode, +): Effect.Effect => + Effect.gen(function* () { + const cursor = yield* runner.syncIterator(args[0], node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node).as( + "TypeError", + ) + } + const errors: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) { + return createAggregateErrorValue(errors, args[1] === undefined ? "" : coerceToString(args[1])) + } + errors.push(step.value) + } + }) diff --git a/packages/codemode/src/interpreter/iterator.ts b/packages/codemode/src/interpreter/iterator.ts new file mode 100644 index 0000000000..f352ed159d --- /dev/null +++ b/packages/codemode/src/interpreter/iterator.ts @@ -0,0 +1,21 @@ +import { Effect, Exit } from "effect" +import type { AstNode } from "./model.js" + +export type IteratorCursor = { + readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R> + readonly close: Effect.Effect +} + +export type SyncIteratorRunner = { + readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect | undefined, unknown, R> +} + +export const preserveConsumerError = ( + cursor: IteratorCursor, + effect: Effect.Effect, +): Effect.Effect => + Effect.flatMap(Effect.exit(effect), (exit) => + Exit.isSuccess(exit) + ? Effect.succeed(exit.value) + : Effect.andThen(Effect.exit(cursor.close), Effect.failCause(exit.cause)), + ) diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 79a014ba4f..cae4951d9a 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { type AstNode, CodeModeFunction, + CodeModeGenerator, CoercionFunction, ErrorConstructorReference, GlobalMethodReference, @@ -33,6 +34,7 @@ import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } fro import { invokeStringStatic } from "../stdlib/string.js" import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js" import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js" +import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js" export type CallbackRunner = { readonly invokeFunction: (fn: CodeModeFunction, args: Array) => Effect.Effect @@ -360,19 +362,12 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u return Array.isArray(args[0]) case "of": return [...args] - case "from": - return arrayFromItems(args[0], node) default: throw new InterpreterRuntimeError(`Array.${name} is not available.`, node) } } -const arrayFromItems = (source: unknown, node: AstNode): Array => { - if (source instanceof CodeModeMap) return Array.from(source.map.entries(), ([key, item]) => [key, item]) - if (source instanceof CodeModeSet) return Array.from(source.set.values()) - if (source instanceof CodeModeURLSearchParams) { - return Array.from(source.params.entries(), ([key, value]) => [key, value]) - } +const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => { if (source instanceof CodeModePromise) { throw new InterpreterRuntimeError( "Array.from received an un-awaited Promise; await it before creating the array.", @@ -380,15 +375,16 @@ const arrayFromItems = (source: unknown, node: AstNode): Array => { "InvalidDataValue", ) } - if (typeof source === "string") return Array.from(source) - if (Array.isArray(source)) return [...source] if ( source !== null && typeof source === "object" && (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && typeof (source as { length?: unknown }).length === "number" ) { - return Array.from(source as ArrayLike) + const length = (source as { length: number }).length + const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length) + if (normalized > 4_294_967_295) throw new RangeError("Invalid array length") + return { length: normalized, source } } throw new InterpreterRuntimeError( "Array.from expects an array, string, Map, Set, or array-like value.", @@ -398,24 +394,42 @@ const arrayFromItems = (source: unknown, node: AstNode): Array => { } export const invokeArrayFrom = ( - runner: CallbackRunner, + runner: CallbackRunner & SyncIteratorRunner, args: Array, node: AstNode, ): Effect.Effect => { - const items = arrayFromItems(args[0], node) - if (args.length < 2 || args[1] === undefined) return Effect.succeed(items) - const apply = applyCollectionCallback(runner, args[1], "Array.from", node) + const source = args[0] + const apply = + args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node) return Effect.gen(function* () { - const values: Array = [] - for (let index = 0; index < items.length; index += 1) { - values.push(yield* apply([items[index], index])) + const cursor = yield* runner.syncIterator(source, node) + if (cursor === undefined) { + if (source instanceof CodeModeGenerator) { + throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node).as( + "TypeError", + ) + } + const arrayLike = arrayLikeSource(source, node) + const values: Array = [] + for (let index = 0; index < arrayLike.length; index += 1) { + const item = Reflect.get(arrayLike.source, index) + values.push(apply === undefined ? item : yield* apply([item, index])) + } + return values + } + const values: Array = [] + let index = 0 + while (true) { + const step = yield* cursor.next + if (step.done) return values + values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index]))) + index += 1 } - return values }) } export const invokeGroupBy = ( - runner: CallbackRunner, + runner: CallbackRunner & SyncIteratorRunner, namespace: "Map" | "Object", args: Array, node: AstNode, @@ -425,47 +439,50 @@ export const invokeGroupBy = ( throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") } const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node) - const items = supportedIterableItems(source) - if (items === undefined) { - throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") - } return Effect.gen(function* () { + const cursor = yield* runner.syncIterator(source, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") + } if (namespace === "Map") { const result = new CodeModeMap() let index = 0 - for (const item of items) { - const key = yield* apply([item, index]) + while (true) { + const step = yield* cursor.next + if (step.done) return result + const item = step.value + const key = yield* preserveConsumerError(cursor, apply([item, index])) const group = result.map.get(key) if (group === undefined) result.map.set(key, [item]) else (group as Array).push(item) index += 1 } - return result } const result: SafeObject = Object.create(null) as SafeObject let index = 0 - for (const item of items) { - const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node) + while (true) { + const step = yield* cursor.next + if (step.done) return result + const item = step.value + const key = yield* preserveConsumerError( + cursor, + Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)), + ) if (isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node) + return yield* preserveConsumerError( + cursor, + Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)), + ) } const group = result[key] if (group === undefined) result[key] = [item] else (group as Array).push(item) index += 1 } - return result }) } -const supportedIterableItems = (source: unknown): Iterable | undefined => { - if (Array.isArray(source) || typeof source === "string") return source - if (source instanceof CodeModeMap) return source.map.entries() - if (source instanceof CodeModeSet) return source.set.values() - if (source instanceof CodeModeURLSearchParams) return source.params.entries() -} - const coerceGroupByPropertyKey = ( runner: CallbackRunner, value: unknown, diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index ef66d8876c..b3a82e6886 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -1,3 +1,4 @@ +import type { Effect } from "effect" import type { SafeObject } from "../tool-runtime.js" import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js" @@ -45,6 +46,27 @@ export class CodeModeFunction { readonly body: AstNode, readonly capturedScopes: ReadonlyArray>, readonly async: boolean, + readonly generator: boolean, + ) {} +} + +export type GeneratorRequestKind = "next" | "return" | "throw" + +export class CodeModeGenerator { + constructor( + readonly asynchronous: boolean, + readonly request: ( + kind: GeneratorRequestKind, + value: unknown, + node: AstNode, + ) => Effect.Effect, + ) {} +} + +export class GeneratorMethodReference { + constructor( + readonly generator: CodeModeGenerator, + readonly kind: GeneratorRequestKind | "iterator", ) {} } @@ -128,6 +150,10 @@ export class ProgramThrow { constructor(readonly value: unknown) {} } +export class GeneratorReturn { + constructor(readonly value: unknown) {} +} + export class ErrorConstructorReference { constructor(readonly name: string) {} } diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 6f7c07c5b8..26b6824e99 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -13,9 +13,9 @@ import { import { caughtErrorValue, normalizeError } from "./errors.js" import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js" import { typeofValue } from "./references.js" -import { spreadItems } from "../stdlib/collections.js" import { createAggregateErrorValue } from "../stdlib/value.js" import { CodeModePromise } from "../values.js" +import type { SyncIteratorRunner } from "./iterator.js" // Observation only controls rejection reporting; program completion interrupts all promise work. export class PromiseRuntime { @@ -64,6 +64,10 @@ export class PromiseRuntime { return Fiber.await(promise.fiber) } + fork(effect: Effect.Effect): Effect.Effect { + return Effect.asVoid(Effect.forkIn(effect, this.scope, { startImmediately: true })) + } + diagnostics(): Array { return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) } @@ -83,114 +87,138 @@ export class PromiseRuntime { export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") -export const invokePromiseMethod = ( +export const resolvePromiseValue = ( runner: CallbackRunner, + value: unknown, + node: AstNode, + own?: { promise?: CodeModePromise }, +): Effect.Effect => { + if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node)) + if (value instanceof CodeModePromise) return runner.settlePromise(value) + if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value) + const then = (value as SafeObject).then + if (typeofValue(then) !== "function") return Effect.succeed(value) + + return Effect.gen(function* () { + // Promise resolution invokes a thenable's method in a later job. + yield* Effect.yieldNow + const deferred = Deferred.makeUnsafe() + const resolve = new PromiseCapabilityFunction((result) => { + Deferred.doneUnsafe(deferred, Exit.succeed(result)) + }) + const reject = new PromiseCapabilityFunction((reason) => { + Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason))) + }) + const executed = yield* Effect.exit(runner.invokeCallable(then, [resolve, reject], node)) + if (!Exit.isSuccess(executed)) { + if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) + Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) + } + return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own) + }) +} + +export const resolvePromise = ( + runner: CallbackRunner, + promises: PromiseRuntime, + value: unknown, + node: AstNode, +): Effect.Effect => { + if (value instanceof CodeModePromise) return Effect.succeed(value) + const box: { promise?: CodeModePromise } = {} + return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => { + box.promise = promise + return promise + }) +} + +export const invokePromiseMethod = ( + runner: CallbackRunner & SyncIteratorRunner, promises: PromiseRuntime, ref: PromiseMethodReference, args: Array, node: AstNode, ): Effect.Effect => { if (ref.name === "resolve") { - const value = args[0] - return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value)) + return resolvePromise(runner, promises, args[0], node) } if (ref.name === "reject") { return promises.create(Effect.fail(new ProgramThrow(args[0]))) } - const spread = spreadItems(args[0]) - if (spread === undefined) { - return promises.create( - Effect.fail( - new InterpreterRuntimeError( - `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + return promises.create( + Effect.gen(function* () { + const cursor = yield* runner.syncIterator(args[0], node) + if (cursor === undefined) { + throw new InterpreterRuntimeError( + `Promise.${ref.name} expects an array or other synchronous iterable.`, node, - ).as("TypeError"), - ), - ) - } - const items = Array.from(spread) + ).as("TypeError") + } + const items: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) break + const item = yield* resolvePromise(runner, promises, step.value, node) + promises.markObserved(item) + items.push(item) + } - for (const item of items) { - if (item instanceof CodeModePromise) promises.markObserved(item) - } - - switch (ref.name) { - case "all": { - const observations = items.map((item) => - item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item), - ) - return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" }))) - } - case "allSettled": { - const observations = items.map((item) => - item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), - ) - return promises.create( - settleAfterTurn( - Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const exit = yield* observation - if (Exit.isSuccess(exit)) { - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), - ) - continue - } - if (Cause.hasInterruptsOnly(exit.cause)) { - // Teardown interruption is not a program-level rejection. - return yield* Effect.failCause(exit.cause) - } - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(Cause.squash(exit.cause)), - }), - ) - } - return outcomes - }), - ), - ) - } - case "race": { - if (items.length === 0) { - return promises.create( - Effect.fail( - new InterpreterRuntimeError( - "Promise.race([]) would never settle; provide at least one promise or value.", - node, - ), + if (ref.name === "all") { + return yield* settleAfterTurn( + Effect.all( + items.map((item) => Effect.flatten(promises.await(item))), + { concurrency: "unbounded" }, ), ) } - const observations = items.map((item) => - item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), - ) - return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) - } - case "any": { + if (ref.name === "allSettled") { + const outcomes: Array = [] + for (const item of items) { + const exit = yield* promises.await(item) + if (Exit.isSuccess(exit)) { + outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value })) + continue + } + if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(Cause.squash(exit.cause)), + }), + ) + } + yield* Effect.yieldNow + return outcomes + } + if (ref.name === "race") { + if (items.length === 0) { + throw new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ) + } + return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item))))) + } const flipped = items.map((item) => - item instanceof CodeModePromise - ? Effect.flatMap(promises.await(item), (exit) => { - if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) - if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) - return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) - }) - : Effect.fail(new PromiseAnyFulfilled(item)), + Effect.flatMap(promises.await(item), (exit) => { + if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) + if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) + return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) + }), ) - const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe( - Effect.flatMap((reasons) => - Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), - ), - Effect.catch((error) => - error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + return yield* settleAfterTurn( + Effect.all(flipped, { concurrency: "unbounded" }).pipe( + Effect.flatMap((reasons) => + Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), + ), + Effect.catch((error) => + error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + ), ), ) - return promises.create(settleAfterTurn(body)) - } - } + }), + ) } export const invokePromiseInstanceMethod = ( @@ -224,15 +252,11 @@ export const constructPromise = ( } return Effect.gen(function* () { const deferred = Deferred.makeUnsafe() - const box: { own?: CodeModePromise } = {} + const box: { promise?: CodeModePromise } = {} const promise = yield* promises.create( - Effect.flatMap(Deferred.await(deferred), (value) => { - if (!(value instanceof CodeModePromise)) return Effect.succeed(value) - if (value === box.own) return Effect.fail(selfResolutionError(node)) - return runner.settlePromise(value) - }), + Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)), ) - box.own = promise + box.promise = promise const resolve = new PromiseCapabilityFunction((value) => { Deferred.doneUnsafe(deferred, Exit.succeed(value)) }) @@ -288,19 +312,17 @@ const chainReaction = ( method: string, node: AstNode, ): Effect.Effect => { - const box: { derived?: CodeModePromise } = {} + const box: { promise?: CodeModePromise } = {} const body = Effect.gen(function* () { const exit = yield* reactionExit(promises, source) const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected if (handler === undefined) return yield* exit const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause)) const result = yield* applyCollectionCallback(runner, handler, method, node)([input]) - if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) - if (result instanceof CodeModePromise) return yield* runner.settlePromise(result) - return result + return yield* resolvePromiseValue(runner, result, node, box) }) return Effect.map(promises.create(body), (derived) => { - box.derived = derived + box.promise = derived return derived }) } @@ -318,7 +340,13 @@ const chainFinally = ( const exit = yield* reactionExit(promises, source) if (cleanup !== undefined) { const result = yield* applyCollectionCallback(runner, cleanup, method, node)([]) - if (result instanceof CodeModePromise) yield* runner.settlePromise(result) + const intermediate = yield* promises.create( + Effect.gen(function* () { + yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node)) + return yield* exit + }), + ) + return yield* runner.settlePromise(intermediate) } return yield* exit }), diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index ae3a9afa46..bfc7663a7e 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -1,10 +1,12 @@ import { type AstNode, CodeModeFunction, + CodeModeGenerator, CoercionFunction, ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, + GeneratorMethodReference, InterpreterRuntimeError, IntrinsicReference, JsonMethodReference, @@ -21,6 +23,8 @@ import { isCodeModeValue, CodeModePromise } from "../values.js" export const isRuntimeReference = (value: unknown): boolean => value instanceof CodeModeFunction || + value instanceof CodeModeGenerator || + value instanceof GeneratorMethodReference || value instanceof ToolReference || value instanceof IntrinsicReference || value instanceof GlobalNamespace || @@ -107,6 +111,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label export const typeofValue = (value: unknown): string => { if ( value instanceof CodeModeFunction || + value instanceof GeneratorMethodReference || value instanceof CoercionFunction || value instanceof IntrinsicReference || value instanceof GlobalMethodReference || diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 765b97ee5b..3af385d513 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit } from "effect" +import { Cause, Deferred, Effect, Exit } from "effect" import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { type AstNode, @@ -6,11 +6,15 @@ import { asNode, type Binding, CodeModeFunction, + CodeModeGenerator, CoercionFunction, ComputedValue, ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, + GeneratorMethodReference, + GeneratorReturn, + type GeneratorRequestKind, type GlobalNamespaceName, getArray, getBoolean, @@ -35,11 +39,10 @@ import { SearchFunction, SymbolNamespace, type StatementResult, - supportedSyntaxMessage, unsupportedSyntax, UriFunction, } from "./model.js" -import { caughtErrorValue, constructErrorValue } from "./errors.js" +import { caughtErrorValue, constructAggregateErrorValue, constructErrorValue } from "./errors.js" import { arrayStatics, type CallbackRunner, @@ -48,22 +51,24 @@ import { invokeGroupBy, invokeIntrinsic, } from "./methods.js" +import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js" import { constructPromise, invokePromiseInstanceMethod, invokePromiseMethod, PromiseRuntime, - selfResolutionError, + resolvePromise, + resolvePromiseValue, } from "./promises.js" import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" import { ScopeStack } from "./scope.js" -import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js" +import { arrayMethods, mapMethods, mapStatics, setMethods } from "../stdlib/collections.js" import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js" import { dateMethods, dateStatics } from "../stdlib/date.js" import { invokeJsonMethod, jsonStatics, type JsonMethodName } from "../stdlib/json.js" -import { mathConstants, mathMethods } from "../stdlib/math.js" +import { invokeMathSumPrecise, mathConstants, mathMethods } from "../stdlib/math.js" import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js" -import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js" +import { invokeObjectFromEntries, objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js" import { promiseStatics } from "../stdlib/promise.js" import { escapeRegexHint, @@ -216,11 +221,54 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => { } type CustomIterator = { - iterator: SafeObject + iterator: SafeObject | CodeModeGenerator next: unknown asynchronous: boolean } +type OpaqueMemberReference = + | ToolReference + | PromiseMethodReference + | PromiseInstanceMethodReference + | IntrinsicReference + | GlobalMethodReference + | JsonMethodReference + | GeneratorMethodReference + +const isOpaqueMemberReference = (value: unknown): value is OpaqueMemberReference => + value instanceof ToolReference || + value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof JsonMethodReference || + value instanceof GeneratorMethodReference + +const copyIteratorSymbols = (source: object, target: object, consumed?: ReadonlySet): void => { + for (const symbol of IteratorSymbols) { + if (!consumed?.has(symbol) && Object.hasOwn(source, symbol)) + Reflect.set(target, symbol, Reflect.get(source, symbol)) + } +} + +type GeneratorRequest = { + kind: GeneratorRequestKind + value: unknown + response: Deferred.Deferred +} + +type GeneratorState = { + started: boolean + completed: boolean + draining: boolean + active?: GeneratorRequest + pending: Array + pendingIndex: number + available?: Deferred.Deferred +} + +const promiseResolutionNode: AstNode = { type: "PromiseResolution" } + export class Interpreter { private scopes: ScopeStack private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect @@ -228,10 +276,13 @@ export class Interpreter { private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array private readonly promises: PromiseRuntime - private readonly runner: CallbackRunner = { + private generatorState?: GeneratorState + private generatorAsync = false + private readonly runner: CallbackRunner & SyncIteratorRunner = { invokeFunction: (fn, args) => this.invokeFunction(fn, args), invokeCallable: (callable, args, node) => this.invokeCallable(callable, args, node), settlePromise: (promise) => this.settlePromise(promise), + syncIterator: (value, node) => this.syncIterator(value, node), } constructor( @@ -308,7 +359,7 @@ export class Interpreter { } // The implicit async body adopts returned promises before copy-out. - if (value instanceof CodeModePromise) value = yield* self.settlePromise(value) + value = yield* resolvePromiseValue(self.runner, value, program) return value }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } @@ -403,16 +454,12 @@ export class Interpreter { } private createFunction(node: AstNode): CodeModeFunction { - if (node.generator === true) { - throw new InterpreterRuntimeError("Generator functions are not supported.", node, "UnsupportedSyntax", [ - supportedSyntaxMessage, - ]) - } return new CodeModeFunction( getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), getNode(node, "body"), this.scopes.capture(), node.async === true, + node.generator === true, ) } @@ -646,14 +693,20 @@ export class Interpreter { const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") - const iterable = spreadItems(right) - const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined - if (iterable === undefined && iterator === undefined) { + const iterator = yield* self.customIterator(right, node, awaiting) + const cursor = iterator === undefined ? yield* self.syncIterator(right, node) : undefined + if (iterator === undefined && cursor === undefined) { throw new InterpreterRuntimeError( - `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`, + `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`, node, - ) + ).as("TypeError") } + const close = () => + iterator + ? self.closeIterator(iterator, node, awaiting) + : awaiting + ? Effect.andThen(cursor?.close ?? Effect.void, Effect.yieldNow) + : (cursor?.close ?? Effect.void) let assignment: AstNode | undefined @@ -687,45 +740,35 @@ export class Interpreter { ), ) - if (iterable !== undefined) { - for (const value of iterable) { - const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value) - - if (result.kind === "return") return result - if (result.kind === "break") { - if (result.label !== undefined && !labels?.has(result.label)) return result - return { kind: "none" } satisfies StatementResult - } - if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result - } - return { kind: "none" } satisfies StatementResult - } - if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node) - while (true) { - const step = yield* self.nextIteratorResult(iterator, node) + const current = iterator + ? yield* self.nextIteratorResult(iterator, node, awaiting) + : yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node)) + const step = cursor && awaiting ? { done: current.done, value: yield* self.awaitValue(current.value) } : current if (step.done) return { kind: "none" } satisfies StatementResult const bodyExit = yield* Effect.exit(evaluateBody(step.value)) if (!Exit.isSuccess(bodyExit)) { // Process interruption must remain prompt; user cleanup cannot extend a timeout. - if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node)) + if (!Cause.hasInterruptsOnly(bodyExit.cause)) { + yield* Effect.exit(close()) + } return yield* Effect.failCause(bodyExit.cause) } const result = bodyExit.value if (result.kind === "return") { - yield* self.closeIterator(iterator, node) + yield* close() return result } if (result.kind === "break") { - yield* self.closeIterator(iterator, node) + yield* close() if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) { - yield* self.closeIterator(iterator, node) + yield* close() return result } } @@ -738,30 +781,92 @@ export class Interpreter { ) } - private awaitValue(value: unknown): Effect.Effect { - return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value) + private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect { + return Effect.flatMap(resolvePromise(this.runner, this.promises, value, node), (promise) => + this.settlePromise(promise), + ) } - private customIterator(value: unknown, node: AstNode) { + private awaitAsyncFromSyncValue( + iterator: CustomIterator, + value: unknown, + node: AstNode, + closeOnRejection: boolean, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const settled = yield* Effect.exit(self.awaitValue(value)) + if (Exit.isSuccess(settled)) return settled.value + if (closeOnRejection && !Cause.hasInterruptsOnly(settled.cause)) { + yield* Effect.exit(self.closeIterator(iterator, node, false)) + } + return yield* Effect.failCause(settled.cause) + }) + } + + private syncIterator(value: unknown, node: AstNode) { + const iterator = Array.isArray(value) + ? value[Symbol.iterator]() + : typeof value === "string" + ? value[Symbol.iterator]() + : value instanceof CodeModeMap + ? value.map.entries() + : value instanceof CodeModeSet + ? value.set.values() + : value instanceof CodeModeURLSearchParams + ? value.params.entries() + : undefined + if (iterator !== undefined) { + return Effect.succeed({ + next: Effect.sync(() => { + const step = iterator.next() + return { done: Boolean(step.done), value: step.value } + }), + close: Effect.void, + }) + } + const self = this + return Effect.map(this.customIterator(value, node, false), (iterator) => + iterator === undefined + ? undefined + : { + next: self.nextIteratorResult(iterator, node, false), + close: Effect.suspend(() => self.closeIterator(iterator, node, false)), + }, + ) + } + + private customIterator(value: unknown, node: AstNode, allowAsync = true) { + if (value instanceof CodeModeGenerator) { + if (value.asynchronous && !allowAsync) return Effect.succeed(undefined) + return Effect.succeed({ + iterator: value, + next: new GeneratorMethodReference(value, "next"), + asynchronous: value.asynchronous, + }) + } if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined) - const asyncMethod = Reflect.get(value, AsyncIteratorSymbol) + const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined const method = asyncMethod ?? Reflect.get(value, IteratorSymbol) if (method === undefined || method === null) return Effect.succeed(undefined) const self = this return Effect.map( this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node), (iterator) => { - const object = self.requireIteratorObject(iterator, "Iterator method result", node) + const object = self.requireIterator(iterator, node) return { iterator: object, - next: self.requireIteratorMethod(object.next, "Iterator next", node), + next: + object instanceof CodeModeGenerator + ? new GeneratorMethodReference(object, "next") + : self.requireIteratorMethod(object.next, "Iterator next", node), asynchronous: asyncMethod !== undefined && asyncMethod !== null, } }, ) } - private nextIteratorResult(iterator: CustomIterator, node: AstNode) { + private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) { const self = this return Effect.gen(function* () { if (iterator.asynchronous) { @@ -775,7 +880,7 @@ export class Interpreter { const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node)) if (!Exit.isSuccess(called)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(called.cause) } const captured = yield* Effect.exit( @@ -785,16 +890,24 @@ export class Interpreter { }), ) if (!Exit.isSuccess(captured)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(captured.cause) } - return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) } + return { + done: captured.value.done, + value: awaiting + ? yield* self.awaitAsyncFromSyncValue(iterator, captured.value.value, node, !captured.value.done) + : captured.value.value, + } }) } - private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect { - const close = iterator.iterator.return - if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow + private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect { + const close = + iterator.iterator instanceof CodeModeGenerator + ? new GeneratorMethodReference(iterator.iterator, "return") + : iterator.iterator.return + if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow const self = this return Effect.gen(function* () { const method = self.requireIteratorMethod(close, "Iterator return", node) @@ -809,17 +922,17 @@ export class Interpreter { const called = yield* Effect.exit(self.invokeCallable(method, [], node)) if (!Exit.isSuccess(called)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(called.cause) } const captured = yield* Effect.exit( Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value), ) if (!Exit.isSuccess(captured)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(captured.cause) } - yield* self.awaitValue(captured.value) + if (awaiting) yield* self.awaitValue(captured.value) }) } @@ -828,6 +941,12 @@ export class Interpreter { throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError") } + private requireIterator(value: unknown, node: AstNode): SafeObject | CodeModeGenerator { + return value instanceof CodeModeGenerator + ? value + : this.requireIteratorObject(value, "Iterator method result", node) + } + private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown { if (typeofValue(value) === "function") return value throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError") @@ -966,7 +1085,7 @@ export class Interpreter { const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { onFailure: (cause) => { - if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + if (cause.reasons.some(Cause.isInterruptReason) || Cause.squash(cause) instanceof GeneratorReturn || !handler) { return Effect.failCause(cause) } @@ -1059,10 +1178,7 @@ export class Interpreter { for (const [key, item] of Object.entries(value as SafeObject)) { if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item } - for (const symbol of IteratorSymbols) { - if (!consumed.has(symbol) && Object.hasOwn(value, symbol)) - Reflect.set(rest, symbol, Reflect.get(value, symbol)) - } + copyIteratorSymbols(value, rest, consumed) yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize) continue } @@ -1084,21 +1200,9 @@ export class Interpreter { } if (pattern.type === "ArrayPattern") { - const items = spreadItems(value) - if (items === undefined) { - throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern) - } - - for (const [index, item] of getArray(pattern, "elements").entries()) { - if (item === null) continue - const element = asNode(item, `elements[${index}]`) - if (element.type === "RestElement") { - yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element, initialize) - break - } - yield* self.declarePattern(element, items[index], mutable, pattern, initialize) - } - return + return yield* self.destructureArrayPattern(pattern, value, (target, item, context) => + self.declarePattern(target, item, mutable, context, initialize), + ) } throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) @@ -1142,10 +1246,7 @@ export class Interpreter { for (const [key, item] of Object.entries(source)) { if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item } - for (const symbol of IteratorSymbols) { - if (!consumed.has(symbol) && Object.hasOwn(source, symbol)) - Reflect.set(rest, symbol, Reflect.get(source, symbol)) - } + copyIteratorSymbols(source, rest, consumed) yield* self.assignPattern(getNode(property, "argument"), rest, property) continue } @@ -1160,26 +1261,63 @@ export class Interpreter { } if (pattern.type === "ArrayPattern") { - const items = spreadItems(value) - if (items === undefined) { - throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern) - } - for (const [index, item] of getArray(pattern, "elements").entries()) { - if (item === null) continue - const element = asNode(item, `elements[${index}]`) - if (element.type === "RestElement") { - yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element) - break - } - yield* self.assignPattern(element, items[index], pattern) - } - return + return yield* self.destructureArrayPattern(pattern, value, (target, item, context) => + self.assignPattern(target, item, context), + ) } throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node) }) } + private destructureArrayPattern( + pattern: AstNode, + value: unknown, + consume: (target: AstNode, value: unknown, context: AstNode) => Effect.Effect, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(value, pattern) + if (cursor === undefined) { + throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern).as( + "TypeError", + ) + } + let done = false + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (done) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + yield* consume( + element.type === "RestElement" ? getNode(element, "argument") : element, + element.type === "RestElement" ? [] : undefined, + element, + ) + if (element.type === "RestElement") return + continue + } + const step = yield* cursor.next + done = step.done + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + if (element.type === "RestElement") { + const rest: Array = [] + if (!step.done) rest.push(step.value) + while (!done) { + const next = yield* cursor.next + done = next.done + if (!done) rest.push(next.value) + } + yield* consume(getNode(element, "argument"), rest, element) + return + } + const consumed = consume(element, step.done ? undefined : step.value, pattern) + yield* step.done ? consumed : preserveConsumerError(cursor, consumed) + } + if (!done) yield* cursor.close + }) + } + private destructuringPropertyKey(property: AstNode): Effect.Effect { if (property.type !== "Property" || getString(property, "kind") !== "init") { throw new InterpreterRuntimeError("Unsupported object destructuring property.", property) @@ -1254,11 +1392,12 @@ export class Interpreter { return this.evaluateUpdateExpression(node) case "AwaitExpression": { // Await always suspends, including for plain values. - const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value), + this.awaitValue(value, node), ) } + case "YieldExpression": + return this.evaluateYieldExpression(node) case "NewExpression": return this.evaluateNewExpression(node) default: @@ -1280,7 +1419,11 @@ export class Interpreter { ) } if (errorConstructors.has(name)) { - return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node)) + return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => + name === "AggregateError" + ? constructAggregateErrorValue(self.runner, args, node) + : Effect.succeed(constructErrorValue(name, args)), + ) } // Array and Object construct identically with or without new, like JS. if (name === "Array") { @@ -1298,13 +1441,13 @@ export class Interpreter { case "RegExp": return self.constructRegExp(args, node) case "Map": - return self.constructMap(args[0], node) + return yield* self.constructMap(args[0], node) case "Set": - return self.constructSet(args[0], node) + return yield* self.constructSet(args[0], node) case "URL": return self.constructURL(args, node) default: - return self.constructURLSearchParams(args[0], node) + return yield* self.constructURLSearchParams(args[0], node) } }) } @@ -1390,44 +1533,53 @@ export class Interpreter { } } - private constructMap(init: unknown, node: AstNode): CodeModeMap { + private constructMap(init: unknown, node: AstNode): Effect.Effect { const target = new CodeModeMap() - if (init === undefined || init === null) return target - const entries = Array.isArray(init) - ? init - : init instanceof CodeModeMap - ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) - : undefined - if (entries === undefined) { - throw new InterpreterRuntimeError( - "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", - node, - ) - } - for (const pair of entries) { - if (!Array.isArray(pair)) { - throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) + if (init === undefined || init === null) return Effect.succeed(target) + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(init, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError( + "new Map(...) expects an iterable of [key, value] pairs or no argument.", + node, + ).as("TypeError") } - target.map.set(pair[0], pair[1]) - } - return target + while (true) { + const step = yield* cursor.next + if (step.done) return target + yield* preserveConsumerError( + cursor, + Effect.sync(() => { + if (!isRecord(step.value) || isRuntimeReference(step.value)) { + throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node).as( + "TypeError", + ) + } + target.map.set(step.value[0], step.value[1]) + }), + ) + } + }) } - private constructSet(init: unknown, node: AstNode): CodeModeSet { + private constructSet(init: unknown, node: AstNode): Effect.Effect { const target = new CodeModeSet() - if (init === undefined || init === null) return target - const items = Array.isArray(init) - ? init - : init instanceof CodeModeSet - ? Array.from(init.set.values()) - : typeof init === "string" - ? Array.from(init) - : undefined - if (items === undefined) { - throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) - } - for (const item of items) target.set.add(item) - return target + if (init === undefined || init === null) return Effect.succeed(target) + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(init, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node).as( + "TypeError", + ) + } + while (true) { + const step = yield* cursor.next + if (step.done) return target + target.set.add(step.value) + } + }) } private constructURL(args: Array, node: AstNode): CodeModeURL { @@ -1448,47 +1600,79 @@ export class Interpreter { } } - private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams { - if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams()) + private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect { + if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams())) if (init instanceof CodeModeURLSearchParams) { - return new CodeModeURLSearchParams(new URLSearchParams(init.params)) + return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params))) } - if (typeof init === "string") return new CodeModeURLSearchParams(new URLSearchParams(init)) + if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init))) if (init === null || typeof init === "number" || typeof init === "boolean") { - return new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))) + return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init)))) } - if (init instanceof CodeModeMap) { - return this.constructURLSearchParams( - Array.from(init.map.entries(), ([key, value]) => [key, value]), - node, - ) - } - if (Array.isArray(init)) { - const entries = init.map((pair) => { - if (!Array.isArray(pair) || pair.length !== 2) { - throw new InterpreterRuntimeError( - "new URLSearchParams(...) expects an array of [name, value] pairs.", - node, - ).as("TypeError") + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(init, node) + if (cursor !== undefined) { + const entries: Array> = [] + while (true) { + const step = yield* cursor.next + if (step.done) { + if (entries.some((entry) => entry.length !== 2)) { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects iterable [name, value] pairs.", + node, + ).as("TypeError") + } + return new CodeModeURLSearchParams( + new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])), + ) + } + entries.push(yield* preserveConsumerError(cursor, self.readURLSearchParamsPair(step.value, node))) } - return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [ - string, - string, - ] - }) - return new CodeModeURLSearchParams(new URLSearchParams(entries)) - } - if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams()) - const data = boundedData(init, "new URLSearchParams input") - if (data === null || typeof data !== "object") { - throw new InterpreterRuntimeError( - "new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.", - node, - ).as("TypeError") - } - return new CodeModeURLSearchParams( - new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))), - ) + } + if (isRuntimeReference(init)) { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.", + node, + ).as("TypeError") + } + if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams()) + const data = boundedData(init, "new URLSearchParams input") + if (data === null || typeof data !== "object") { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.", + node, + ).as("TypeError") + } + return new CodeModeURLSearchParams( + new URLSearchParams( + Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])), + ), + ) + }) + } + + private readURLSearchParamsPair(value: unknown, node: AstNode): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(value, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node).as( + "TypeError", + ) + } + const items: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) return items + items.push( + yield* preserveConsumerError( + cursor, + Effect.sync(() => uriArgument(step.value, "URLSearchParams pair value")), + ), + ) + } + }) } private evaluateBinaryExpression(node: AstNode): Effect.Effect { @@ -1503,6 +1687,8 @@ export class Interpreter { } private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { + if (operator === "===") return lhs === rhs + if (operator === "!==") return lhs !== rhs if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue") } @@ -1532,12 +1718,8 @@ export class Interpreter { return (l as number) ** (r as number) case "==": return bothObjects ? lhs === rhs : l == r - case "===": - return lhs === rhs case "!=": return bothObjects ? lhs !== rhs : l != r - case "!==": - return lhs !== rhs case "<": return (l as string) < (r as string) case "<=": @@ -1773,6 +1955,11 @@ export class Interpreter { if (callable instanceof CodeModeFunction) { return yield* self.invokeFunction(callable, args) } + if (callable instanceof GeneratorMethodReference) { + if (callable.kind === "iterator") return callable.generator + const requested = callable.generator.request(callable.kind, args[0], node) as Effect.Effect + return callable.generator.asynchronous ? yield* self.createPromise(requested) : yield* requested + } if (callable instanceof IntrinsicReference) { return yield* invokeIntrinsic(self.runner, callable, args, node) } @@ -1782,6 +1969,7 @@ export class Interpreter { return self.invokeObjectMethodOnTools(callable.name, args[0], node) } if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { + if (callable.name === "fromEntries") return yield* invokeObjectFromEntries(self.runner, args[0], node) return invokeGlobalMethod(callable, args, node) } if (callable.namespace === "Array" && callable.name === "from") { @@ -1790,6 +1978,9 @@ export class Interpreter { if ((callable.namespace === "Object" || callable.namespace === "Map") && callable.name === "groupBy") { return yield* invokeGroupBy(self.runner, callable.namespace, args, node) } + if (callable.namespace === "Math" && callable.name === "sumPrecise") { + return yield* invokeMathSumPrecise(self.runner, args[0], node) + } if (callable.namespace === "Array" && callable.name === "of") { return invokeGlobalMethod(callable, args, node) } @@ -1808,7 +1999,8 @@ export class Interpreter { return yield* self.invokeSearch(args) } if (callable instanceof ErrorConstructorReference) { - return constructErrorValue(callable.name, args, node) + if (callable.name === "AggregateError") return yield* constructAggregateErrorValue(self.runner, args, node) + return constructErrorValue(callable.name, args) } if (callable instanceof GlobalNamespace) { // Real JS permits calling Array, Object, Date, and RegExp without new. @@ -1868,10 +2060,16 @@ export class Interpreter { const argNode = asNode(arg, `arguments[${index}]`) if (argNode.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) - const items = spreadItems(spread) - if (items === undefined) - throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set.", argNode) - args.push(...items) + const cursor = yield* self.syncIterator(spread, argNode) + if (cursor === undefined) + throw new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode).as( + "TypeError", + ) + while (true) { + const step = yield* cursor.next + if (step.done) break + args.push(step.value) + } } else { args.push(yield* self.evaluateExpression(argNode)) } @@ -1906,24 +2104,263 @@ export class Interpreter { return yield* invocation.evaluateExpression(fn.body) }) + if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async)) if (!fn.async) return run - // The initial yield assigns `box.own` before the body can self-resolve. - const box: { own?: CodeModePromise } = {} + // The initial yield assigns the promise before the body can self-resolve. + const box: { promise?: CodeModePromise } = {} return Effect.map( - this.createPromise( - Effect.flatMap(run, (value) => { - if (!(value instanceof CodeModePromise)) return Effect.succeed(value) - if (value === box.own) return Effect.fail(selfResolutionError()) - return invocation.settlePromise(value) - }), - ), + this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))), (promise) => { - box.own = promise + box.promise = promise return promise }, ) } + private createGenerator( + invocation: Interpreter, + run: Effect.Effect, + asynchronous: boolean, + ): CodeModeGenerator { + const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 } + invocation.generatorState = state + invocation.generatorAsync = asynchronous + const generator = new CodeModeGenerator(asynchronous, (kind, value, node) => { + const request = { kind, value, response: Deferred.makeUnsafe() } + if (!asynchronous && state.active) { + return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node).as("TypeError")) + } + if (asynchronous && (state.completed || (!state.started && kind !== "next"))) { + state.started = true + state.completed = true + state.pending.push(request) + if (state.draining) return Deferred.await(request.response) + state.draining = true + return Effect.andThen( + this.promises.fork( + invocation + .completeGeneratorRequests(state, true) + .pipe(Effect.ensuring(Effect.sync(() => (state.draining = false)))), + ), + Deferred.await(request.response), + ) + } + if (state.completed) { + if (kind === "throw") return Effect.fail(new ProgramThrow(value)) + return Effect.succeed({ value: kind === "return" ? value : undefined, done: true }) + } + if (!state.started && kind !== "next") { + state.completed = true + if (kind === "throw") return Effect.fail(new ProgramThrow(value)) + return Effect.succeed({ value, done: true }) + } + + state.pending.push(request) + if (state.available) { + const available = state.available + state.available = undefined + Deferred.doneUnsafe(available, Exit.succeed(undefined)) + } + if (!state.started) { + state.started = true + const body = Effect.gen(function* () { + state.active = yield* invocation.takeGeneratorRequest(state) + const exit = yield* Effect.exit( + run.pipe( + Effect.flatMap((result) => (asynchronous ? invocation.awaitValue(result) : Effect.succeed(result))), + Effect.catch((error) => + error instanceof GeneratorReturn + ? asynchronous + ? invocation.awaitValue(error.value) + : Effect.succeed(error.value) + : Effect.fail(error), + ), + ), + ) + const active = state.active + state.active = undefined + if (active) { + Deferred.doneUnsafe( + active.response, + Exit.isSuccess(exit) ? Exit.succeed({ value: exit.value, done: true }) : exit, + ) + } + yield* invocation.completeGeneratorRequests(state, asynchronous) + state.completed = true + }) + return Effect.andThen(this.promises.fork(body), Deferred.await(request.response)) + } + return Deferred.await(request.response) + }) + return generator + } + + private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect { + const self = this + return Effect.gen(function* () { + while (true) { + const pending = self.dequeueGeneratorRequest(state) + if (!pending) return + if (pending.kind === "throw") { + Deferred.doneUnsafe(pending.response, Exit.fail(new ProgramThrow(pending.value))) + continue + } + if (asynchronous && pending.kind === "return") { + const resolved = yield* Effect.exit(self.awaitValue(pending.value)) + Deferred.doneUnsafe( + pending.response, + Exit.isSuccess(resolved) ? Exit.succeed({ value: resolved.value, done: true }) : resolved, + ) + continue + } + Deferred.doneUnsafe( + pending.response, + Exit.succeed({ value: pending.kind === "return" ? pending.value : undefined, done: true }), + ) + } + }) + } + + private takeGeneratorRequest(state: GeneratorState): Effect.Effect { + const next = this.dequeueGeneratorRequest(state) + if (next) return Effect.succeed(next) + state.available = Deferred.makeUnsafe() + return Effect.andThen( + Deferred.await(state.available), + Effect.sync(() => this.dequeueGeneratorRequest(state)!), + ) + } + + private dequeueGeneratorRequest(state: GeneratorState): GeneratorRequest | undefined { + const request = state.pending[state.pendingIndex] + if (!request) return undefined + state.pendingIndex += 1 + if (state.pendingIndex === state.pending.length) { + state.pending = [] + state.pendingIndex = 0 + } + return request + } + + private evaluateYieldExpression(node: AstNode): Effect.Effect { + const argument = getOptionalNode(node, "argument") + const self = this + return Effect.gen(function* () { + if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node) + if (node.delegate === true) { + const value = argument ? yield* self.evaluateExpression(argument) : undefined + return yield* self.delegateYield(value, node) + } + const value = argument ? yield* self.evaluateExpression(argument) : undefined + const yielded = self.generatorAsync ? yield* self.awaitValue(value) : value + return yield* self.suspendGenerator(yielded, node) + }) + } + + private suspendGenerator(value: unknown, node: AstNode): Effect.Effect { + const state = this.generatorState + if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node) + Deferred.doneUnsafe(state.active.response, Exit.succeed({ value, done: false })) + state.active = undefined + return Effect.flatMap(this.takeGeneratorRequest(state), (request) => { + state.active = request + if (request.kind === "next") return Effect.succeed(request.value) + if (request.kind === "throw") return Effect.fail(new ProgramThrow(request.value)) + return this.generatorAsync + ? Effect.flatMap(this.awaitValue(request.value), (value) => Effect.fail(new GeneratorReturn(value))) + : Effect.fail(new GeneratorReturn(request.value)) + }) + } + + private delegateYield(value: unknown, node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function* () { + if ( + Array.isArray(value) || + typeof value === "string" || + value instanceof CodeModeMap || + value instanceof CodeModeSet || + value instanceof CodeModeURLSearchParams + ) { + const cursor = yield* self.syncIterator(value, node) + if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node) + while (true) { + const step = yield* cursor.next + if (step.done) return undefined + const resumed = yield* Effect.exit( + self.suspendGenerator(self.generatorAsync ? yield* self.awaitValue(step.value) : step.value, node), + ) + if (Exit.isSuccess(resumed)) continue + const error = Cause.squash(resumed.cause) + if (error instanceof GeneratorReturn) { + yield* cursor.close + return yield* Effect.fail(error) + } + if (error instanceof ProgramThrow) { + yield* cursor.close + throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node).as( + "TypeError", + ) + } + return yield* Effect.failCause(resumed.cause) + } + } + + const iterator = yield* self.customIterator(value, node, self.generatorAsync) + if (!iterator) + throw new InterpreterRuntimeError("yield* requires a compatible iterable value.", node).as("TypeError") + let kind: GeneratorRequestKind = "next" + let input: unknown = undefined + while (true) { + const method = + kind === "next" + ? iterator.next + : iterator.iterator instanceof CodeModeGenerator + ? new GeneratorMethodReference(iterator.iterator, kind) + : iterator.iterator[kind] + if (method === undefined || method === null) { + if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input)) + yield* self.closeIterator(iterator, node, self.generatorAsync) + throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node).as( + "TypeError", + ) + } + const called = yield* self.invokeCallable( + self.requireIteratorMethod(method, `Iterator ${kind}`, node), + [input], + node, + ) + const result = self.requireIteratorObject( + iterator.asynchronous ? yield* self.awaitValue(called) : called, + `Iterator ${kind}() result`, + node, + ) + const done = Boolean(result.done) + const resultValue: unknown = + self.generatorAsync && !iterator.asynchronous + ? yield* self.awaitAsyncFromSyncValue(iterator, result.value, node, kind !== "return" && !done) + : result.value + if (done) { + if (kind === "return") return yield* Effect.fail(new GeneratorReturn(resultValue)) + return resultValue + } + + const resumed: Exit.Exit = yield* Effect.exit(self.suspendGenerator(resultValue, node)) + if (Exit.isSuccess(resumed)) { + kind = "next" + input = resumed.value + continue + } + const error: unknown = Cause.squash(resumed.cause) + if (!(error instanceof GeneratorReturn) && !(error instanceof ProgramThrow)) { + return yield* Effect.failCause(resumed.cause) + } + kind = error instanceof GeneratorReturn ? "return" : "throw" + input = error.value + } + }) + } + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { const objectValue: Record = Object.create(null) as Record const properties = getArray(node, "properties") @@ -1942,9 +2379,7 @@ export class Interpreter { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property) objectValue[key] = value } - for (const symbol of IteratorSymbols) { - if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol)) - } + copyIteratorSymbols(spread, objectValue) continue } @@ -1997,10 +2432,14 @@ export class Interpreter { const element = asNode(elementValue, "elements") if (element.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(element, "argument")) - const items = spreadItems(spread) - if (items === undefined) - throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set.", element) - values.push(...items) + const cursor = yield* self.syncIterator(spread, element) + if (cursor === undefined) + throw new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element).as("TypeError") + while (true) { + const step = yield* cursor.next + if (step.done) break + values.push(step.value) + } } else { values.push(yield* self.evaluateExpression(element)) } @@ -2061,6 +2500,7 @@ export class Interpreter { | IntrinsicReference | GlobalMethodReference | JsonMethodReference + | GeneratorMethodReference | ComputedValue | typeof OptionalShortCircuit | undefined, @@ -2205,6 +2645,19 @@ export class Interpreter { ) } + if (objectValue instanceof CodeModeGenerator) { + if (key === "next" || key === "return" || key === "throw") { + return new GeneratorMethodReference(objectValue, key) + } + if ( + (key === IteratorSymbol && !objectValue.asynchronous) || + (key === AsyncIteratorSymbol && objectValue.asynchronous) + ) { + return new GeneratorMethodReference(objectValue, "iterator") + } + return new ComputedValue(undefined) + } + if (isRuntimeReference(objectValue)) { throw new InterpreterRuntimeError( "Runtime references are opaque and do not expose properties.", @@ -2241,16 +2694,7 @@ export class Interpreter { return Effect.map(this.getMemberReference(node), (reference) => { if (reference === OptionalShortCircuit) return OptionalShortCircuit if (reference instanceof ComputedValue) return reference.value - if ( - reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof PromiseInstanceMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof JsonMethodReference - ) - return reference + if (reference === undefined || isOpaqueMemberReference(reference)) return reference if (Array.isArray(reference.target)) { if (reference.key === "length") return reference.target.length if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) @@ -2278,12 +2722,7 @@ export class Interpreter { if ( reference instanceof ComputedValue || reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof PromiseInstanceMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof JsonMethodReference || + isOpaqueMemberReference(reference) || reference.target instanceof CodeModeURL ) { throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue") @@ -2307,12 +2746,7 @@ export class Interpreter { reference === OptionalShortCircuit || reference instanceof ComputedValue || reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof PromiseInstanceMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof JsonMethodReference + isOpaqueMemberReference(reference) ) { throw new InterpreterRuntimeError("Only data fields may be assigned.", node) } diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index 040153b97e..211d307cac 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -1,5 +1,6 @@ +import { Effect } from "effect" +import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" -import { spreadItems } from "./collections.js" // Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types. declare global { @@ -53,17 +54,6 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available.`, node) if (name === "random") return Math.random() - if (name === "sumPrecise") { - const items = spreadItems(args[0]) - if (items === undefined) { - throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable collection.", node).as("TypeError") - } - const numbers = Array.from(items) - if (!numbers.every((item): item is number => typeof item === "number")) { - throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError") - } - return Math.sumPrecise(numbers) - } // Validate only the arguments the method consumes; like JS, extras are ignored // (so built-ins work as callbacks receiving (element, index, array)). const num = (index: number): number => { @@ -153,3 +143,29 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo } throw new InterpreterRuntimeError(`Math.${name} is not available.`, node) } + +export const invokeMathSumPrecise = ( + runner: SyncIteratorRunner, + source: unknown, + node: AstNode, +): Effect.Effect => + Effect.gen(function* () { + const cursor = yield* runner.syncIterator(source, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node).as("TypeError") + } + const numbers: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) return Math.sumPrecise(numbers) + yield* preserveConsumerError( + cursor, + Effect.sync(() => { + if (typeof step.value !== "number") { + throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError") + } + numbers.push(step.value) + }), + ) + } + }) diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index b4182904d6..583a4b4a42 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect" import { type AstNode, AsyncIteratorSymbol, @@ -7,8 +8,9 @@ import { } from "../interpreter/model.js" import { containsOpaqueReference } from "../interpreter/references.js" import { isBlockedMember } from "../tool-runtime.js" -import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js" +import { isCodeModeValue, CodeModePromise } from "../values.js" import { boundedData, coerceToString } from "./value.js" +import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js" export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) @@ -39,11 +41,6 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node) out[key] = item } - const addEntry = (out: Record, key: unknown, item: unknown): void => { - boundedData(key, "Object.fromEntries key") - boundedData(item, "Object.fromEntries value") - guardedSet(out, coerceToString(key), item) - } switch (name) { case "keys": return Object.keys(requireObject()) @@ -79,32 +76,47 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast } return out } - case "fromEntries": { - if (args[0] instanceof CodeModeMap) { - const out: Record = Object.create(null) - for (const [key, item] of args[0].map.entries()) addEntry(out, key, item) - return out - } - if (args[0] instanceof CodeModeURLSearchParams) { - const out: Record = Object.create(null) - for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) - return out - } - const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0] - if (!Array.isArray(pairs)) { - boundedData(args[0], "Object.fromEntries input") - throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) - } - const out: Record = Object.create(null) - for (const pair of pairs) { - const validated = boundedData(pair, "Object.fromEntries entry") - if (validated === null || typeof validated !== "object" || isCodeModeValue(validated)) - throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node) - const entry = pair as Record - addEntry(out, entry[0], entry[1]) - } - return out - } } throw new InterpreterRuntimeError(`Object.${name} is not available.`, node) } + +export const invokeObjectFromEntries = ( + runner: SyncIteratorRunner, + source: unknown, + node: AstNode, +): Effect.Effect, unknown, R> => { + const out: Record = Object.create(null) + return Effect.gen(function* () { + const cursor = yield* runner.syncIterator(source, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node).as( + "TypeError", + ) + } + while (true) { + const step = yield* cursor.next + if (step.done) return out + yield* preserveConsumerError( + cursor, + Effect.sync(() => { + if ( + step.value === null || + typeof step.value !== "object" || + isCodeModeValue(step.value) || + containsOpaqueReference(step.value) + ) { + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as( + "TypeError", + ) + } + const entry = step.value as Record + boundedData(entry[0], "Object.fromEntries key") + boundedData(entry[1], "Object.fromEntries value") + const key = coerceToString(entry[0]) + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node) + out[key] = entry[1] + }), + ) + } + }) +} diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f64858aa35..febc2ba586 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -553,7 +553,7 @@ export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget "## Language", "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.", + "Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.", "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index c017a0b971..8699daf07d 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -732,9 +732,10 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("not a general-purpose runtime") expect(instructions).not.toContain("Standard modern JavaScript works") expect(instructions).not.toContain("TypeScript type annotations") - for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) { + for (const missing of ["Modules/imports", "classes", "fetch"]) { expect(instructions).toContain(missing) } + expect(instructions).not.toContain("generators") expect(instructions).not.toContain("new Promise(...) are unavailable") expect(instructions).not.toContain("promise chaining") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") diff --git a/packages/codemode/test/generator-test262.test.ts b/packages/codemode/test/generator-test262.test.ts new file mode 100644 index 0000000000..70528f51fc --- /dev/null +++ b/packages/codemode/test/generator-test262.test.ts @@ -0,0 +1,1271 @@ +/* + * Portable portions adapted from Test262 at revision + * 250f204f23a9249ff204be2baec29600faae7b75. Exact source paths are cited + * beside the corresponding tests below. + * + * Copyright (C) 2013-2017 the V8 project authors. All rights reserved. + * Copyright (C) 2018 Valerie Young. All rights reserved. + * Copyright (C) 2020 Alexey Shvayka. All rights reserved. + * Copyright (C) 2022 Kevin Gibbons. All rights reserved. + * Copyright Ecma International. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) + +const value = async (code: string) => { + const result = await execute(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +describe("confined generators", () => { + // test/built-ins/GeneratorPrototype/next/return-yield-expr.js + test("is lazy and preserves next(value), nested suspension, return, and exhaustion", async () => { + expect( + await value(` + const events = [] + function* generate() { + events.push("start") + const received = yield 1 + (yield 2) + return received + } + const iterator = generate() + const before = events.slice() + const first = iterator.next(99) + const second = iterator.next(3) + const third = iterator.next(7) + const fourth = iterator.next(8) + return [before, events, first, second, third, fourth] + `), + ).toEqual([ + [], + ["start"], + { value: 2, done: false }, + { value: 4, done: false }, + { value: 7, done: true }, + { value: null, done: true }, + ]) + }) + + test("routes throw and return through catch and finally", async () => { + expect( + await value(` + function* generate() { + try { + try { yield "try" } catch (error) { yield "caught " + error } + } finally { + yield "finally" + } + } + const iterator = generate() + return [iterator.next(), iterator.throw("boom"), iterator.return("done"), iterator.next()] + `), + ).toEqual([ + { value: "try", done: false }, + { value: "caught boom", done: false }, + { value: "finally", done: false }, + { value: "done", done: true }, + ]) + }) + + test("throws into a suspended generator and after exhaustion", async () => { + expect( + await value(` + function* generate() { yield 1 } + const iterator = generate() + iterator.next() + let suspended + let exhausted + try { iterator.throw("first") } catch (error) { suspended = error } + try { iterator.throw("second") } catch (error) { exhausted = error } + return [suspended, exhausted, iterator.next()] + `), + ).toEqual(["first", "second", { value: null, done: true }]) + }) + + test("rejects synchronous generator reentry", async () => { + expect( + await value(` + let iterator + function* generate() { + try { iterator.next() } catch (error) { return error.name } + } + iterator = generate() + return iterator.next() + `), + ).toEqual({ value: "TypeError", done: true }) + }) + + test("delegates yield*, forwards next values, and receives the delegate return value", async () => { + expect( + await value(` + function* inner() { + const input = yield 1 + return input * 2 + } + function* outer() { + const result = yield* inner() + return result + 1 + } + const iterator = outer() + return [iterator.next(), iterator.next(4)] + `), + ).toEqual([ + { value: 1, done: false }, + { value: 9, done: true }, + ]) + }) + + test("delegates throw and return to custom iterators", async () => { + expect( + await value(` + const calls = [] + let step = 0 + const delegate = { + [Symbol.iterator]: () => delegate, + next(...args) { + calls.push(["next", args.length, args[0]]) + step += 1 + return step === 1 ? { value: "one", done: false } : { value: "end", done: true } + }, + throw(value) { + calls.push(["throw", value]) + return { value: "recovered", done: false } + }, + return(value) { + calls.push(["return", value]) + return { value: value + "!", done: true } + }, + } + function* generate() { return yield* delegate } + const iterator = generate() + const first = iterator.next() + const second = iterator.throw("x") + const third = iterator.return("stop") + return [first, second, third, calls] + `), + ).toEqual([ + { value: "one", done: false }, + { value: "recovered", done: false }, + { value: "stop!", done: true }, + [ + ["next", 1, null], + ["throw", "x"], + ["return", "stop"], + ], + ]) + }) + + test("uses the missing-throw delegation path for built-in iterables", async () => { + expect( + await value(` + function* generate() { yield* [1, 2] } + const iterator = generate() + iterator.next() + try { iterator.throw("boom") } catch (error) { return error.name } + `), + ).toBe("TypeError") + }) + + test("exposes only the appropriate iterator symbol and works in for...of", async () => { + expect( + await value(` + function* generate() { yield 1; yield 2 } + const iterator = generate() + const symbols = [iterator[Symbol.iterator]() === iterator, iterator[Symbol.asyncIterator]] + const values = [] + for (const item of iterator) values.push(item) + return [symbols, values] + `), + ).toEqual([ + [true, null], + [1, 2], + ]) + }) + + test("accepts generator methods as iterator acquisition results", async () => { + expect( + await value(` + const sync = { + *[Symbol.iterator]() { yield 1; yield 2 }, + } + const asynchronous = { + async *[Symbol.asyncIterator]() { yield 3; yield 4 }, + } + const values = [] + for (const item of sync) values.push(item) + for await (const item of asynchronous) values.push(item) + return values + `), + ).toEqual([1, 2, 3, 4]) + }) + + test("closes a generator when for...of exits abruptly", async () => { + expect( + await value(` + const events = [] + function* generate() { + try { yield 1; yield 2 } finally { events.push("closed") } + } + for (const item of generate()) break + return events + `), + ).toEqual(["closed"]) + }) + + test("async generator requests are promises and execute in request order", async () => { + expect( + await value(` + const events = [] + async function* generate() { + events.push("start") + const input = yield Promise.resolve(1) + events.push("received " + input) + return Promise.resolve(3) + } + const iterator = generate() + const first = iterator.next() + const second = iterator.next(2) + const third = iterator.next(4) + const promiseFlags = [first instanceof Promise, second instanceof Promise, third instanceof Promise] + return [promiseFlags, await Promise.all([first, second, third]), events] + `), + ).toEqual([ + [true, true, true], + [ + { value: 1, done: false }, + { value: 3, done: true }, + { value: null, done: true }, + ], + ["start", "received 2"], + ]) + }) + + test("keeps requests queued while a completed generator adopts return values", async () => { + expect( + await value(` + const events = [] + let resolve + const pending = new Promise((done) => { resolve = done }) + async function* generate() { return 1 } + const iterator = generate() + const first = iterator.next() + const returned = iterator.return(pending) + const later = first.then(() => iterator.next()).then(() => events.push("later")) + returned.then(() => events.push("returned")) + await first + await Promise.resolve() + const before = events.slice() + resolve(9) + await Promise.all([returned, later]) + return [before, events] + `), + ).toEqual([[], ["returned", "later"]]) + }) + + test("serializes requests made after async generator exhaustion", async () => { + expect( + await value(` + const events = [] + let resolve + const pending = new Promise((done) => { resolve = done }) + async function* generate() { return 1 } + const iterator = generate() + await iterator.next() + const returned = iterator.return(pending).then(() => events.push("returned")) + const later = iterator.next().then(() => events.push("later")) + await Promise.resolve() + const before = events.slice() + resolve(9) + await Promise.all([returned, later]) + return [before, events] + `), + ).toEqual([[], ["returned", "later"]]) + }) + + test("async generators adopt yielded, returned, and return-request promises", async () => { + expect( + await value(` + async function* yielded() { yield Promise.resolve(1) } + async function* returned() { return Promise.resolve(2) } + async function* pending() { yield 0 } + const first = yielded() + const second = returned() + const third = pending() + const exhausted = returned() + await third.next() + await exhausted.next() + return await Promise.all([ + first.next(), + second.next(), + third.return(Promise.resolve(3)), + exhausted.return(Promise.resolve(4)), + ]) + `), + ).toEqual([ + { value: 1, done: false }, + { value: 2, done: true }, + { value: 3, done: true }, + { value: 4, done: true }, + ]) + }) + + test("awaits return-request promises before injecting completion", async () => { + expect( + await value(` + const events = [] + async function* generate() { + try { + yield 1 + } catch (error) { + events.push("caught " + error) + yield "recovered" + } finally { + events.push("finally") + } + } + const iterator = generate() + const first = await iterator.next() + const returned = await iterator.return(Promise.reject("bad")) + const beforeNext = events.slice() + const last = await iterator.next() + return [first, returned, beforeNext, last, events] + `), + ).toEqual([ + { value: 1, done: false }, + { value: "recovered", done: false }, + ["caught bad"], + { value: null, done: true }, + ["caught bad", "finally"], + ]) + }) + + test("loop consumers call iterator next with no arguments", async () => { + expect( + await value(` + const calls = [] + let syncStep = 0 + const sync = { + [Symbol.iterator]: () => sync, + next(...args) { + calls.push(["sync", args.length]) + syncStep += 1 + return { value: syncStep, done: syncStep > 1 } + }, + } + let asyncStep = 0 + const asynchronous = { + [Symbol.asyncIterator]: () => asynchronous, + async next(...args) { + calls.push(["async", args.length]) + asyncStep += 1 + return { value: asyncStep, done: asyncStep > 1 } + }, + } + for (const item of sync) {} + for await (const item of asynchronous) {} + return calls + `), + ).toEqual([ + ["sync", 0], + ["sync", 0], + ["async", 0], + ["async", 0], + ]) + }) + + test("supports async generators in for await...of and keeps them out of for...of", async () => { + expect( + await value(` + async function* generate() { yield 1; yield await Promise.resolve(2) } + const values = [] + for await (const item of generate()) values.push(item) + let name + try { for (const item of generate()) {} } catch (error) { name = error.name } + return [values, name] + `), + ).toEqual([[1, 2], "TypeError"]) + }) + + test("keeps generator references opaque at the data boundary", async () => { + const result = await execute(`function* generate() { yield 1 } return generate()`) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("InvalidDataValue") + }) + + // test/built-ins/GeneratorPrototype/return/from-state-suspended-start.js + // test/built-ins/GeneratorPrototype/throw/from-state-suspended-start.js + // test/built-ins/GeneratorPrototype/return/from-state-completed.js + // test/built-ins/GeneratorPrototype/throw/from-state-completed.js + test("honors sync return and throw in suspended-start and completed states", async () => { + expect( + await value(` + const events = [] + function* generate() { events.push("body"); yield 1 } + + const returned = generate() + const startReturn = returned.return(7) + const afterReturn = returned.next() + + const thrown = generate() + let startThrow + try { thrown.throw("start") } catch (error) { startThrow = error } + const afterThrow = thrown.next() + let completedThrow + try { thrown.throw("completed") } catch (error) { completedThrow = error } + + return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events] + `), + ).toEqual([ + { value: 7, done: true }, + { value: null, done: true }, + "start", + { value: null, done: true }, + "completed", + [], + ]) + }) + + // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedStart-promise.js + // test/built-ins/AsyncGeneratorPrototype/throw/throw-suspendedStart.js + // test/built-ins/AsyncGeneratorPrototype/return/return-state-completed.js + // test/built-ins/AsyncGeneratorPrototype/throw/throw-state-completed.js + test("honors async return and throw in suspended-start and completed states", async () => { + expect( + await value(` + const events = [] + async function* generate() { events.push("body"); yield 1 } + + const returned = generate() + const startReturn = await returned.return(Promise.resolve(7)) + const afterReturn = await returned.next() + + const thrown = generate() + let startThrow + try { await thrown.throw("start") } catch (error) { startThrow = error } + const afterThrow = await thrown.next() + let completedThrow + try { await thrown.throw("completed") } catch (error) { completedThrow = error } + + return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events] + `), + ).toEqual([ + { value: 7, done: true }, + { value: null, done: true }, + "start", + { value: null, done: true }, + "completed", + [], + ]) + }) + + // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally.js + // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally-return.js + // test/built-ins/AsyncGeneratorPrototype/throw/throw-suspendedYield-try-finally-throw.js + test("runs finally yields and lets finally completions override requests", async () => { + expect( + await value(` + async function* yielding() { + try { yield 1 } finally { yield 2 } + } + async function* returning() { + try { yield 1 } finally { return "override" } + } + async function* throwing() { + try { yield 1 } finally { throw "override" } + } + + const first = yielding() + await first.next() + const finallyYield = await first.return("sent") + const preservedReturn = await first.next() + + const second = returning() + await second.next() + const overriddenReturn = await second.return("sent") + + const third = throwing() + await third.next() + let overriddenThrow + try { await third.throw("sent") } catch (error) { overriddenThrow = error } + return [finallyYield, preservedReturn, overriddenReturn, overriddenThrow] + `), + ).toEqual([{ value: 2, done: false }, { value: "sent", done: true }, { value: "override", done: true }, "override"]) + }) + + // test/language/statements/async-generator/yield-promise-reject-next-catch.js + // test/language/statements/async-generator/yield-promise-reject-next-yield-star-sync-iterator.js + test("rejects yielded promises and closes direct and sync-delegating async generators", async () => { + expect( + await value(` + async function* direct() { yield Promise.reject("direct") } + async function* delegated() { yield* [Promise.reject("delegated"), "unreachable"] } + const results = [] + for (const iterator of [direct(), delegated()]) { + try { await iterator.next() } catch (error) { results.push(error) } + results.push(await iterator.next()) + } + return results + `), + ).toEqual(["direct", { value: null, done: true }, "delegated", { value: null, done: true }]) + }) + + // test/built-ins/AsyncFromSyncIteratorPrototype/next/for-await-iterator-next-rejected-promise-close.js + // test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-iterator-next-rejected-promise-close.js + // test/built-ins/AsyncFromSyncIteratorPrototype/throw/iterator-result-rejected-promise-close.js + test("closes sync iterators when async-from-sync values reject", async () => { + expect( + await value(` + const events = [] + function* loopSource() { + try { yield Promise.reject("loop") } finally { events.push("loop close") } + } + let loopError + try { for await (const item of loopSource()) {} } catch (error) { loopError = error } + + function* yieldSource() { + try { yield Promise.reject("yield") } finally { events.push("yield close") } + } + async function* delegate() { yield* yieldSource() } + let yieldError + try { await delegate().next() } catch (error) { yieldError = error } + + const throwing = { + [Symbol.iterator]: () => throwing, + next: () => ({ value: 1, done: false }), + throw: () => ({ value: Promise.reject("throw"), done: false }), + return: () => { events.push("throw close"); return {} }, + } + async function* throwDelegate() { yield* throwing } + const iterator = throwDelegate() + await iterator.next() + let throwError + try { await iterator.throw("sent") } catch (error) { throwError = error } + return [loopError, yieldError, throwError, events] + `), + ).toEqual(["loop", "yield", "throw", ["loop close", "yield close", "throw close"]]) + }) + + test("does not close rejected terminal or delegated return values", async () => { + expect( + await value(` + const events = [] + const terminal = { + [Symbol.iterator]: () => terminal, + next: () => ({ value: Promise.reject("terminal"), done: true }), + return: () => { events.push("terminal close"); return {} }, + } + let terminalError + try { for await (const item of terminal) {} } catch (error) { terminalError = error } + + let returnCount = 0 + const returned = { + [Symbol.iterator]: () => returned, + next: () => ({ value: 1, done: false }), + return: () => { + returnCount += 1 + return { value: Promise.reject("return"), done: false } + }, + } + async function* delegate() { yield* returned } + const iterator = delegate() + await iterator.next() + let returnError + try { await iterator.return("sent") } catch (error) { returnError = error } + return [terminalError, returnError, returnCount, events] + `), + ).toEqual(["terminal", "return", 1, []]) + }) + + test("serializes a mixed async next, throw, return, and next request queue", async () => { + expect( + await value(` + async function* generate() { + try { + try { yield 1; yield 2 } catch (error) { yield "caught " + error } + } finally { + yield "finally" + } + } + const iterator = generate() + const first = iterator.next() + const thrown = iterator.throw("x") + const returned = iterator.return("done") + const last = iterator.next() + return await Promise.all([first, thrown, returned, last]) + `), + ).toEqual([ + { value: 1, done: false }, + { value: "caught x", done: false }, + { value: "finally", done: false }, + { value: "done", done: true }, + ]) + }) + + // test/built-ins/AsyncGeneratorPrototype/next/request-queue-order.js + // test/built-ins/AsyncGeneratorPrototype/throw/request-queue-order-state-executing.js + // test/built-ins/AsyncGeneratorPrototype/return/request-queue-order-state-executing.js + test("orders async requests enqueued while generators are executing", async () => { + expect( + await value(` + const events = [] + let returned, returnRequest + async function* returnWhileExecuting() { + returnRequest = returned.return(42).then((result) => events.push(["return", result])) + yield 1 + } + returned = returnWhileExecuting() + const firstReturn = returned.next().then((result) => events.push(["first return", result])) + await Promise.all([firstReturn, returnRequest]) + + let thrown, throwRequest + async function* throwWhileExecuting() { + throwRequest = thrown.throw("boom").catch((error) => events.push(["throw", error])) + yield 2 + } + thrown = throwWhileExecuting() + const firstThrow = thrown.next().then((result) => events.push(["first throw", result])) + await Promise.all([firstThrow, throwRequest]) + + async function* queued() { yield "first"; yield "second" } + const iterator = queued() + const first = iterator.next() + const second = iterator.next() + const third = iterator.next() + await Promise.all([ + third.then(() => events.push("third")), + second.then(() => events.push("second")), + first.then(() => events.push("first")), + ]) + return events + `), + ).toEqual([ + ["first return", { value: 1, done: false }], + ["return", { value: 42, done: true }], + ["first throw", { value: 2, done: false }], + ["throw", "boom"], + "first", + "second", + "third", + ]) + }) + + // test/language/statements/async-generator/yield-star-promise-not-unwrapped.js + // test/language/statements/async-generator/yield-star-sync-next.js + test("preserves async iterator promise values but unwraps async-from-sync values", async () => { + expect( + await value(` + const asyncValue = Promise.resolve("async") + const asynchronous = { + [Symbol.asyncIterator]: () => asynchronous, + next: () => ({ value: asyncValue, done: false }), + } + const syncValue = Promise.resolve("sync") + const synchronous = { + [Symbol.iterator]: () => synchronous, + next: () => ({ value: syncValue, done: false }), + } + async function* delegate(value) { yield* value } + const asyncResult = await delegate(asynchronous).next() + const syncResult = await delegate(synchronous).next() + return [asyncResult.value === asyncValue, await asyncResult.value, syncResult] + `), + ).toEqual([true, "async", { value: "sync", done: false }]) + }) + + test("captures delegated result done before awaiting async-from-sync values", async () => { + expect( + await value(` + const result = { value: Promise.resolve(1), done: false } + result.value.then(() => { result.done = true }) + const source = { + [Symbol.iterator]: () => source, + next: () => result, + } + async function* delegate() { yield* source } + return await delegate().next() + `), + ).toEqual({ value: 1, done: false }) + }) + + // test/language/statements/async-generator/yield-star-async-next.js + // test/language/statements/async-generator/yield-star-sync-return.js + // test/language/statements/async-generator/yield-star-async-throw.js + test("forwards next, return, and throw through sync and async yield delegates", async () => { + expect( + await value(` + const calls = [] + const make = (symbol, label) => { + let step = 0 + const iterator = { + [symbol]: () => iterator, + next(...args) { + calls.push([label, "next", args.length, args[0]]) + step += 1 + return { value: step, done: false } + }, + return(value) { + calls.push([label, "return", value]) + return { value: value + "!", done: true } + }, + throw(value) { + calls.push([label, "throw", value]) + return { value: "caught " + value, done: false } + }, + } + return iterator + } + async function* delegate(iterator) { return yield* iterator } + const sync = delegate(make(Symbol.iterator, "sync")) + const asynchronous = delegate(make(Symbol.asyncIterator, "async")) + const results = [await sync.next(9), await sync.next(2), await sync.throw("x"), await sync.return("stop")] + results.push(await asynchronous.next(9), await asynchronous.next(3), await asynchronous.throw("y")) + return [results, calls] + `), + ).toEqual([ + [ + { value: 1, done: false }, + { value: 2, done: false }, + { value: "caught x", done: false }, + { value: "stop!", done: true }, + { value: 1, done: false }, + { value: 2, done: false }, + { value: "caught y", done: false }, + ], + [ + ["sync", "next", 1, null], + ["sync", "next", 1, 2], + ["sync", "throw", "x"], + ["sync", "return", "stop"], + ["async", "next", 1, null], + ["async", "next", 1, 3], + ["async", "throw", "y"], + ], + ]) + }) + + // test/language/statements/async-generator/yield-star-sync-return.js + // test/language/statements/async-generator/yield-star-async-throw.js + test("continues delegation when return and throw report done false", async () => { + expect( + await value(` + let returns = 0 + const synchronous = { + [Symbol.iterator]: () => synchronous, + next: () => ({ value: "next", done: false }), + return(value) { + returns += 1 + return { value: returns === 1 ? "return pending" : value, done: returns > 1 } + }, + } + let throws = 0 + const asynchronous = { + [Symbol.asyncIterator]: () => asynchronous, + next: () => ({ value: "next", done: false }), + throw(value) { + throws += 1 + return { value: throws === 1 ? "throw pending" : value, done: throws > 1 } + }, + } + async function* delegate(iterator) { return yield* iterator } + const returned = delegate(synchronous) + const thrown = delegate(asynchronous) + return [ + await returned.next(), + await returned.return("first"), + await returned.return("second"), + await thrown.next(), + await thrown.throw("first"), + await thrown.throw("second"), + ] + `), + ).toEqual([ + { value: "next", done: false }, + { value: "return pending", done: false }, + { value: "second", done: true }, + { value: "next", done: false }, + { value: "throw pending", done: false }, + { value: "second", done: true }, + ]) + }) + + // test/language/expressions/yield/star-rhs-iter-nrml-next-call-non-obj.js + // test/language/expressions/yield/star-rhs-iter-thrw-thrw-call-non-obj.js + // test/language/expressions/yield/star-rhs-iter-rtrn-rtrn-call-non-obj.js + // test/language/statements/async-generator/yield-star-next-not-callable-number-throw.js + test("rejects malformed yield delegate methods and iterator results", async () => { + expect( + await value(` + const run = (iterator, operation) => { + function* generate() { + try { yield* iterator } catch (error) { return error.name } + } + const value = generate() + const first = value.next() + return operation === "next" ? first : value[operation]() + } + const iterable = (fields) => ({ [Symbol.iterator]: () => fields }) + const nextResult = run(iterable({ next: () => 1 }), "next") + const throwResult = run(iterable({ next: () => ({ done: false }), throw: () => 1 }), "throw") + const returnResult = run(iterable({ next: () => ({ done: false }), return: () => 1 }), "return") + + const badAsync = { + [Symbol.asyncIterator]: () => ({ next: 1 }), + } + async function* asynchronous() { + try { yield* badAsync } catch (error) { return error.name } + } + return [nextResult, throwResult, returnResult, await asynchronous().next()] + `), + ).toEqual([ + { value: "TypeError", done: true }, + { value: "TypeError", done: true }, + { value: "TypeError", done: true }, + { value: "TypeError", done: true }, + ]) + }) + + // test/language/expressions/yield/captured-free-vars.js + // test/language/statements/generators/dflt-params-ref-prior.js + // test/language/expressions/generators/dflt-params-ref-prior.js + // test/language/expressions/object/method-definition/generator-no-yield.js + test("supports declaration, expression, and method forms with closures and parameters", async () => { + expect( + await value(` + const captured = 4 + function* declaration(x, y = x, ...rest) { yield captured + y + rest[0] } + const expression = function* (x, y = x) { yield captured + y } + const object = { *method({ value }, extra = 1) { return captured + value + extra } } + return [declaration(2, undefined, 3).next(), expression(5).next(), object.method({ value: 6 }).next()] + `), + ).toEqual([ + { value: 9, done: false }, + { value: 9, done: false }, + { value: 11, done: true }, + ]) + }) + + // test/language/expressions/assignment/dstr/array-elem-iter-nrml-close.js + test("steps holes and rest and closes array binding and assignment patterns early", async () => { + expect( + await value(` + const events = [] + function* binding() { + try { events.push("b1"); yield 1; events.push("b2"); yield 2; events.push("b3"); yield 3; yield 4 } + finally { events.push("binding close") } + } + const [first, , third] = binding() + function* assignment() { + try { yield 5; yield 6; yield 7 } + finally { events.push("assignment close") } + } + let head, rest + ;[head, ...rest] = assignment() + return [first, third, head, rest, events] + `), + ).toEqual([1, 3, 5, [6, 7], ["b1", "b2", "b3", "binding close", "assignment close"]]) + }) + + // test/language/statements/variable/dstr/ary-ptrn-elem-id-init-throws.js + // test/language/expressions/assignment/dstr/array-elem-iter-thrw-close-err.js + test("closes on destructuring defaults and preserves the binding error over return failure", async () => { + expect( + await value(` + const events = [] + const iterator = { + [Symbol.iterator]: () => iterator, + next: () => ({ value: undefined, done: false }), + return: () => { events.push("close"); throw "close error" }, + } + let caught + try { + const [value = (() => { throw "binding error" })()] = iterator + } catch (error) { caught = error } + return [caught, events] + `), + ).toEqual(["binding error", ["close"]]) + }) + + test("does not close an exhausted iterator when a destructuring default fails", async () => { + expect( + await value(` + const events = [] + const iterator = { + [Symbol.iterator]: () => iterator, + next: () => ({ done: true }), + return: () => { events.push("close"); return {} }, + } + try { const [item = (() => { throw "default" })()] = iterator } catch {} + return events + `), + ).toEqual([]) + }) + + test("consumes generators in array and argument spread without awaiting yielded promises", async () => { + expect( + await value(` + function* values() { yield 1; yield Promise.resolve(2); yield 3 } + const array = [...values()] + const args = ((...items) => items)(...values()) + return [array[0], array[1] instanceof Promise, await array[1], args[2]] + `), + ).toEqual([1, true, 2, 3]) + }) + + test("constructs Map, Set, and URLSearchParams from generators lazily", async () => { + expect( + await value(` + const events = [] + function* pairs() { events.push(1); yield ["a", 1]; events.push(2); yield ["b", 2] } + function* values() { yield 1; yield 1; yield 2 } + const map = new Map(pairs()) + const set = new Set(values()) + const params = new URLSearchParams(pairs()) + return [map.get("b"), [...set], params.toString(), events] + `), + ).toEqual([2, [1, 2], "a=1&b=2", [1, 2, 1, 2]]) + }) + + test("keeps built-in collection iteration live during callbacks", async () => { + expect( + await value(` + const map = new Map([[1, 1], [2, 2]]) + const mapped = Array.from(map, (entry, index) => { + if (index === 0) map.set(3, 3) + return entry[0] + }) + const set = new Set([1, 2]) + const grouped = Map.groupBy(set, (item, index) => { + if (index === 0) set.add(3) + return "items" + }) + return [mapped, grouped.get("items")] + `), + ).toEqual([ + [1, 2, 3], + [1, 2, 3], + ]) + }) + + test("keeps built-in collection iteration live in loops and yield delegation", async () => { + expect( + await value(` + const map = new Map([[1, 1], [2, 2]]) + const mapValues = [] + for (const [key] of map) { + mapValues.push(key) + if (key === 1) map.set(3, 3) + } + const set = new Set([1, 2]) + const setValues = [] + for await (const item of set) { + setValues.push(item) + if (item === 1) set.add(3) + } + const params = new URLSearchParams("a=1&b=2") + function* delegate() { yield* params } + const iterator = delegate() + const first = iterator.next() + params.append("c", "3") + return [mapValues, setValues, first, iterator.next(), iterator.next()] + `), + ).toEqual([ + [1, 2, 3], + [1, 2, 3], + { value: ["a", "1"], done: false }, + { value: ["b", "2"], done: false }, + { value: ["c", "3"], done: false }, + ]) + }) + + test("preserves async-from-sync turns for built-in loop completion and close", async () => { + expect( + await value(` + const completed = [] + Promise.resolve().then(() => completed.push("reaction")) + for await (const item of []) {} + completed.push("after") + + const closed = [] + for await (const item of [1]) { + Promise.resolve().then(() => closed.push("reaction")) + break + } + closed.push("after") + return [completed, closed] + `), + ).toEqual([ + ["reaction", "after"], + ["reaction", "after"], + ]) + }) + + test("reads iterator return only when closing", async () => { + expect( + await value(` + const events = [] + const iterator = { + [Symbol.iterator]: () => iterator, + next() { + iterator.return = () => { events.push("new"); return {} } + return { value: 1, done: false } + }, + return: () => { events.push("old"); return {} }, + } + const [item] = iterator + return [item, events] + `), + ).toEqual([1, ["new"]]) + }) + + test("accepts iterable URLSearchParams entry pairs", async () => { + expect( + await value(` + function* pair() { yield "a"; yield 1 } + function* entries() { yield pair() } + return new URLSearchParams(entries()).toString() + `), + ).toBe("a=1") + }) + + test("converts URLSearchParams pair elements before requesting the next", async () => { + expect( + await value(` + const events = [] + function* pair() { + try { + events.push("first") + yield (function* () {})() + events.push("second") + yield 2 + } finally { events.push("pair close") } + } + function* entries() { + try { yield pair() } finally { events.push("outer close") } + } + let name + try { new URLSearchParams(entries()) } catch (error) { name = error.name } + return [events, name] + `), + ).toEqual([["first", "pair close", "outer close"], "Error"]) + }) + + test("validates URLSearchParams pair lengths after converting the outer sequence", async () => { + expect( + await value(` + const events = [] + function* entries() { + try { + events.push("one") + yield ["a"] + events.push("two") + yield ["b", 2] + } finally { events.push("outer close") } + } + let name + try { new URLSearchParams(entries()) } catch (error) { name = error.name } + return [events, name] + `), + ).toEqual([["one", "two", "outer close"], "TypeError"]) + }) + + test("closes entry constructors when a generator yields a malformed entry", async () => { + expect( + await value(` + const events = [] + function* mapEntries() { try { yield ["a", 1]; yield null } finally { events.push("map close") } } + function* parameterEntries() { try { yield ["a"]; yield ["b", 2] } finally { events.push("params close") } } + const names = [] + try { new Map(mapEntries()) } catch (error) { names.push(error.name) } + try { new URLSearchParams(parameterEntries()) } catch (error) { names.push(error.name) } + return [names, events] + `), + ).toEqual([ + ["TypeError", "TypeError"], + ["map close", "params close"], + ]) + }) + + // test/built-ins/Array/from/iter-map-fn-args.js + // test/built-ins/Array/from/iter-map-fn-err.js + test("interleaves Array.from mapping and closes when its mapper fails", async () => { + expect( + await value(` + const events = [] + function* source() { + try { events.push("next 1"); yield 1; events.push("next 2"); yield 2 } + finally { events.push("close") } + } + const mapped = Array.from(source(), (item) => { events.push("map " + item); return item * 2 }) + let caught + try { Array.from(source(), (item) => { throw "mapper " + item }) } catch (error) { caught = error } + return [mapped, caught, events] + `), + ).toEqual([[2, 4], "mapper 1", ["next 1", "map 1", "next 2", "map 2", "close", "next 1", "close"]]) + }) + + test("reads array-like Array.from values immediately before mapping", async () => { + expect( + await value(` + const source = { 0: 1, 1: 2, length: 2 } + return Array.from(source, (item, index) => { + if (index === 0) source[1] = 9 + return item + }) + `), + ).toEqual([1, 9]) + }) + + test("interleaves Object.groupBy and Map.groupBy callbacks and closes on callback failure", async () => { + expect( + await value(` + const events = [] + function* source() { try { events.push("next"); yield 1; events.push("next"); yield 2 } finally { events.push("close") } } + const object = Object.groupBy(source(), (item) => { events.push("object " + item); return item % 2 }) + const map = Map.groupBy(source(), (item) => { events.push("map " + item); return item % 2 }) + let caught + try { Object.groupBy(source(), () => { throw "callback" }) } catch (error) { caught = error } + return [object, Object.fromEntries(map), caught, events] + `), + ).toEqual([ + { 0: [2], 1: [1] }, + { 0: [2], 1: [1] }, + "callback", + ["next", "object 1", "next", "object 2", "close", "next", "map 1", "next", "map 2", "close", "next", "close"], + ]) + }) + + test("consumes Promise combinator generators in order and observes rejections", async () => { + expect( + await value(` + function* items() { yield Promise.resolve(1); yield Promise.reject("bad"); yield 3 } + const settled = await Promise.allSettled(items()) + let all, any + try { await Promise.all(items()) } catch (error) { all = error } + try { await Promise.any((function* () { yield Promise.reject("a"); yield Promise.reject("b") })()) } + catch (error) { any = error.errors } + const race = await Promise.race((function* () { yield 4; yield Promise.resolve(5) })()) + return [settled, all, any, race] + `), + ).toEqual([ + [ + { status: "fulfilled", value: 1 }, + { status: "rejected", reason: "bad" }, + { status: "fulfilled", value: 3 }, + ], + "bad", + ["a", "b"], + 4, + ]) + }) + + test("finishes Promise combinator iterator consumption before returning the promise", async () => { + expect( + await value(` + const events = [] + function* items() { events.push("first"); yield 1; events.push("second"); yield 2 } + const promise = Promise.all(items()) + events.push("after call") + await promise + return events + `), + ).toEqual(["first", "second", "after call"]) + }) + + // test/built-ins/Object/fromEntries/iterator-closed-for-null-entry.js + test("closes Object.fromEntries on malformed entries and consumes valid generators", async () => { + expect( + await value(` + const events = [] + function* valid() { yield ["a", 1]; yield ["b", 2] } + function* invalid() { try { yield ["a", 1]; yield null; yield ["c", 3] } finally { events.push("close") } } + let name + try { Object.fromEntries(invalid()) } catch (error) { name = error.name } + return [Object.fromEntries(valid()), name, events] + `), + ).toEqual([{ a: 1, b: 2 }, "TypeError", ["close"]]) + }) + + test("consumes AggregateError and Math.sumPrecise generators and closes on invalid numbers", async () => { + expect( + await value(` + const events = [] + function* errors() { yield "a"; yield "b" } + function* numbers() { yield 1e30; yield 0.1; yield -1e30 } + function* invalid() { try { yield 1; yield "bad"; yield 2 } finally { events.push("close") } } + const aggregate = new AggregateError(errors(), "message") + let name + try { Math.sumPrecise(invalid()) } catch (error) { name = error.name } + return [aggregate.errors, aggregate.message, Math.sumPrecise(numbers()), name, events] + `), + ).toEqual([["a", "b"], "message", 0.1, "TypeError", ["close"]]) + }) + + test("rejects async generators in every synchronous iterable consumer", async () => { + expect( + await value(` + async function* source() { yield ["a", 1] } + const checks = [ + () => [...source()], + () => ((...items) => items)(...source()), + () => { const [item] = source(); return item }, + () => Array.from(source()), + () => new Map(source()), + () => new Set(source()), + () => new URLSearchParams(source()), + () => Object.fromEntries(source()), + () => Object.groupBy(source(), (item) => item), + () => Math.sumPrecise(source()), + () => new AggregateError(source()), + ] + const names = [] + for (const check of checks) { + try { check() } catch (error) { names.push(error.name) } + } + try { await Promise.all(source()) } catch (error) { names.push(error.name) } + return names + `), + ).toEqual(Array(12).fill("TypeError")) + }) + + // test/built-ins/Array/from/iter-get-iter-err.js + // test/built-ins/Array/from/iter-adv-err.js + test("does not close when iterator acquisition or next-result validation fails", async () => { + expect( + await value(` + const events = [] + const acquisition = { [Symbol.iterator]: () => { events.push("acquire"); throw "acquisition" } } + const malformed = { + [Symbol.iterator]: () => malformed, + next: () => { events.push("next"); return 1 }, + return: () => { events.push("close"); return {} }, + } + for (const source of [acquisition, malformed]) { + try { Array.from(source) } catch {} + } + return events + `), + ).toEqual(["acquire", "next"]) + }) + + test("reports synchronous iterator failures before queued promise reactions", async () => { + expect( + await value(` + const events = [] + Promise.resolve().then(() => events.push("reaction")) + const iterator = { + [Symbol.iterator]: () => iterator, + next: () => { throw "next" }, + } + try { Array.from(iterator) } catch { events.push("catch") } + await Promise.resolve() + return events + `), + ).toEqual(["catch", "reaction"]) + }) +}) diff --git a/packages/codemode/test/promise-test262.test.ts b/packages/codemode/test/promise-test262.test.ts index 0dc57e7b75..d2b60659b2 100644 --- a/packages/codemode/test/promise-test262.test.ts +++ b/packages/codemode/test/promise-test262.test.ts @@ -946,7 +946,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe("TypeError") }) - test.failing("Promise.resolve recursively assimilates callable thenables", async () => { + test("Promise.resolve recursively assimilates callable thenables", async () => { // Source: test/built-ins/Promise/resolve/resolve-thenable.js expect( await value(` @@ -958,7 +958,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) - test.failing("Promise combinators assimilate callable thenable inputs", async () => { + test("Promise combinators assimilate callable thenable inputs", async () => { // Sources: // test/built-ins/Promise/all/reject-immed.js // test/built-ins/Promise/all/reject-ignored-immed.js @@ -988,7 +988,7 @@ describe("Test262 expected Promise conformance", () => { ]) }) - test.failing("await assimilates callable thenables", async () => { + test("await assimilates callable thenables", async () => { // Source: test/language/expressions/await/await-awaits-thenables.js expect( await value(` @@ -998,7 +998,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe(42) }) - test.failing("await rejects when a callable thenable throws", async () => { + test("await rejects when a callable thenable throws", async () => { // Source: test/language/expressions/await/await-awaits-thenables-that-throw.js expect( await value(` @@ -1013,6 +1013,94 @@ describe("Test262 expected Promise conformance", () => { `), ).toBe(true) }) + + test("thenable resolution is deferred and settles only once", async () => { + // Sources: + // test/built-ins/Promise/resolve/S25.Promise_resolve_foreign_thenable_2.js + // test/built-ins/Promise/exception-after-resolve-in-thenable-job.js + expect( + await value(` + const sequence = [] + const thenable = { + then: (resolve, reject) => { + sequence.push("then") + resolve(1) + reject(2) + throw 3 + } + } + const promise = Promise.resolve(thenable) + sequence.push("after resolve") + const result = await promise + sequence.push("after await") + return [result, sequence] + `), + ).toEqual([1, ["after resolve", "then", "after await"]]) + }) + + test("the first thenable rejection wins over later resolution and throws", async () => { + // Source: test/built-ins/Promise/exception-after-resolve-in-thenable-job.js + expect( + await value(` + const thenable = { + then: (resolve, reject) => { + reject("first") + resolve("second") + throw "third" + } + } + try { + await thenable + return "fulfilled" + } catch (reason) { + return reason + } + `), + ).toBe("first") + }) + + test("constructors, reactions, finally, and async returns assimilate thenables", async () => { + // Sources: + // test/built-ins/Promise/resolve-thenable-immed.js + // test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-thenable.js + // test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js + const thenable = (value: string) => `({ then: (resolve) => resolve(${JSON.stringify(value)}) })` + expect( + await value(` + const fromAsync = async () => ${thenable("async")} + const cleanup = [] + return await Promise.all([ + new Promise((resolve) => resolve(${thenable("constructor")})), + Promise.resolve().then(() => ${thenable("reaction")}), + Promise.resolve("kept").finally(() => { + cleanup.push("ran") + return ${thenable("ignored")} + }), + fromAsync(), + ]).then((values) => [values, cleanup]) + `), + ).toEqual([["constructor", "reaction", "kept", "async"], ["ran"]]) + }) + + test("finally settles after its cleanup thenable reactions", async () => { + // Source: test/built-ins/Promise/prototype/finally/resolved-observable-then-calls-PromiseResolve.js + expect( + await value(` + const sequence = [] + const cleanup = { then: (resolve) => { sequence.push("then"); resolve() } } + const result = Promise.resolve("kept").finally(() => cleanup) + result.then(() => sequence.push("finally")) + Promise.resolve() + .then(() => sequence.push("tick1")) + .then(() => sequence.push("tick2")) + .then(() => sequence.push("tick3")) + .then(() => sequence.push("tick4")) + await result + sequence.push("await") + return sequence + `), + ).toEqual(["tick1", "then", "tick2", "tick3", "tick4", "finally", "await"]) + }) }) describe("Test262 Promise.any", () => { diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index d81d625057..6d749b8526 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -344,7 +344,12 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { prepareTransport: (body) => Effect.succeed(body), streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions), } - return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route }) + return Model.make({ + id: info.modelID ?? info.id, + provider: info.providerID, + route, + compatibility: info.compatibility, + }) } function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly>) { diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts new file mode 100644 index 0000000000..3276038975 --- /dev/null +++ b/packages/core/src/app.ts @@ -0,0 +1,33 @@ +export * as App from "./app" + +import { Context, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" + +export interface Info { + readonly name: string + readonly version: string + readonly channel: string +} + +export const Metadata = Context.Reference("@opencode/App", { + defaultValue: () => make(), +}) + +export function make(input: Partial = {}): Info { + return { + name: input.name ?? "opencode", + version: input.version ?? "unknown", + channel: input.channel ?? "unknown", + } +} + +export function useragent(app: Info) { + return `opencode/${app.channel}/${app.version}/${app.name}` +} + +export const layer = (input?: Partial) => Layer.succeed(Metadata, make(input)) + +export const configured = (input?: Partial) => + makeGlobalNode({ service: Metadata, layer: layer(input), deps: [] }) + +export const node = configured() diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4c63972c39..2f338b6b35 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -30,6 +30,7 @@ import { ConfigReference } from "./config/reference" import { ConfigToolOutput } from "./config/tool-output" import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" +import { ConfigWarming } from "./config/warming" import { ConfigV1 } from "./v1/config/config" import { ConfigMigrateV1 } from "./v1/config/migrate" import { WellKnown } from "./wellknown" @@ -110,6 +111,9 @@ export class Info extends Schema.Class("Config.Info")({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered plugin enablement directives and external package declarations", }), + warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({ + description: "Keep recently active sessions warm with transient model requests (default: false)", + }), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), experimental: ConfigExperimental.Info.pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 1bb05e0fa1..b0dceca26e 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,7 +4,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" import { Config } from "../../config" -import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" export const Plugin = define({ @@ -59,6 +58,8 @@ export const Plugin = define({ if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name if (config.modelID !== undefined) model.modelID = config.modelID + if (config.compatibility !== undefined) + model.compatibility = { ...model.compatibility, ...config.compatibility } if (config.package !== undefined) model.package = config.package if (config.settings !== undefined) model.settings = ProviderV2.mergeOverlay(model.settings, config.settings) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index e2fa972806..8441ef0828 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -42,6 +42,7 @@ class Model extends Schema.Class("ConfigV2.Model")({ modelID: ModelV2.ID.pipe(Schema.optional), family: ModelV2.Family.pipe(Schema.optional), name: Schema.String.pipe(Schema.optional), + compatibility: ModelV2.Compatibility.pipe(Schema.optional), package: Schema.String.pipe(Schema.optional), ...Overlays, capabilities: ModelV2.Capabilities.pipe(Schema.optional), diff --git a/packages/core/src/config/warming.ts b/packages/core/src/config/warming.ts new file mode 100644 index 0000000000..5e3b7d4a91 --- /dev/null +++ b/packages/core/src/config/warming.ts @@ -0,0 +1,17 @@ +export * as ConfigWarming from "./warming" + +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigV2.Warming")({ + prompt: Schema.String.pipe(Schema.optional).annotate({ + description: "Prompt sent for keep-alive requests", + }), + interval: Schema.DurationFromString.pipe(Schema.optional).annotate({ + description: 'Idle time between keep-alive requests (default: "4 minutes")', + }), + duration: Schema.DurationFromString.pipe(Schema.optional).annotate({ + description: 'Time after the last active request to keep a session warm (default: "30 minutes")', + }), +}) {} + +export const Warming = Schema.Union([Schema.Boolean, Info]) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index c4067c4765..dd6214b095 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -55,5 +55,6 @@ export const migrations = ( import("./migration/20260709190621_session_pending_table"), import("./migration/20260710025429_instruction_sync"), import("./migration/20260716020354_kv"), + import("./migration/20260722011141_delete_tool_progress_events"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts b/packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts new file mode 100644 index 0000000000..afb8f44334 --- /dev/null +++ b/packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260722011141_delete_tool_progress_events", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 989e15ae3b..8802ca7317 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -17,6 +17,25 @@ export class Repository extends Schema.Class("Git.Repository")({ commonDirectory: AbsolutePath, }) {} +// Included from $GIT_DIR/config via include.path (git >= 1.7.10); OpenCode owns +// this file entirely, so updates are plain rewrites with no config parsing. +const snapshotConfigFile = "opencode.gitconfig" +const snapshotConfigInclude = `[include] + path = ${snapshotConfigFile} +` +const snapshotConfig = `[core] + autocrlf = false + longpaths = true + symlinks = true + fsmonitor = false + untrackedCache = true +[feature] + manyFiles = true +[index] + version = 4 + threads = true +` + export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet")) export type ChangeSet = typeof ChangeSet.Type @@ -376,19 +395,24 @@ const layer = Layer.effect( commonDirectory: input.gitDirectory, }) yield* repositoryOperation("create", repository, ["init"]) - yield* Effect.forEach( - [ - ["core.autocrlf", "false"], - ["core.longpaths", "true"], - ["core.symlinks", "true"], - ["core.fsmonitor", "false"], - ["feature.manyFiles", "true"], - ["index.version", "4"], - ["index.threads", "true"], - ["core.untrackedCache", "true"], - ], - ([key, value]) => repositoryOperation("create", repository, ["config", key, value]), - { discard: true }, + yield* Effect.gen(function* () { + yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig) + const config = path.join(input.gitDirectory, "config") + const current = yield* fs.readFileString(config) + if (current.includes(snapshotConfigInclude)) return + yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, { + flag: "a", + }) + }).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to configure Git storage", + cause, + }), + ), ) if (!input.seed) return repository yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe( diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 3c15d9a2a4..0de177aafe 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type export class PathError extends Schema.TaggedErrorClass()("LocationMutation.PathError", { path: Schema.String, - reason: Schema.Literals(["location_escape", "non_directory_ancestor"]), + reason: Schema.Literal("non_directory_ancestor"), }) {} export interface ExternalDirectoryAuthorization { @@ -83,7 +83,6 @@ const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service - const locationRoot = yield* fs.realPath(location.directory) function notFound(effect: Effect.Effect) { return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) @@ -124,14 +123,8 @@ const layer = Layer.effect( const lexicallyInternal = FSUtil.contains(location.directory, absolute) const resolved = yield* resolvePath(absolute) - if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) { - return yield* new PathError({ path: input.path, reason: "location_escape" }) - } - const external = !lexicallyInternal - const resource = external - ? slash(resolved.canonical) - : slash(path.relative(locationRoot, resolved.canonical) || ".") + const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".") const externalDirectory = input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory const externalResource = slash(path.join(externalDirectory, "*")) diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 98e125bfca..630ff88b98 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -12,6 +12,7 @@ import { ElicitationCompleteNotificationSchema, ElicitRequestSchema, GetPromptResultSchema, + type Implementation, type ElicitRequestFormParams, type ElicitRequestParams, type ElicitRequestURLParams, @@ -29,7 +30,6 @@ import { } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit, Schema } from "effect" import { ConfigMCP } from "../config/mcp" -import { InstallationVersion } from "@opencode-ai/util/installation/version" const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_CATALOG_TIMEOUT = 30_000 @@ -185,6 +185,7 @@ export const connect = Effect.fnUntraced(function* ( // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth. authProvider?: OAuthClientProvider, elicitation?: ElicitationHandler, + clientInfo: Implementation = { name: "opencode", version: "unknown" }, ) { const transport: Transport = yield* Effect.gen(function* () { if (config.type === "local") { @@ -209,7 +210,7 @@ export const connect = Effect.fnUntraced(function* ( }) }) const client = new Client( - { name: "opencode", version: InstallationVersion }, + clientInfo, { capabilities: { ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}), diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index d8807ae4c8..225919c27f 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -157,7 +157,17 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/MCP") {} -export const layer = Layer.effect( +export const Options = Schema.Struct({ + clientInfo: Schema.optional( + Schema.Struct({ + name: Schema.String, + version: Schema.String, + }), + ), +}) +export type Options = typeof Options.Type + +export const layer = (options?: Options) => Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -498,7 +508,14 @@ export const layer = Layer.effect( const authProvider = yield* connectProvider(entry) // 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(name, entry.config, location.directory, authProvider, elicitation).pipe( + const result = yield* MCPClient.connect( + name, + entry.config, + location.directory, + authProvider, + elicitation, + options?.clientInfo, + ).pipe( Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Scope.provide(scope), Effect.exit, @@ -772,11 +789,15 @@ export const layer = Layer.effect( }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], -}) +export function configured(options?: Options) { + return makeLocationNode({ + service: Service, + layer: layer(options), + deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], + }) +} + +export const node = configured() // Schema `optional` strips undefined-valued properties on encode, so fields can assign // optional properties directly instead of conditionally spreading them. diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3f264ecd9f..e8045f1104 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -12,6 +12,12 @@ export type VariantID = typeof VariantID.Type export const Family = Model.Family export type Family = Model.Family +export const ReasoningField = Model.ReasoningField +export type ReasoningField = Model.ReasoningField + +export const Compatibility = Model.Compatibility +export type Compatibility = Model.Compatibility + export const Capabilities = Model.Capabilities export type Capabilities = Model.Capabilities @@ -25,6 +31,12 @@ export type Info = Model.Info export type MutableInfo = DeepMutable +export function compatibility(input: unknown): Compatibility | undefined { + if (typeof input === "string") return { reasoningField: input } + if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined + return typeof input.field === "string" ? { reasoningField: input.field } : undefined +} + export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") return { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 3bc763882e..2db39ab124 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -3,12 +3,11 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Money } from "@opencode-ai/schema/money" -import { Client } from "@opencode-ai/util/client" +import { App } from "./app" import { Global } from "@opencode-ai/util/global" import { Flock } from "@opencode-ai/util/flock" import { Hash } from "@opencode-ai/util/hash" import { FSUtil } from "@opencode-ai/util/fs-util" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { EventV2 } from "./event" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" @@ -44,7 +43,7 @@ type SourceModel = { readonly reasoning_options?: readonly ReasoningOption[] readonly temperature?: boolean readonly tool_call: boolean - readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" } + readonly interleaved?: boolean | string | { readonly field: string } readonly cost?: Cost readonly limit: { readonly context: number; readonly input?: number; readonly output: number } readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] } @@ -496,6 +495,7 @@ function modelInfo( modelID: ModelV2.ID.make(model.id), providerID, name: input.name ?? model.name, + compatibility: ModelV2.compatibility(model.interleaved), family: model.family ? ModelV2.Family.make(model.family) : undefined, package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined, settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, @@ -543,7 +543,7 @@ export const layer = (options?: Options) => Effect.gen(function* () { const fs = yield* FSUtil.Service const events = yield* EventV2.Service - const client = yield* Client.Name + const app = yield* App.Metadata const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -556,7 +556,7 @@ export const layer = (options?: Options) => const source = options?.url || "https://models.dev" const fetch = options?.fetch ?? true - const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}` + const userAgent = App.useragent(app) const filepath = path.join( Global.Path.cache, source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, @@ -660,7 +660,7 @@ export function configured(options?: Options) { return makeGlobalNode({ service: Service, layer: layer(options), - deps: [FSUtil.node, EventV2.node, Client.node, httpClient], + deps: [FSUtil.node, EventV2.node, App.node, httpClient], }) } diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 59c5373679..6007b67abb 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -3,6 +3,7 @@ export * as PluginV2 from "./plugin" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { App } from "./app" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" @@ -144,6 +145,7 @@ export const node = makeLocationNode({ layer, deps: [ EventV2.node, + App.node, AgentV2.node, AISDK.node, Catalog.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index d9659bd94a..a97b6bf0bb 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -2,6 +2,7 @@ export * as PluginHost from "./host" import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { App } from "../app" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" @@ -26,6 +27,7 @@ import { PluginHooks } from "./hooks" const mutable = (value: T) => value as DeepMutable export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { + const app = yield* App.Metadata const agents = yield* AgentV2.Service const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service @@ -61,6 +63,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) return { + app, options: {}, agent: { get: (id) => agents.get(AgentV2.ID.make(id)), diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2488c1c6fd..5457a13d3d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -54,6 +54,7 @@ import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" import { SystemPromptPlugin } from "./system-prompt" import { VariantPlugin } from "./variant" +import { WarmingPlugin } from "./warming" import { WellKnownPlugin } from "../wellknown/plugin" const services = Effect.fn("PluginInternal.services")(function* () { @@ -143,6 +144,7 @@ const pre = [ WebFetchTool.Plugin, WebSearchTool.Plugin, WriteTool.Plugin, + WarmingPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] const post = [ diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index d3b3332b61..e54365e79f 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -57,6 +57,7 @@ export function fromPromise(plugin: Plugin) { ) const context2: Context = { + app: host.app, options: host.options, agent: { get: (id) => run(host.agent.get(id)), diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index a24cd711b3..6787e91697 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,5 +1,5 @@ import os from "os" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Effect, Option, Schema } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" @@ -23,7 +23,7 @@ export const CloudflareAIGatewayPlugin = define({ accountId: config.accountId, gateway: config.gatewayId, apiKey: config.apiKey, - options: gatewayOptions(evt.options, metadata), + options: gatewayOptions(evt.options, metadata, ctx.app), } as any) const unified = createUnified({ apiKey: config.apiKey }) evt.sdk = { @@ -64,7 +64,7 @@ function gatewayMetadata(options: Record) { return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined } -function gatewayOptions(options: Record, metadata: unknown) { +function gatewayOptions(options: Record, metadata: unknown, app: App.Info) { return { metadata, cacheTtl: options.cacheTtl, @@ -72,7 +72,7 @@ function gatewayOptions(options: Record, metadata: unknown) { skipCache: options.skipCache, collectLog: options.collectLog, headers: { - "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, }, } } diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 435d7086c2..99f94320ba 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,5 +1,5 @@ import os from "os" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" @@ -29,10 +29,13 @@ export const CloudflareWorkersAIPlugin = define({ if (!hasWorkersEndpoint(evt.model) && !accountId) return const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible( - sdkOptions({ - ...evt.options, - baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), - }) as any, + sdkOptions( + { + ...evt.options, + baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), + }, + ctx.app, + ) as any, ) }), ) @@ -61,13 +64,13 @@ function hasWorkersEndpoint(model: { return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string" } -function sdkOptions(options: Record) { +function sdkOptions(options: Record, app: App.Info) { return { ...options, baseURL: expandAccountId(options.baseURL), apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, headers: { - "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, ...options.headers, }, name: providerID, diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 68ed4591c7..94414eda3d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -4,7 +4,7 @@ import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { EventV2 } from "../../event" import { CopilotModels } from "../../github-copilot/models" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { define } from "@opencode-ai/plugin/v2/effect/plugin" @@ -13,6 +13,7 @@ import type { PluginInternal } from "../internal" const clientID = "Ov23li8tweQw6odWQebz" const apiVersion = "2026-06-01" +const userApiVersion = "2025-04-01" const pollingSafetyMargin = 3000 const methodID = Integration.MethodID.make("device") @@ -27,10 +28,18 @@ const Token = Schema.Struct({ error: Schema.optional(Schema.String), interval: Schema.optional(Schema.Number), }) +const User = Schema.Struct({ + endpoints: Schema.optional( + Schema.Struct({ + api: Schema.optional(Schema.String), + }), + ), +}) +const decodeUser = Schema.decodeUnknownOption(User) const JsonBody = Schema.UnknownFromJsonString const decodeBody = Schema.decodeUnknownOption(JsonBody) -const oauth = { +const oauth = (app: App.Info) => ({ integrationID: Integration.ID.make("github-copilot"), method: { id: methodID, @@ -63,7 +72,7 @@ const oauth = { const urls = oauthURLs(domain) const device = yield* request(urls.device, { method: "POST", - headers: headers(), + headers: headers(app), body: JSON.stringify({ client_id: clientID, scope: "read:user" }), }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) const interval = Math.max(device.interval, 1) * 1000 @@ -71,7 +80,7 @@ const oauth = { const poll = (wait: number): Effect.Effect => request(urls.token, { method: "POST", - headers: headers(), + headers: headers(app), body: JSON.stringify({ client_id: clientID, device_code: device.device_code, @@ -81,15 +90,35 @@ const oauth = { Effect.map(Schema.decodeUnknownSync(Token)), Effect.flatMap((token) => { if (token.access_token) { - return Effect.succeed( - Credential.OAuth.make({ - type: "oauth", - methodID, - refresh: token.access_token, - access: token.access_token, - expires: 0, - ...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}), - }), + const access = token.access_token + return request( + `${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${access}`, + "User-Agent": App.useragent(app), + "X-GitHub-Api-Version": userApiVersion, + }, + }, + ).pipe( + Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")), + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((apiEndpoint) => + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: access, + access, + expires: 0, + ...((enterprise || apiEndpoint) && { + metadata: { + ...(enterprise ? { enterpriseUrl: domain } : {}), + ...(apiEndpoint ? { apiEndpoint } : {}), + }, + }), + }), + ), ) } if (token.error === "authorization_pending") @@ -109,7 +138,7 @@ const oauth = { callback: poll(interval), } }), -} satisfies IntegrationOAuthMethodRegistration +}) satisfies IntegrationOAuthMethodRegistration function shouldUseResponses(modelID: string) { // Copilot supports Responses for GPT-5 class models, except mini variants @@ -141,8 +170,7 @@ export const GithubCopilotPlugin = define({ return } - const enterprise = credential.metadata?.enterpriseUrl - loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined) + loaded.baseURL = copilotBaseURL(credential.metadata) const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot) const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot) loaded.models = yield* Effect.tryPromise({ @@ -152,7 +180,7 @@ export const GithubCopilotPlugin = define({ { ...provider?.headers, Authorization: `Bearer ${credential.refresh}`, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(ctx.app), "X-GitHub-Api-Version": apiVersion, }, existing, @@ -166,7 +194,7 @@ export const GithubCopilotPlugin = define({ }) yield* ctx.integration.transform((draft) => { - draft.method.update(oauth) + draft.method.update(oauth(ctx.app)) }) yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(ProviderV2.ID.githubCopilot) @@ -209,6 +237,7 @@ export const GithubCopilotPlugin = define({ typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined, evt.options.fetch, evt.package === "@ai-sdk/anthropic", + ctx.app, ) if (evt.package === "@ai-sdk/anthropic") { evt.options.headers = { @@ -261,11 +290,18 @@ function baseURL(enterprise?: string) { return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com" } -function headers() { +export function copilotBaseURL(metadata?: Readonly>) { + const endpoint = metadata?.apiEndpoint + if (typeof endpoint === "string" && endpoint) return endpoint + const enterprise = metadata?.enterpriseUrl + return baseURL(typeof enterprise === "string" ? enterprise : undefined) +} + +function headers(app: App.Info) { return { Accept: "application/json", "Content-Type": "application/json", - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(app), } } @@ -282,7 +318,12 @@ function request(url: string, init: RequestInit) { type Fetch = (input: Parameters[0], init?: RequestInit) => Promise -export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch { +export function copilotFetch( + token: string | undefined, + upstream: Fetch | undefined, + anthropic: boolean, + app: App.Info, +): Fetch { const send = upstream ?? fetch return async (input, init) => { const requestHeaders = new Headers(init?.headers) @@ -291,7 +332,7 @@ export function copilotFetch(token: string | undefined, upstream: Fetch | undefi requestHeaders.delete("x-api-key") requestHeaders.set("Authorization", `Bearer ${token}`) } - requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`) + requestHeaders.set("User-Agent", App.useragent(app)) requestHeaders.set("Openai-Intent", "conversation-edits") requestHeaders.set("X-GitHub-Api-Version", apiVersion) if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14") diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9cb3974f1d..9772a38b3a 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,5 +1,5 @@ import os from "os" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" @@ -20,7 +20,7 @@ export const GitLabPlugin = define({ : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"), apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN, aiGatewayHeaders: { - "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `${App.useragent(ctx.app)} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, "anthropic-beta": "context-1m-2025-08-07", ...evt.options.aiGatewayHeaders, }, diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 195fdd317b..b6ff9e5810 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -2,9 +2,9 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { App } from "../../app" import { Credential } from "../../credential" import { EventV2 } from "../../event" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { OauthCallbackPage } from "../../oauth/page" @@ -42,7 +42,7 @@ const Claims = Schema.fromJsonString( ) const decodeClaims = Schema.decodeUnknownOption(Claims) -const browser = { +const browser = (app: App.Info) => ({ integrationID: Integration.ID.make("openai"), method: { id: browserMethodID, @@ -91,15 +91,15 @@ const browser = { url: authorizeURL(redirect, pkce, state), instructions: "Complete authorization in your browser. This window will close automatically.", callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, redirect, pkce)), + Effect.flatMap((value) => exchange(value, redirect, pkce, app)), Effect.map((tokens) => credential(browserMethodID, tokens)), ), } }), - refresh: (value) => refresh(browserMethodID, value), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(browserMethodID, value, app), +}) satisfies IntegrationOAuthMethodRegistration -const headless = { +const headless = (app: App.Info) => ({ integrationID: Integration.ID.make("openai"), method: { id: headlessMethodID, @@ -112,7 +112,7 @@ const headless = { `${issuer}/api/accounts/deviceauth/usercode`, { method: "POST", - headers: headers("application/json"), + headers: headers("application/json", app), body: JSON.stringify({ client_id: clientID }), }, ) @@ -127,7 +127,7 @@ const headless = { try: (signal) => fetch(`${issuer}/api/accounts/deviceauth/token`, { method: "POST", - headers: headers("application/json"), + headers: headers("application/json", app), body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), signal, }), @@ -140,10 +140,12 @@ const headless = { } return credential( headlessMethodID, - yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { - verifier: data.code_verifier, - challenge: "", - }), + yield* exchange( + data.authorization_code, + `${issuer}/deviceauth/callback`, + { verifier: data.code_verifier, challenge: "" }, + app, + ), ) } if (response.status !== 403 && response.status !== 404) { @@ -154,8 +156,8 @@ const headless = { }), } }), - refresh: (value) => refresh(headlessMethodID, value), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(headlessMethodID, value, app), +}) satisfies IntegrationOAuthMethodRegistration export const OpenAIPlugin = define({ id: "opencode.provider.openai", @@ -173,8 +175,8 @@ export const OpenAIPlugin = define({ }) yield* ctx.integration.transform((draft) => { - draft.method.update(browser) - draft.method.update(headless) + draft.method.update(browser(ctx.app)) + draft.method.update(headless(ctx.app)) }) yield* load() yield* ctx.catalog.transform((evt) => { @@ -232,14 +234,14 @@ export const OpenAIPlugin = define({ }), } satisfies PluginInternal.InternalPlugin) -function headers(contentType: string) { - return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } +function headers(contentType: string, app: App.Info) { + return { "Content-Type": contentType, "User-Agent": App.useragent(app) } } -function exchange(code: string, redirect: string, pkce: Pkce) { +function exchange(code: string, redirect: string, pkce: Pkce, app: App.Info) { return request(`${issuer}/oauth/token`, { method: "POST", - headers: headers("application/x-www-form-urlencoded"), + headers: headers("application/x-www-form-urlencoded", app), body: new URLSearchParams({ grant_type: "authorization_code", code, @@ -250,10 +252,10 @@ function exchange(code: string, redirect: string, pkce: Pkce) { }) } -function refresh(methodID: Integration.MethodID, value: Pick) { +function refresh(methodID: Integration.MethodID, value: Pick, app: App.Info) { return request(`${issuer}/oauth/token`, { method: "POST", - headers: headers("application/x-www-form-urlencoded"), + headers: headers("application/x-www-form-urlencoded", app), body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: value.refresh, diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1e8c52e6fc..e70be42814 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -134,6 +134,7 @@ export const OpencodePlugin = define ({ integrationID: Integration.ID.make("xai"), method: { id: browserMethodID, @@ -108,15 +108,15 @@ const browser = { url: authorizeURL(pkce, state, randomString(32)), instructions: "Complete authorization in your browser. This window will close automatically.", callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, pkce)), + Effect.flatMap((value) => exchange(value, pkce, app)), Effect.flatMap((tokens) => credential(browserMethodID, tokens)), ), } }), - refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app), +}) satisfies IntegrationOAuthMethodRegistration -const device = { +const device = (app: App.Info) => ({ integrationID: Integration.ID.make("xai"), method: { id: deviceMethodID, @@ -128,7 +128,7 @@ const device = { `${issuer}/device/code`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ client_id: clientID, scope }).toString(), }, Device, @@ -142,14 +142,14 @@ const device = { url: value.verification_uri_complete ?? value.verification_uri, instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}), - callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), + callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), } }), ), ), ), - refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app), +}) satisfies IntegrationOAuthMethodRegistration export const XAIPlugin = define({ id: "opencode.provider.xai", @@ -158,8 +158,8 @@ export const XAIPlugin = define({ draft.update("xai", (integration) => { integration.name = "xAI" }) - draft.method.update(browser) - draft.method.update(device) + draft.method.update(browser(ctx.app)) + draft.method.update(device(ctx.app)) draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } }) }) yield* ctx.aisdk.hook( @@ -180,12 +180,12 @@ export const XAIPlugin = define({ }), }) -function exchange(code: string, pkce: Pkce) { +function exchange(code: string, pkce: Pkce, app: App.Info) { return request( `${issuer}/token`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ grant_type: "authorization_code", code, @@ -198,12 +198,12 @@ function exchange(code: string, pkce: Pkce) { ) } -function refresh(methodID: Integration.MethodID, value: Credential.OAuth) { +function refresh(methodID: Integration.MethodID, value: Credential.OAuth, app: App.Info) { return request( `${issuer}/token`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: value.refresh, @@ -214,7 +214,7 @@ function refresh(methodID: Integration.MethodID, value: Credential.OAuth) { ).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata))) } -function poll(device: typeof Device.Type): Effect.Effect { +function poll(device: typeof Device.Type, app: App.Info): Effect.Effect { return Effect.gen(function* () { const started = yield* Clock.currentTimeMillis const expires = started + positiveSeconds(device.expires_in, 300) * 1000 @@ -225,7 +225,7 @@ function poll(device: typeof Device.Type): Effect.Effect { } const response = yield* send(`${issuer}/token`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ grant_type: deviceGrant, client_id: clientID, @@ -314,11 +314,11 @@ function tokenExpiration(tokens: Token) { return expiration ? expiration * 1000 : Date.now() + 3600 * 1000 } -function headers() { +function headers(app: App.Info) { return { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(app), } } diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ed2cdccb64..c0edc4a431 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,11 +2,10 @@ export * as SkillPlugin from "./skill" -import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { define, type Context } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { Config } from "../config" import { Location } from "../location" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -27,7 +26,7 @@ const REPORT_DESCRIPTION = export const Plugin = define({ id: "opencode.skill", effect: Effect.fn(function* (ctx) { - const reportContent = yield* reportContentWithDiagnostics() + const reportContent = yield* reportContentWithDiagnostics(ctx.app) yield* ctx.skill.transform((draft) => { draft.source( SkillV2.EmbeddedSource.make({ @@ -58,7 +57,9 @@ export const Plugin = define({ }), }) -const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () { +const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* ( + app: Context["app"], +) { const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"])) return [ ReportContent, @@ -67,8 +68,8 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia "", "These values were captured when the built-in report skill was registered. Verify them before publishing.", "", - `- opencode version: ${InstallationVersion}`, - `- install/channel: ${InstallationChannel}`, + `- opencode version: ${app.version}`, + `- install/channel: ${app.channel}`, `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`, `- Terminal: ${terminal()}`, `- Shell: ${shell()}`, diff --git a/packages/core/src/plugin/warming.ts b/packages/core/src/plugin/warming.ts new file mode 100644 index 0000000000..d7bb9d0fd8 --- /dev/null +++ b/packages/core/src/plugin/warming.ts @@ -0,0 +1,101 @@ +export * as WarmingPlugin from "./warming" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Clock, Duration, Effect, Scope } from "effect" +import { Config } from "../config" +import { SessionSchema } from "../session/schema" + +const defaults = { + prompt: "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK", + interval: Duration.minutes(4), + duration: Duration.minutes(30), +} + +export const Plugin = define({ + id: "opencode.warming", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const loadSettings = Effect.fn("WarmingPlugin.loadSettings")(function* () { + const warming = Config.latest(yield* config.entries(), "warming") + if (!warming) return + const settings = warming === true ? defaults : { ...defaults, ...warming } + const interval = Duration.toMillis(settings.interval) + const duration = Duration.toMillis(settings.duration) + if (Number.isFinite(interval) && interval > 0 && Number.isFinite(duration) && duration > 0) return settings + yield* Effect.logWarning("warming interval and duration must be finite positive durations") + }) + + const scope = yield* Scope.Scope + const sessions = new Map() + const loop: (sessionID: SessionSchema.ID) => Effect.Effect = Effect.fn("WarmingPlugin.loop")( + function* (sessionID) { + const current = sessions.get(sessionID) + if (!current) return + + const now = yield* Clock.currentTimeMillis + const next = Math.min(current.last + Duration.toMillis(current.settings.interval), current.expires) + if (now < next) { + yield* Effect.sleep(Duration.millis(next - now)) + return yield* loop(sessionID) + } + if (now >= current.expires) { + sessions.delete(sessionID) + return + } + + const last = current.last + yield* Effect.logInfo("warming session", { sessionID, last }) + yield* ctx.session + .generate({ sessionID, prompt: current.settings.prompt }) + .pipe(Effect.catchCause((cause) => Effect.logWarning("failed to warm session", { sessionID, cause }))) + const latest = sessions.get(sessionID) + if (latest === current && latest.last === last) latest.last = yield* Clock.currentTimeMillis + return yield* loop(sessionID) + }, + ) + + yield* ctx.session.hook("context", (event) => + Effect.gen(function* () { + const active = sessions.get(event.sessionID) + const settings = yield* loadSettings() + if (!settings) { + sessions.delete(event.sessionID) + return + } + + // Once generate exposes request metadata to context hooks, tag warm requests instead of matching the prompt. + const message = event.messages.at(-1) + if ( + message?.role === "user" && + message.content.length === 1 && + message.content[0]?.type === "text" && + (message.content[0].text === active?.settings.prompt || message.content[0].text === settings.prompt) + ) { + if (active) active.settings = settings + return + } + + const now = yield* Clock.currentTimeMillis + const duration = Duration.toMillis(settings.duration) + if (active) { + active.last = now + active.expires = now + duration + active.settings = settings + return + } + sessions.set(event.sessionID, { last: now, expires: now + duration, settings }) + yield* Effect.logInfo("scheduled session warming", { + sessionID: event.sessionID, + interval: settings.interval, + expires: now + duration, + }) + yield* loop(event.sessionID).pipe( + Effect.catchCause((cause) => + Effect.logError("session warming loop failed", { sessionID: event.sessionID, cause }), + ), + Effect.forkIn(scope), + ) + }), + ) + }), +}) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 822c93465a..53a64866c2 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -20,7 +20,7 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema" import { AgentV2 } from "./agent" import { SessionV1 } from "./v1/session" import { Money } from "@opencode-ai/schema/money" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "./app" import { Slug } from "./util/slug" import { ProjectTable } from "./project/sql" import path from "path" @@ -305,6 +305,7 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { + const app = yield* App.Metadata const database = yield* Database.Service const db = database.db const events = yield* EventV2.Service @@ -352,7 +353,7 @@ const layer = Layer.effect( const info = SessionV1.SessionInfo.make({ id: sessionID, slug: Slug.create(), - version: InstallationVersion, + version: app.version, projectID: project.id, parentID: input.parentID, directory: location.directory, @@ -1040,5 +1041,6 @@ export const node = makeGlobalNode({ SessionProjector.node, FSUtil.node, Global.node, + App.node, ], }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 7a18f55f38..a5b2c626b6 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -10,7 +10,7 @@ import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import type { SessionMessage } from "./message" import { SessionModelHeaders } from "./model-headers" -import { Client } from "@opencode-ai/util/client" +import { App } from "../app" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" @@ -61,7 +61,7 @@ type Settings = { } type Dependencies = { - readonly client: string + readonly app: App.Info readonly events: EventV2.Interface readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream @@ -260,7 +260,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: plan.model, - http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) }, + http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, messages: [Message.user(plan.prompt)], tools: [], }), @@ -399,13 +399,13 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const config = yield* Config.Service const models = yield* SessionRunnerModel.Service - const client = yield* Client.Name - return make({ events, llm, models, config: settings(yield* config.entries()), client }) + const app = yield* App.Metadata + return make({ events, llm, models, config: settings(yield* config.entries()), app }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node], + deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, App.node], }) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 24be6f835c..f26e2847c2 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -4,7 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai" import { Effect, Layer } from "effect" import { Database } from "../database/database" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { Client } from "@opencode-ai/util/client" +import { App } from "../app" import { llmClient } from "../effect/app-node-platform" import { PluginHooks } from "../plugin/hooks" import { SessionContext } from "./context" @@ -12,6 +12,7 @@ import { SessionGenerate } from "./generate" import { SessionHistory } from "./history" import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" +import { ToolRegistry } from "../tool/registry" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" @@ -23,7 +24,8 @@ export const layer = Layer.effect( const hooks = yield* PluginHooks.Service const llm = yield* LLMClient.Service const models = yield* SessionRunnerModel.Service - const client = yield* Client.Name + const registry = yield* ToolRegistry.Service + const app = yield* App.Metadata return SessionGenerate.Service.of({ generate: Effect.fn("SessionGenerate.generate")(function* (input) { @@ -34,6 +36,9 @@ export const layer = Layer.effect( const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id) ? selection.session.id.slice(4) : selection.session.id + const executableTools = yield* registry.materialize(selection.agent.info.permissions) + const toolDefinitions = executableTools.definitions + const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) const contextEvent = yield* hooks.trigger("session", "context", { sessionID: selection.session.id, agent: selection.agent.id, @@ -46,19 +51,34 @@ export const layer = Layer.effect( ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), Message.user(input.prompt), ], - tools: {}, + tools: Object.fromEntries( + toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + ), }) - return (yield* llm.generate( + const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => { + const registered = toolsByName.get(name) + return registered + ? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })] + : [] + }) + yield* Effect.logInfo("sending session generation request", { + sessionID: selection.session.id, + providerID: model.ref.providerID, + modelID: model.ref.id, + }) + const response = yield* llm.generate( LLM.request({ model: model.model, - http: { headers: SessionModelHeaders.make(selection.session, client) }, + http: { headers: SessionModelHeaders.make(selection.session, app) }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, messages: contextEvent.messages, - tools: [], + tools: hookedTools, toolChoice: "none", }), - )).text + ) + yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) + return response.text }), }) }), @@ -67,5 +87,13 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: SessionGenerate.Service, layer, - deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, Client.node, llmClient], + deps: [ + SessionContext.node, + Database.node, + PluginHooks.node, + SessionRunnerModel.node, + ToolRegistry.node, + App.node, + llmClient, + ], }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 00b0927cf4..ecc6bf6257 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -402,8 +402,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, - structured: match.state.status === "running" ? match.state.structured : {}, - content: match.state.status === "running" ? match.state.content : [], + structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}), + content: event.data.content ?? (match.state.status === "running" ? match.state.content : []), result: event.data.result, }), ) diff --git a/packages/core/src/session/model-headers.ts b/packages/core/src/session/model-headers.ts index cbe9d78f2b..1491038a2a 100644 --- a/packages/core/src/session/model-headers.ts +++ b/packages/core/src/session/model-headers.ts @@ -1,14 +1,14 @@ export * as SessionModelHeaders from "./model-headers" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../app" import { SessionSchema } from "./schema" -export const make = (session: Pick, client: string) => ({ +export const make = (session: Pick, app: App.Info) => ({ "x-session-affinity": session.id, "X-Session-Id": session.id, ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(app), "x-opencode-project": session.projectID, "x-opencode-session": session.id, - "x-opencode-client": client, + "x-opencode-client": app.name, }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 7e5f84d20c..6afe3318ac 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -4,7 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@op import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { Client } from "@opencode-ai/util/client" +import { App } from "../app" import { ModelV2 } from "../model" import { PluginHooks } from "../plugin/hooks" import { ToolRegistry } from "../tool/registry" @@ -85,7 +85,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const hooks = yield* PluginHooks.Service const registry = yield* ToolRegistry.Service - const client = yield* Client.Name + const app = yield* App.Metadata const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { const session = input.context.session @@ -123,7 +123,7 @@ export const layer = Layer.effect( const request = LLM.request({ model, http: { - headers: SessionModelHeaders.make(session, client), + headers: SessionModelHeaders.make(session, app), }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, @@ -157,5 +157,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [PluginHooks.node, ToolRegistry.node, Client.node], + deps: [PluginHooks.node, ToolRegistry.node, App.node], }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 97044675d1..f759a3757f 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -697,7 +697,6 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event)) - yield* events.project(SessionEvent.Tool.Progress, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2a8aee08cc..ad792ce1c7 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -163,16 +163,7 @@ const layer = Layer.effect( agent: agent.id, messageID: assistantMessageID, call: event, - progress: (update) => - serialized( - events.publish(SessionEvent.Tool.Progress, { - sessionID: session.id, - assistantMessageID, - callID: event.id, - structured: { ...update.structured }, - content: [...update.content], - }), - ), + progress: (update) => serialized(publisher.progress(event.id, update)), }), ).pipe( Effect.flatMap((settlement) => diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index efb584fb43..6013ce7b74 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -209,14 +209,14 @@ export const fromCatalogModel = ( return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id }), + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { return Effect.succeed( withDefaults(resolved, AnthropicMessages.route) .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: resolved.modelID ?? resolved.id }), + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } if ( @@ -227,7 +227,7 @@ export const fromCatalogModel = ( return Effect.succeed( withDefaults(resolved, OpenAICompatibleChat.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id }), + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } if (ProviderV2.isAISDK(resolved.package)) { @@ -257,8 +257,15 @@ export const fromCatalogModel = ( limits: { context: resolved.limit.context, output: resolved.limit.output }, } return yield* Effect.try({ - try: () => - Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }), + try: () => { + const runtime = module.model(resolved.modelID ?? resolved.id, settings) + return Model.update(runtime, { + provider: resolved.providerID, + compatibility: resolved.compatibility + ? { ...runtime.compatibility, ...resolved.compatibility } + : runtime.compatibility, + }) + }, catch: () => unsupported(resolved), }) }) @@ -302,7 +309,7 @@ const codexModel = ( account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), ), }) - .model({ id: model.modelID ?? model.id }) + .model({ id: model.modelID ?? model.id, compatibility: model.compatibility }) } const unsupported = (model: ModelV2.Info) => diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 1344d2627a..df3e950895 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent" import { Snapshot } from "../../snapshot" import { RelativePath } from "../../schema" import { SessionUsage } from "../usage" +import type { ToolRegistry } from "../../tool/registry" type Input = { readonly sessionID: SessionSchema.ID @@ -54,8 +55,17 @@ export const createLLMEventPublisher = (events: Pick() + const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => { + if (!tool.progress) return {} + const first = tool.progress.content[0] + return { + ...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }), + metadata: tool.progress.structured, + } + } let assistantMessageID = input.assistantMessageID let stepStarted = false let stepFailed = false @@ -232,6 +242,7 @@ export const createLLMEventPublisher = (events: Pick Stream.Stream @@ -68,7 +68,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: resolved.model, - http: { headers: SessionModelHeaders.make(session, dependencies.client) }, + http: { headers: SessionModelHeaders.make(session, dependencies.app) }, system: agent.system, messages: [Message.user(firstUser.text)], tools: [], @@ -112,8 +112,8 @@ export const layer = Layer.effect( const agents = yield* AgentV2.Service const models = yield* SessionRunnerModel.Service const database = yield* Database.Service - const client = yield* Client.Name - const title = make({ events, llm, agents, models, client }) + const app = yield* App.Metadata + const title = make({ events, llm, agents, models, app }) return Service.of({ generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), }) @@ -123,5 +123,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node], + deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, App.node], }) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index e231ec7c0b..27a7d26a01 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" -import { RelativePath } from "../schema" +import { NonNegativeInt, RelativePath } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -25,6 +25,9 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Array(FileSystem.Entry) +const StructuredOutput = Schema.Struct({ + count: NonNegativeInt, +}) type ModelOutput = typeof Output.Encoded /** Format raw search results into the concise line-oriented output models expect. */ @@ -51,6 +54,8 @@ export const Plugin = { "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ count: output.length }), toModelOutput: ({ output }) => [ { type: "text", diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index a972c95865..56cb004ddf 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" -import { RelativePath } from "../schema" +import { NonNegativeInt, RelativePath } from "../schema" import { Tool } from "./tool" export const name = "grep" @@ -30,6 +30,9 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Array(FileSystem.Match) +const StructuredOutput = Schema.Struct({ + matches: NonNegativeInt, +}) type ModelOutput = typeof Output.Encoded /** Format raw search matches into the familiar concise model output. */ @@ -65,6 +68,8 @@ export const Plugin = { "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ matches: output.length }), toModelOutput: ({ output }) => [ { type: "text", diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 11a3e67d49..63980f911d 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -256,13 +256,22 @@ export const Plugin = { } } + let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined const progress = yield* Effect.sleep("1 second").pipe( Effect.andThen( captureShell().pipe( Effect.flatMap((capture) => - context.progress({ - structured: { truncated: capture.truncated }, - content: [{ type: "text", text: capture.output }], + Effect.gen(function* () { + if ( + previousProgress?.output === capture.output && + previousProgress.truncated === capture.truncated + ) + return + previousProgress = capture + yield* context.progress({ + structured: { truncated: capture.truncated }, + content: [{ type: "text", text: capture.output }], + }) }), ), ), diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 21fdef69c0..dcf0e8bf70 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -21,6 +21,10 @@ export const Output = Schema.Struct({ directory: Schema.String, output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + name: Output.fields.name, + directory: Output.fields.directory, +}) export const description = [ "Load a specialized skill when the task at hand matches one of the available skills in the instructions.", @@ -66,6 +70,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 1e459a674e..1f2f2f6c8b 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -31,6 +31,10 @@ export const Output = Schema.Struct({ status: Schema.Literals(["completed", "running"]), output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + sessionID: Output.fields.sessionID, + status: Output.fields.status, +}) export const description = [ "Spawn a subagent: a child session running a configured agent with fresh context.", @@ -115,6 +119,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { @@ -185,7 +191,11 @@ export const Plugin = { const run = Effect.gen(function* () { // The child session owns its agent/model (set at create); prompt only admits input. - yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false }) + yield* runtime.session.prompt({ + sessionID: child.id, + text: ["You are a subagent spawned by another session.", input.prompt].join("\n"), + resume: false, + }) yield* runtime.session.resume(child.id) return yield* latestAssistantText(child.id) }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index e7a372e4b4..0ce3250995 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -37,6 +37,9 @@ const Output = Schema.Struct({ format: Input.fields.format, output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + contentType: Output.fields.contentType, +}) type Format = (typeof Input.Type)["format"] @@ -126,6 +129,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ contentType: output.contentType }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 75752de6fb..49b059c9df 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../app" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -190,6 +190,9 @@ const Output = Schema.Struct({ provider: Provider, text: Schema.String, }) +const StructuredOutput = Schema.Struct({ + provider: Output.fields.provider, +}) export const Plugin = { id: "opencode.tool.websearch", @@ -206,6 +209,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ provider: output.provider }), toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) @@ -241,7 +246,7 @@ export const Plugin = { // V2 invocation context does not safely expose the model yet. }, { - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(ctx.app), ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), }, ) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 63348e5480..6c0a342abe 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -8,6 +8,7 @@ import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" import { ProviderV2 } from "../../provider" +import { ModelV2 } from "../../model" const keys = new Set([ "logLevel", @@ -278,6 +279,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) { modelID: info.id, family: info.family, name: info.name, + compatibility: ModelV2.compatibility(info.interleaved), package: info.provider?.npm ? ProviderV2.aisdk(info.provider.npm) : undefined, settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings, capabilities, diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f9..37e8c58c27 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -16,9 +16,10 @@ export const Model = Schema.Struct({ tool_call: Schema.optional(Schema.Boolean), interleaved: Schema.optional( Schema.Union([ - Schema.Literal(true), + Schema.Boolean, + Schema.String, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: Schema.String, }), ]), ), diff --git a/packages/core/test/app.test.ts b/packages/core/test/app.test.ts new file mode 100644 index 0000000000..8d62f00141 --- /dev/null +++ b/packages/core/test/app.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test" +import { App } from "@opencode-ai/core/app" + +test("formats app metadata as a user agent", () => { + expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe( + "opencode/beta/1.2.3/sdk", + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2daecccc6a..64e08b5e79 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -419,6 +419,28 @@ describe("Config", () => { }), ) + it.effect("migrates v1 interleaved fields to compatibility", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + custom: { + models: { + object: { interleaved: { field: "vendor_reasoning" } }, + string: { interleaved: "reasoning_text" }, + boolean: { interleaved: true }, + }, + }, + }, + }) + + expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({ + reasoningField: "vendor_reasoning", + }) + expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" }) + expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined() + }), + ) + it.effect("migrates v1 command configuration", () => Effect.sync(() => { expect( diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 0c7c85a1ca..c3c5b9c420 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -170,6 +170,7 @@ describe("ConfigProviderPlugin.Plugin", () => { models: { chat: { name: "First", + compatibility: { reasoningField: "vendor_reasoning" }, capabilities: { tools: true, input: ["text"], output: ["text"] }, disabled: true, limit: { context: 100, output: 50 }, @@ -251,6 +252,7 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.id).toBe(modelID) expect(model.modelID).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") + expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" }) expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) diff --git a/packages/core/test/config/warming.test.ts b/packages/core/test/config/warming.test.ts new file mode 100644 index 0000000000..d0ecbf3a91 --- /dev/null +++ b/packages/core/test/config/warming.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { Duration, Schema } from "effect" +import { Config } from "../../src/config" + +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("config warming", () => { + test("accepts boolean enablement", () => { + expect(decode({}).warming).toBeUndefined() + expect(decode({ warming: false }).warming).toBe(false) + expect(decode({ warming: true }).warming).toBe(true) + }) + + test("decodes custom durations", () => { + const warming = decode({ + warming: { prompt: "Reply pong", interval: "2 minutes", duration: "1 hour" }, + }).warming + expect(typeof warming).toBe("object") + if (typeof warming !== "object") return + expect(warming.prompt).toBe("Reply pong") + expect(warming.interval).toEqual(Duration.minutes(2)) + expect(warming.duration).toEqual(Duration.hours(1)) + }) +}) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 2c68d2e186..58673f2039 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -24,6 +24,7 @@ import renameInstructionsMigration from "@opencode-ai/core/database/migration/20 import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended" import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync" +import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -557,6 +558,31 @@ describe("DatabaseMigration", () => { ) }) + test("deletes durable tool progress without changing aggregate sequence watermarks", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 5)`) + yield* db.run(sql`INSERT INTO event VALUES ('evt_success', 'ses_test', 4, 'session.tool.success.1', '{}')`) + yield* db.run(sql`INSERT INTO event VALUES ('evt_progress', 'ses_test', 5, 'session.tool.progress.1', '{}')`) + + yield* DatabaseMigration.applyOnly(db, [deleteToolProgressEventsMigration]) + + expect(yield* db.all(sql`SELECT id, seq, type, data FROM event ORDER BY seq`)).toEqual([ + { id: "evt_success", seq: 4, type: "session.tool.success.1", data: "{}" }, + ]) + expect(yield* db.get(sql`SELECT aggregate_id, seq FROM event_sequence`)).toEqual({ + aggregate_id: "ses_test", + seq: 5, + }) + }), + ) + }) + test("records the authoritative parent sequence on existing forks", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 27a1251eff..416d9da4b6 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -39,12 +39,13 @@ describe("resource", () => { process.env.OTEL_RESOURCE_ATTRIBUTES = "opencode.client=web,service.instance.id=override,service.namespace=anomalyco" - expect(resource("cli").attributes).toMatchObject({ + const app = { client: "cli", version: "1.2.3", channel: "beta" } + expect(resource(app).attributes).toMatchObject({ "opencode.client": "cli", "service.namespace": "anomalyco", }) - expect(resource("cli").attributes["service.instance.id"]).not.toBe("override") - expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) + expect(resource(app).attributes["service.instance.id"]).not.toBe("override") + expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) }) }) diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index abcc60181f..055a6b7640 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -148,8 +148,21 @@ describe("Git trees", () => { const git = yield* Git.Service const source = yield* git.repo.discover(AbsolutePath.make(root.path)) if (!source) throw new Error("Repository not found") - const storage = AbsolutePath.make(path.join(root.path, ".snapshot")) + const storage = AbsolutePath.make(path.join(root.path, ".snapshot storage")) const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source }) + yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path first.gitconfig`.quiet()) + yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path second.gitconfig`.quiet()) + yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf true`.quiet()) + yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source }) + expect( + yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()), + ).toBe("false\n") + expect( + (yield* Effect.promise(() => fs.readFile(path.join(storage, "config"), "utf8"))).match(/opencode\.gitconfig/g), + ).toHaveLength(1) + expect( + yield* Effect.promise(() => $`git --git-dir ${storage} config --local --get-all include.path`.text()), + ).toBe("opencode.gitconfig\nfirst.gitconfig\nsecond.gitconfig\n") yield* git.index.refresh({ repository, scope: RelativePath.make("scope") }) const before = yield* git.tree.write(repository) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index 35e21905d2..ba52af866e 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -77,7 +77,7 @@ describe("LocationMutation", () => { ), ) - it.live("rejects a prospective target below an escaping symlink ancestor", () => + it.live("authorizes a prospective target below an external symlink by its in-location path", () => withTmp((directory) => { const outside = `${directory}-outside` return Effect.gen(function* () { @@ -86,10 +86,12 @@ describe("LocationMutation", () => { await fs.mkdir(outside) await fs.symlink(outside, path.join(directory, "escape")) }) - const error = yield* Effect.flip( - (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }), - ) - expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" }) + const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }) + expect(target).toMatchObject({ + canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"), + resource: "escape/new.txt", + }) + expect(target.externalDirectory).toBeUndefined() yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true })) }).pipe(provide(directory)) }), @@ -106,7 +108,7 @@ describe("LocationMutation", () => { expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({ canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"), - resource: "actual/new.txt", + resource: "linked/new.txt", }) }).pipe(provide(directory)), ), diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2d2661ddab..158ad32c67 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -136,6 +136,7 @@ function resourceServer( return { state, url: http.url.toString(), + clientVersion: () => protocol.getClientVersion(), sendResourceListChanged: () => protocol.sendResourceListChanged(), completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(), close: async () => { @@ -151,10 +152,11 @@ function resourceServer( function resourceMcpLayer( server: string | typeof ConfigMCP.Server.Type, onFormCreated?: (form: Form.Info) => Effect.Effect, + options?: MCP.Options, ) { const directory = AbsolutePath.make(import.meta.dir) const unusedIntegration = () => Effect.die("unused integration service") - return MCP.layer.pipe( + return MCP.layer(options).pipe( Layer.provideMerge(Form.layer), Layer.provide( Layer.mergeAll( @@ -643,7 +645,12 @@ test("loads and reads MCP resources", async () => { { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, ], }) - }).pipe(Effect.provide(resourceMcpLayer(server.url))) + expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" }) + }).pipe( + Effect.provide( + resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }), + ), + ) }), ), ) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 5d9366bbfa..1d8bd4ae18 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, beforeEach, afterAll } from "bun:test" +import { describe, expect, beforeEach, afterAll, test } from "bun:test" import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" @@ -15,6 +15,13 @@ import path from "path" const cacheFile = path.join(Global.Path.cache, "models.json") +test("normalizes permissive interleaved values to compatibility", () => { + expect(ModelV2.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" }) + expect(ModelV2.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" }) + expect(ModelV2.compatibility(true)).toBeUndefined() + expect(ModelV2.compatibility(false)).toBeUndefined() +}) + const fixture = { acme: { id: "acme", @@ -30,6 +37,7 @@ const fixture = { reasoning: false, temperature: true, tool_call: true, + interleaved: { field: "vendor_reasoning" }, limit: { context: 128000, output: 8192 }, }, }, @@ -49,6 +57,7 @@ const fixtureSnapshot = [ modelID: ModelV2.ID.make("acme-1"), providerID: ProviderV2.ID.make("acme"), name: "Acme One", + compatibility: { reasoningField: "vendor_reasoning" }, family: undefined, package: undefined, settings: undefined, @@ -302,7 +311,7 @@ describe("ModelsDev Service", () => { const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) expect(final.calls[0].url).toContain("/api.json") - expect(final.calls[0].userAgent).toContain("/cli") + expect(final.calls[0].userAgent).toContain("/opencode") }), ) diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index d3ff40b786..18dd3695ce 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -22,6 +22,30 @@ describe("Patch", () => { ]) }) + test("parses an empty patch", () => { + expect(parse("*** Begin Patch\n*** End Patch")).toEqual([]) + }) + + test("ignores a Codex environment preamble", () => { + expect( + parse("*** Begin Patch\n*** Environment ID: remote\n*** Add File: file.txt\n+content\n*** End Patch"), + ).toEqual([{ type: "add", path: "file.txt", contents: "content" }]) + }) + + test("parses an update followed by an add", () => { + expect( + parse("*** Begin Patch\n*** Update File: update.txt\n@@\n+line\n*** Add File: add.txt\n+content\n*** End Patch"), + ).toEqual([ + { + type: "update", + path: "update.txt", + movePath: undefined, + chunks: [{ oldLines: [], newLines: ["line"], changeContext: undefined }], + }, + { type: "add", path: "add.txt", contents: "content" }, + ]) + }) + test("parses a file move", () => { expect( parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch"), @@ -66,6 +90,20 @@ describe("Patch", () => { ]) }) + test("strips quoted heredoc wrappers", () => { + const patch = "*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch" + expect(parse(`<<'EOF'\n${patch}\nEOF`)).toEqual([{ type: "add", path: "add.txt", contents: "added" }]) + expect(parse(`<<\"EOF\"\n${patch}\nEOF`)).toEqual([{ type: "add", path: "add.txt", contents: "added" }]) + }) + + test("rejects malformed heredoc wrappers", () => { + const patch = "*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch" + expect(() => parse(`<<\"EOF'\n${patch}\nEOF`)).toThrow("The first line of the patch must be '*** Begin Patch'") + expect(() => parse("< { expect(parse("*** Begin Patch\n *** Update File: foo.txt\n@@\n-old\n+new\n*** End Patch")).toEqual([ { @@ -99,6 +137,23 @@ describe("Patch", () => { ]) }) + test("parses relative and absolute hunk paths", () => { + expect( + parse( + "*** Begin Patch\n*** Add File: relative.txt\n+content\n*** Delete File: /tmp/delete.txt\n*** Update File: /tmp/update.txt\n@@\n-old\n+new\n*** End Patch", + ), + ).toEqual([ + { type: "add", path: "relative.txt", contents: "content" }, + { type: "delete", path: "/tmp/delete.txt" }, + { + type: "update", + path: "/tmp/update.txt", + movePath: undefined, + chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }], + }, + ]) + }) + test("strips one carriage return from CRLF patch lines", () => { expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n")).toEqual([ { @@ -136,6 +191,42 @@ describe("Patch", () => { ]) }) + test("allows an end-of-file marker before an explicit chunk", () => { + expect( + parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n@@\n-old\n+new\n*** End Patch"), + ).toEqual([ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }], + }, + ]) + }) + + test("allows an end-of-file marker before an implicit chunk and move", () => { + expect(parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n-old\n+new\n*** End Patch")).toEqual([ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [{ oldLines: ["old"], newLines: ["new"] }], + }, + ]) + expect( + parse( + "*** Begin Patch\n*** Update File: old.txt\n*** End of File\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch", + ), + ).toEqual([ + { + type: "update", + path: "old.txt", + movePath: "new.txt", + chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }], + }, + ]) + }) + test("derives fuzzy line updates while preserving BOM", () => { const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n") expect(update).toEqual({ content: "new\n", bom: true }) @@ -236,15 +327,157 @@ describe("Patch", () => { ).toThrow("Failed to find expected lines") }) - test("matches V1 lenient parsing of malformed hunk bodies", () => { - expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([ - { type: "add", path: "add.txt", contents: "" }, - ]) - expect(parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([ - { type: "update", path: "update.txt", movePath: undefined, chunks: [] }, - ]) - expect(parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([ - { type: "delete", path: "delete.txt" }, + test("parses an update without an explicit first chunk header", () => { + expect(parse("*** Begin Patch\n*** Update File: file.txt\n import foo\n+bar\n*** End Patch")).toEqual([ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [{ oldLines: ["import foo"], newLines: ["import foo", "bar"] }], + }, ]) }) + + test("keeps indented update markers as context lines", () => { + expect( + parse( + "*** Begin Patch\n*** Update File: a.txt\n@@\n-old a\n+new a\n *** Update File: b.txt\n@@\n-old b\n+new b\n*** End Patch", + ), + ).toEqual([ + { + type: "update", + path: "a.txt", + movePath: undefined, + chunks: [ + { + oldLines: ["old a", "*** Update File: b.txt"], + newLines: ["new a", "*** Update File: b.txt"], + changeContext: undefined, + }, + { oldLines: ["old b"], newLines: ["new b"], changeContext: undefined }, + ], + }, + ]) + }) + + test("keeps indented move and EOF markers as context lines", () => { + expect( + parse( + "*** Begin Patch\n*** Update File: file.txt\n@@\n before\n *** Move to: moved.txt\n *** End of File\n*** End Patch", + ), + ).toEqual([ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [ + { + oldLines: ["before", "*** Move to: moved.txt", "*** End of File"], + newLines: ["before", "*** Move to: moved.txt", "*** End of File"], + changeContext: undefined, + }, + ], + }, + ]) + }) + + test("preserves update context indentation", () => { + expect(parse("*** Begin Patch\n*** Update File: file.txt\n@@ section\n-old\n+new\n*** End Patch")).toEqual([ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: " section" }], + }, + ]) + }) + + test("preserves bare empty update lines as context", () => { + expect( + parse("*** Begin Patch\n*** Update File: file.txt\n@@\n context before\n\n context after\n*** End Patch"), + ).toEqual([ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [ + { + oldLines: ["context before", "", "context after"], + newLines: ["context before", "", "context after"], + changeContext: undefined, + }, + ], + }, + ]) + }) + + test("rejects invalid add and delete lines", () => { + expect(() => parse("*** Begin Patch\n*** Add File: file.txt\nbad\n*** End Patch")).toThrow( + "Invalid hunk at line 3: 'bad' is not a valid hunk header", + ) + expect(() => parse("*** Begin Patch\n*** Delete File: file.txt\nbad\n*** End Patch")).toThrow( + "Invalid hunk at line 3: 'bad' is not a valid hunk header", + ) + }) + + test("rejects an empty update hunk", () => { + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n*** End Patch")).toThrow( + "Invalid hunk at line 2: Update file hunk for path 'file.txt' is empty", + ) + expect(() => + parse( + "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n*** End Patch", + ), + ).toThrow("Invalid hunk at line 2: Update file hunk for path 'old.txt' is empty") + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n*** End Patch")).toThrow( + "Invalid hunk at line 2: Update file hunk for path 'file.txt' is empty", + ) + }) + + test("rejects an empty update chunk", () => { + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch")).toThrow( + "Invalid hunk at line 4: Update hunk does not contain any lines", + ) + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n*** End Patch")).toThrow( + "Invalid hunk at line 4: Update hunk does not contain any lines", + ) + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n-old\n+new\n*** End Patch")).toThrow( + "Invalid hunk at line 4: Unexpected line found in update hunk: '@@'", + ) + expect(() => + parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n@@\n-old\n+new\n*** End Patch"), + ).toThrow("Invalid hunk at line 4: Unexpected line found in update hunk: '*** Update File: other.txt'") + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\nbad\n*** End Patch")).toThrow( + "Invalid hunk at line 4: Unexpected line found in update hunk: 'bad'", + ) + }) + + test("rejects an invalid update line", () => { + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n*** End Patch")).toThrow( + "Invalid hunk at line 5: Expected update hunk to start with a @@ context marker, got: 'bad'", + ) + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@foo\n*** End Patch")).toThrow( + "Invalid hunk at line 3: Unexpected line found in update hunk: '@@foo'", + ) + expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n*** Frobnicate File: foo\n*** End Patch")).toThrow( + "Invalid hunk at line 5: Expected update hunk to start with a @@ context marker, got: '*** Frobnicate File: foo'", + ) + }) + + test("rejects invalid and pathless hunk headers", () => { + expect(() => parse("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch")).toThrow( + "Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header", + ) + expect(() => parse("*** Begin Patch\n*** Add File:\n*** End Patch")).toThrow( + "Invalid hunk at line 2: '*** Add File:' is not a valid hunk header", + ) + for (const header of ["*** Add File: ", "*** Delete File: ", "*** Update File: "]) { + expect(() => parse(`*** Begin Patch\n${header}\n*** End Patch`)).toThrow( + `Invalid hunk at line 2: '${header.trim()}' is not a valid hunk header`, + ) + } + expect(() => + parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: \n@@\n-old\n+new\n*** End Patch"), + ).toThrow("Invalid hunk at line 3: '*** Move to:' is not a valid hunk header") + }) }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 85c43d4304..1b28d6a840 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -19,6 +19,7 @@ type Overrides = Partial> & { export function host(overrides: Overrides = {}): PluginContext { return { + app: overrides.app ?? { name: "test", version: "test", channel: "test" }, options: {}, agent: overrides.agent ?? { get: () => Effect.die("unused agent.get"), diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b850d9ac73..bb2443ae41 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,11 +1,12 @@ import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" +import { App } from "@opencode-ai/core/app" +import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" import { Integration } from "@opencode-ai/core/integration" import type { LanguageModelV3 } from "@ai-sdk/provider" @@ -40,6 +41,15 @@ function fakeSelectorSdk(calls: string[]) { } describe("GithubCopilotPlugin", () => { + test("prefers the account-specific Copilot API endpoint", () => { + expect( + copilotBaseURL({ + enterpriseUrl: "company.ghe.com", + apiEndpoint: "https://api.business.githubcopilot.com", + }), + ).toBe("https://api.business.githubcopilot.com") + }) + it.effect("registers GitHub Copilot device OAuth", () => Effect.gen(function* () { yield* addPlugin() @@ -62,6 +72,7 @@ describe("GithubCopilotPlugin", () => { return Response.json({ ok: true }) }, false, + App.make({ name: "test", version: "1.2.3", channel: "beta" }), ) yield* Effect.promise(() => send("https://api.githubcopilot.com/chat/completions", { @@ -77,6 +88,7 @@ describe("GithubCopilotPlugin", () => { expect(requests[0]?.get("x-initiator")).toBe("user") expect(requests[0]?.get("copilot-vision-request")).toBe("true") expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01") + expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test") }), ) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index c35d7073eb..cd9a9b3d82 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -3,7 +3,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/util/fs-util" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Location } from "@opencode-ai/core/location" import { Effect } from "effect" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" @@ -21,6 +20,7 @@ describe("SkillPlugin.Plugin", () => { const skill = yield* SkillV2.Service yield* SkillPlugin.Plugin.effect( host({ + app: { name: "test", version: "1.2.3", channel: "beta" }, skill: { list: () => Effect.die("unused skill.list"), transform: skill.transform, @@ -54,7 +54,8 @@ describe("SkillPlugin.Plugin", () => { }), ) expect(report?.slash).toBe(true) - expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`) + expect(report?.content).toContain("- opencode version: 1.2.3") + expect(report?.content).toContain("- install/channel: beta") }), ) }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index c58433b9cd..40386568b6 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -18,7 +18,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "@opencode-ai/core/app" import { AbsolutePath } from "@opencode-ai/core/schema" import { Money } from "@opencode-ai/schema/money" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" @@ -192,10 +192,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () => "x-session-affinity": sessionID, "X-Session-Id": sessionID, "x-parent-session-id": parentID, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(App.make()), "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, - "x-opencode-client": "cli", + "x-opencode-client": "opencode", }) expect(requests[0]?.generation).toBeUndefined() expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 879c4cb2b3..3f0183167c 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" @@ -38,6 +38,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SkillInstructions } from "@opencode-ai/core/skill/instructions" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { asc, eq } from "drizzle-orm" import { Effect, Layer, Schema, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -92,6 +93,15 @@ const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succee const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) +const tools = Layer.mock(ToolRegistry.Service, { + materialize: () => + Effect.succeed({ + definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })], + settle: () => Effect.die(new Error("unused")), + }), + register: () => Effect.die(new Error("unused")), + registerBatch: () => Effect.die(new Error("unused")), +}) const it = testEffect( AppNodeBuilder.build( @@ -114,6 +124,7 @@ const it = testEffect( [ReferenceInstructions.node, references], [McpInstructions.node, mcp], [PluginSupervisor.node, plugins], + [ToolRegistry.node, tools], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], ], ), @@ -259,6 +270,7 @@ it.effect("generates from fresh settled Session context without durable mutation yield* hooks.register("session", "context", (event) => Effect.sync(() => { event.system = [SystemPart.make("Hooked system"), ...event.system] + if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup" }), ) @@ -287,7 +299,7 @@ it.effect("generates from fresh settled Session context without durable mutation : [], ), ).toEqual(["Settled partial answer"]) - expect(requests[0]?.tools).toEqual([]) + expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }]) expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) expect(yield* durableState(db, sessionID)).toEqual(before) }), diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 291cd66cc3..38fc707918 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -16,6 +16,7 @@ import { it } from "./lib/effect" interface ModelOptions { readonly modelID?: string + readonly compatibility?: ModelV2.Compatibility readonly settings?: ModelV2.Info["settings"] readonly headers?: ModelV2.Info["headers"] readonly body?: ModelV2.Info["body"] @@ -28,6 +29,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"), providerID: ProviderV2.ID.make("test-provider"), name: "Test model", + compatibility: options.compatibility, package: packageName, settings: options.settings ?? {}, headers: options.headers ?? { "x-test": "header" }, @@ -101,6 +103,7 @@ describe("SessionRunnerModel", () => { Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { + compatibility: { reasoningField: "vendor_reasoning" }, settings: { apiKey: "settings-secret", baseURL: "https://compatible.example/v1", @@ -121,6 +124,7 @@ describe("SessionRunnerModel", () => { expect(headers.authorization).toBe("Bearer settings-secret") expect(resolved.route.id).toBe("openai-compatible-chat") + expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning") expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1") expect(resolved.route.defaults.http?.body).toEqual({}) }), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index b6f44dccdb..66a0a43f54 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { Effect, Schema } from "effect" +import { Cause, Effect, Exit, Schema } from "effect" import { LLMEvent } from "@opencode-ai/ai" import { Money } from "@opencode-ai/schema/money" import { EventV2 } from "@opencode-ai/core/event" @@ -16,11 +16,11 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis const sessionID = SessionV2.ID.make("ses_tool_event_test") const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" -const capture = (providerMetadataKey = "anthropic") => { +const capture = (providerMetadataKey = "anthropic", options?: { readonly interruptProgress?: boolean }) => { const published: Array<{ readonly type: string; readonly data: unknown }> = [] const events: Pick = { - publish: (definition, data) => - Effect.sync(() => { + publish: (definition, data) => { + const publish = Effect.sync(() => { const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload published.push({ type: definition.durable @@ -29,7 +29,11 @@ const capture = (providerMetadataKey = "anthropic") => { data, }) return event - }), + }) + return definition.type === SessionEvent.Tool.Progress.type && options?.interruptProgress + ? publish.pipe(Effect.andThen(Effect.interrupt)) + : publish + }, } return { published, @@ -92,6 +96,34 @@ test("provider-executed success retains its raw provider result", async () => { expect(success?.data).toHaveProperty("result") }) +test("interrupted progress publication remains in the terminal failure snapshot", async () => { + const { published, publisher } = capture("anthropic", { interruptProgress: true }) + await Effect.runPromise(publisher.publish(call)) + const exit = await Effect.runPromiseExit( + publisher.progress(call.id, { + structured: { phase: "visible" }, + content: [{ type: "text", text: "visible" }], + }), + ) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) + await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) + + expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({ + metadata: { phase: "visible" }, + content: [{ type: "text", text: "visible" }], + }) +}) + +test("failure before progress omits partial output fields", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(call)) + await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) + + const failed = published.find((event) => event.type === "session.tool.failed.1")?.data + expect(failed).not.toHaveProperty("content") + expect(failed).not.toHaveProperty("metadata") +}) + test("provider metadata is flattened using the route key", async () => { const { published, publisher } = capture() await Effect.runPromise( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1208161827..6ce9cf0f20 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -22,7 +22,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "@opencode-ai/core/app" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -892,6 +892,52 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("persists the latest partial snapshot when a tool fails", () => + Effect.gen(function* () { + const session = yield* setup + const registry = yield* ToolRegistry.Service + yield* registry.register({ + failing_progress: Tool.make({ + description: "Report progress and fail", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: (_, context) => + Effect.gen(function* () { + yield* context.progress({ + structured: { phase: "running" }, + content: [{ type: "text", text: "before failure" }], + }) + return yield* new ToolFailure({ message: "failed after progress" }) + }), + }), + }, { codemode: false }) + yield* admit(session, "Run failing progress") + responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()] + + yield* session.resume(sessionID) + + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Run failing progress" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-failing-progress", + state: { + status: "error", + structured: { phase: "running" }, + content: [{ type: "text", text: "before failure" }], + error: { message: "failed after progress" }, + }, + }, + ], + }, + { type: "assistant", finish: "stop" }, + ]) + }), + ) + it.effect("executes the tool advertised before a registry reload", () => Effect.gen(function* () { const session = yield* setup @@ -3180,10 +3226,10 @@ describe("SessionRunnerLLM", () => { expect(requests[0]?.http?.headers).toEqual({ "x-session-affinity": sessionID, "X-Session-Id": sessionID, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(App.make()), "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, - "x-opencode-client": "cli", + "x-opencode-client": "opencode", }) }), ) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index cb53c3dcde..3673509ae5 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -17,7 +17,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "@opencode-ai/core/app" import { AbsolutePath } from "@opencode-ai/core/schema" import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Stream } from "effect" @@ -155,10 +155,10 @@ it.effect("generates a title from the sole user message and renames the session" expect(requests[0]?.http?.headers).toEqual({ "x-session-affinity": sessionID, "X-Session-Id": sessionID, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(App.make()), "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, - "x-opencode-client": "cli", + "x-opencode-client": "opencode", }) expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") const renamed = yield* store.get(sessionID) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 0935ee3633..cc81820e39 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -22,10 +22,10 @@ const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2. const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } -const content = (text: string) => [{ type: "text" as const, text }] +const content = (text: string) => [{ type: "text" as const, text }] as const describe("Tool.Progress", () => { - it.effect("projects durable progress and keeps final settlements durable", () => + it.effect("keeps progress live-only and terminal settlements durable", () => Effect.gen(function* () { const { db } = yield* Database.Service const service = yield* EventV2.Service @@ -87,7 +87,7 @@ describe("Tool.Progress", () => { state: { status: "running", structured: {}, content: [] }, }) - yield* service.publish(SessionEvent.Tool.Progress, { + const progress = yield* service.publish(SessionEvent.Tool.Progress, { sessionID, assistantMessageID, callID: "call-success", @@ -95,7 +95,7 @@ describe("Tool.Progress", () => { content: content("saved"), }) expect((yield* readAssistant).content[0]).toMatchObject({ - state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") }, + state: { status: "running", structured: {}, content: [] }, }) const success = yield* service.publish(SessionEvent.Tool.Success, { @@ -123,6 +123,8 @@ describe("Tool.Progress", () => { assistantMessageID, callID: "call-failed", error: { type: "unknown", message: "boom" }, + metadata: { phase: "checkpoint" }, + content: content("before failure"), executed: false, }) expect((yield* readAssistant).content[1]).toMatchObject({ @@ -133,6 +135,7 @@ describe("Tool.Progress", () => { error: { type: "unknown", message: "boom" }, }, }) + expect(Schema.is(SessionEvent.Durable)(progress)).toBe(false) expect(Schema.is(SessionEvent.Durable)(success)).toBe(true) expect(Schema.is(SessionEvent.Durable)(failed)).toBe(true) @@ -143,7 +146,7 @@ describe("Tool.Progress", () => { .orderBy(asc(EventTable.seq)) .all() .pipe(Effect.orDie) - expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1)) + expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1)) expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1)) expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1)) }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 4beb47f849..aaecf9766f 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -1,7 +1,6 @@ import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -199,6 +198,41 @@ describe("EditTool", () => { ), ) + it.live("edits an external symlink target with only its in-location permission", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + if (process.platform === "win32") return Effect.void + const target = path.join(outside.path, "external.txt") + const link = path.join(active.path, "link.txt") + return Effect.promise(async () => { + await fs.writeFile(target, "before") + await fs.symlink(target, link) + }).pipe( + Effect.andThen( + withTool(active.path, (registry) => + executeTool(registry, call({ path: "link.txt", oldString: "before", newString: "after" })), + ), + ), + Effect.andThen((result) => + Effect.sync(() => { + expect(result.type).toBe("text") + expect(assertions.map((input) => input.action)).toEqual(["edit"]) + expect(assertions[0]?.resources).toEqual(["link.txt"]) + }), + ), + Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), + Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + it.live("approves an explicit external absolute path before edit", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), @@ -425,25 +459,3 @@ describe("EditTool", () => { ), ) }) - -test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => { - const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") - const definition = await Effect.runPromise( - withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)), - ) - const schema = definition[0]?.inputSchema as { readonly properties?: Record } - - expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"]) - expect(source).toContain( - "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", - ) - for (const todo of [ - "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.", - "Add formatter integration after V2 formatter runtime exists.", - "Publish watcher/file-edit events after V2 watcher integration exists.", - "Add snapshots / undo after design exists.", - "Add LSP notification and diagnostics after V2 LSP runtime exists.", - ]) { - expect(source).toContain(`TODO: ${todo}`) - } -}) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 3152c3f4e1..41e25953c4 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -86,8 +86,12 @@ describe("search tools", () => { const glob = yield* settleTool(registry, call("glob", { pattern: "*" })) const grep = yield* settleTool(registry, call("grep", { pattern: "needle" })) - expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) - expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT }) + expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT }) + expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }]) + expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }]) + expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`) }), ) }), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 2b66b2ec8d..039680a8d1 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" import path from "path" -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -159,6 +159,9 @@ const mixedOutputCommand = isWindows ? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100" : "printf stdout; sleep 0.05; printf stderr >&2" const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60" +const steadyProgressCommand = isWindows + ? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400" + : "printf steady; sleep 3.4" const bodyExitCommand = isWindows ? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7" : "printf body && exit 7" @@ -462,6 +465,34 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) + it.live( + "does not repeat unchanged shell progress", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const updates: ToolRegistry.Progress[] = [] + yield* settleTool(registry, { + ...call({ command: steadyProgressCommand }, "call-steady-progress"), + progress: (update) => Effect.sync(() => updates.push(update)), + }) + expect(updates).toEqual([ + { + structured: { truncated: false }, + content: [{ type: "text", text: "steady" }], + }, + ]) + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + { timeout: 10_000 }, + ) + it.live("returns a useful timeout settlement", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -612,20 +643,3 @@ describe("ShellTool", () => { ), ) }) - -test("keeps locked deferred parity TODOs visible", async () => { - const source = await fs.readFile(new URL("../src/tool/shell.ts", import.meta.url), "utf8") - for (const todo of [ - "Port tree-sitter bash / PowerShell parser-based approval reduction.", - "Port BashArity reusable command-prefix approvals.", - "Replace token-based command-argument external-directory advisories with parser-based detection.", - "Restore PowerShell and cmd-specific invocation/path handling on Windows.", - "Add plugin shell.env environment augmentation once V2 plugin hooks exist.", - "Persist job status and define restart recovery before exposing remote observation.", - "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", - "Revisit binary output handling if stdout/stderr decoding is text-only.", - "Stream full shell output into managed storage while retaining only a bounded in-memory preview.", - ]) { - expect(source).toContain(`TODO: ${todo}`) - } -}) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index d6745b44d2..72f5565dee 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -119,9 +119,12 @@ describe("SkillTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, }), - ).toMatchObject({ + ).toEqual({ result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { structured: { name: "Effect" } }, + output: { + structured: { name: "Effect", directory }, + content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }], + }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index f86d0d1845..8c70525a9a 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") }) const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } -const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID +const outputSessionID = (value: unknown) => + Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID const executionNode = makeGlobalNode({ service: SessionExecution.Service, @@ -229,7 +230,17 @@ describe("SubagentTool", () => { }, }) - expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect(settled).toMatchObject({ + result: { type: "text", value: childText }, + output: { + structured: { status: "completed" }, + content: [{ type: "text", text: childText }], + }, + }) + expect(settled.output?.structured).toEqual({ + sessionID: outputSessionID(settled.output?.structured), + status: "completed", + }) expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) }), ), @@ -264,8 +275,15 @@ describe("SubagentTool", () => { }, }) - expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect(settled).toMatchObject({ + result: { type: "text", value: childText }, + output: { + structured: { status: "completed" }, + content: [{ type: "text", text: childText }], + }, + }) const child = yield* sessions.get(outputSessionID(settled.output?.structured)) + expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" }) expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ parentID: parent.id, @@ -273,6 +291,9 @@ describe("SubagentTool", () => { agent: "reviewer", model: childModel, }) + expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe( + "You are a subagent spawned by another session.\nreview this", + ) const fallback = yield* settleTool(registry, { sessionID: parent.id, @@ -358,8 +379,10 @@ describe("SubagentTool", () => { const childID = outputSessionID(settled.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running", - output: expect.stringContaining(`id: ${childID}`), }) + expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" }) + expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) }) + expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }]) const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.input.data.text).toContain(` { expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ result: { type: "text", value: "hello" }, output: { - structured: { url, contentType: "text/plain", format: "text", output: "hello" }, + structured: { contentType: "text/plain" }, content: [{ type: "text", text: "hello" }], }, }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index f8468285a5..37d3960ab7 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => { expect(settled).toEqual({ result: { type: "text", value: "parallel results" }, output: { - structured: { provider: "parallel", text: "parallel results" }, + structured: { provider: "parallel" }, content: [{ type: "text", text: "parallel results" }], }, }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 11698f9224..67a001e0eb 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -1,7 +1,6 @@ import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -220,6 +219,39 @@ describe("WriteTool", () => { ), ) + it.live("writes an external symlink target with only its in-location permission", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + if (process.platform === "win32") return Effect.void + const target = path.join(outside.path, "external.txt") + const link = path.join(active.path, "link.txt") + return Effect.promise(async () => { + await fs.writeFile(target, "before") + await fs.symlink(target, link) + }).pipe( + Effect.andThen( + withTool(active.path, (registry) => executeTool(registry, call({ path: "link.txt", content: "after" }))), + ), + Effect.andThen((result) => + Effect.sync(() => { + expect(result.type).toBe("text") + expect(assertions.map((input) => input.action)).toEqual(["edit"]) + expect(assertions[0]?.resources).toEqual(["link.txt"]) + }), + ), + Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), + Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + it.live("approves an explicit external absolute path before edit", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), @@ -330,25 +362,3 @@ describe("WriteTool", () => { ), ) }) - -test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => { - const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") - const definition = await Effect.runPromise( - withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)), - ) - const schema = definition[0]?.inputSchema as { readonly properties?: Record } - - expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) - expect(source).toContain( - "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", - ) - for (const todo of [ - "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", - "Add formatter integration after V2 formatter runtime exists.", - "Publish watcher/file-edit events after V2 watcher integration exists.", - "Add snapshots / undo after design exists.", - "Add LSP notification and diagnostics after V2 LSP runtime exists.", - ]) { - expect(source).toContain(`TODO: ${todo}`) - } -}) diff --git a/packages/core/test/wellknown.test.ts b/packages/core/test/wellknown.test.ts index 829f60ccd7..266e96f754 100644 --- a/packages/core/test/wellknown.test.ts +++ b/packages/core/test/wellknown.test.ts @@ -69,7 +69,7 @@ serviceIt.live("persists sources in one KV value", () => const events = yield* EventV2.Service const changed = yield* events .subscribe(WellKnown.Event.Updated) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) const entry = yield* wellknown.add(`${server.url.origin}/`) expect(entry.origin).toBe(server.url.origin) @@ -108,7 +108,7 @@ serviceIt.live("refreshes changed manifests", () => const changed = yield* events .subscribe(WellKnown.Event.Updated) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) update() expect(yield* wellknown.refresh()).toBe(true) expect(yield* Fiber.join(changed)).toHaveLength(1) diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx index 25d9cb0989..bf53d35b45 100644 --- a/packages/docs/config.mdx +++ b/packages/docs/config.mdx @@ -338,6 +338,25 @@ Control automatic context compaction and how much recent context it preserves. See the [compaction guide](/compaction) for automatic context management. +### Session warming + +Keep recently active model sessions warm with periodic transient requests. +Warming is disabled by default; set it to `true` to use the four-minute idle +interval and 30-minute active window. + +```jsonc +{ + "warming": { + "prompt": "Do not perform any work. Reply with exactly: OK", + "interval": "4 minutes", + "duration": "30 minutes" + } +} +``` + +See the [session warming guide](/warming) for request behavior, customization, +and cost considerations. + ### Skills Add directories or URLs that OpenCode should search for agent skills. diff --git a/packages/docs/docs.json b/packages/docs/docs.json index fbbf84d3fa..ff41ab4b92 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -37,6 +37,7 @@ "mcp-servers", "attachments", "compaction", + "warming", "formatters", "lsp", "references" diff --git a/packages/docs/models.mdx b/packages/docs/models.mdx index d8ba1a8be3..60fd63812a 100644 --- a/packages/docs/models.mdx +++ b/packages/docs/models.mdx @@ -98,6 +98,28 @@ Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2 model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog. +OpenAI-compatible models that stream reasoning through a custom assistant-message field can set +`compatibility.reasoningField`: + +```jsonc title="opencode.jsonc" +{ + "providers": { + "local": { + "models": { + "reasoner": { + "compatibility": { + "reasoningField": "reasoning_content" + } + } + } + } + } +} +``` + +OpenCode recognizes `reasoning`, `reasoning_content`, and `reasoning_text`, and accepts any provider-specific string. It +reads streamed reasoning from this field and includes the field when replaying assistant messages to the model. + ### Custom variants Add a variant, or override a catalog variant with the same ID, under the model's `variants` array: diff --git a/packages/docs/warming.mdx b/packages/docs/warming.mdx new file mode 100644 index 0000000000..8b7bd06a59 --- /dev/null +++ b/packages/docs/warming.mdx @@ -0,0 +1,76 @@ +--- +title: "Session warming" +description: "Keep recently active model sessions warm with periodic transient requests." +--- + +Session warming sends periodic model requests for recently active sessions. +This can preserve provider-side prompt caches or other short-lived session +state while you pause between prompts. + +Warming is disabled by default. Enable it with the default settings in any +[OpenCode configuration file](/config): + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "warming": true +} +``` + +With the defaults, OpenCode sends a warming request after a session has made no +model request for four minutes. It repeats this while the session remains idle, +but stops 30 minutes after the last non-warming request. New model activity +starts a new 30-minute window. + +## Configuration + +Use the object form to customize the prompt, idle interval, or active duration: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "warming": { + "prompt": "Do not perform any work. Reply with exactly: OK", + "interval": "4 minutes", + "duration": "30 minutes" + } +} +``` + +| Field | Default | Description | +| --- | --- | --- | +| `prompt` | Keep-alive instruction | Prompt sent in each warming request. The default instructs the model to do no work and reply with `OK`. | +| `interval` | `"4 minutes"` | Idle time between warming requests. | +| `duration` | `"30 minutes"` | Maximum warming window after the latest non-warming model request. | + +`interval` and `duration` accept duration strings such as `"30 seconds"`, +`"4 minutes"`, or `"1 hour"`. Both must be finite and greater than zero. + +To disable warming explicitly: + +```jsonc +{ + "warming": false +} +``` + +## Request behavior + +A warming request uses the session's current model, agent, instructions, and +conversation context. Tools are disabled. The configured prompt is appended as +a transient user message, and the response is discarded. + +Warming does not admit input, add messages to session history, or otherwise +mutate durable session state. A warming request resets the idle interval but +does not extend the active duration; without new model activity, warming still +ends when the configured duration expires. + +## Costs and limits + +Warming requests are real provider requests. They can consume tokens, incur +costs, count against rate limits, and fail for the same reasons as other model +requests. OpenCode logs warming failures without failing or changing the +session, then waits for the next interval before trying again. + +Enable warming only when the provider-side benefit is worth the additional +requests. A shorter interval or longer duration increases request volume. diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 337593117c..4de8068d56 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -853,7 +853,7 @@ function structuralTypes(schemas: ReadonlyArray, mutable: boolean, r .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") .replaceAll("Schema.Json", "JsonValue") .replaceAll(/(? render(code.Type)), @@ -893,9 +893,15 @@ function structuralType(schema: Schema.Top) { } return type } - return expand(document.codes[0].Type) - .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") - .replaceAll("Schema.Json", "JsonValue") + return preserveStringSuggestions( + expand(document.codes[0].Type) + .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + .replaceAll("Schema.Json", "JsonValue"), + ) +} + +function preserveStringSuggestions(type: string) { + return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})") } function normalizePromiseClientContent(content: string, groups: ReadonlyArray) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 0322157cbd..7374bd5490 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -503,6 +503,19 @@ describe("HttpApiCodegen.generate", () => { expect(types).not.toContain("Brand") }) + test("preserves suggestions for open string unions in Promise wire types", () => { + const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({ + identifier: "Field", + }) + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type Field = "reasoning" | "reasoning_content" | (string & {})', + ) + }) + test("retains non-recursive references in Promise wire types", () => { const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) const output = emitPromise( diff --git a/packages/plugin/src/v2/app.ts b/packages/plugin/src/v2/app.ts new file mode 100644 index 0000000000..7c9b80d813 --- /dev/null +++ b/packages/plugin/src/v2/app.ts @@ -0,0 +1,5 @@ +export interface App { + readonly name: string + readonly version: string + readonly channel: string +} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 834578dd60..2d2f6777c6 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,11 +1,14 @@ import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { CatalogApi } from "@opencode-ai/client/effect/api" +import type { Model } from "@opencode-ai/schema/model" import type { Effect } from "effect" import type { Transform } from "./registration.js" +type CatalogModel = ModelInfo & { compatibility?: Model.Compatibility } + export interface CatalogProviderRecord { readonly provider: ProviderV2Info - readonly models: ReadonlyMap + readonly models: ReadonlyMap } export interface CatalogDraft { @@ -16,8 +19,8 @@ export interface CatalogDraft { remove(providerID: string): void } readonly model: { - get(providerID: string, modelID: string): ModelInfo | undefined - update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void + get(providerID: string, modelID: string): CatalogModel | undefined + update(providerID: string, modelID: string, update: (model: CatalogModel) => void): void remove(providerID: string, modelID: string): void readonly default: { get(): { providerID: string; modelID: string } | undefined diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 3b0b6eb918..02cb8a681f 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,6 +1,7 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" +import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" @@ -13,6 +14,7 @@ import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" export interface Context { + readonly app: App readonly options: PluginOptions readonly agent: AgentDomain readonly aisdk: AISDKDomain diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index 2e05b1fded..a6a3389f7a 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,5 +1,6 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" +import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" @@ -12,6 +13,7 @@ import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" export interface Context { + readonly app: App readonly options: PluginOptions readonly agent: AgentDomain readonly aisdk: AISDKDomain diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index a5970192bb..c3f6828770 100644 --- a/packages/protocol/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -17,7 +17,8 @@ export const ModelGroup = HttpApiGroup.make("server.model") OpenApi.annotations({ identifier: "v2.model.list", summary: "List models", - description: "Retrieve available models ordered by release date.", + description: + "Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.", }), ), ) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 623b80a60a..81caea654f 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -41,6 +41,17 @@ export interface Ref extends Schema.Schema.Type {} export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type +export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {}) +export const ReasoningField: Schema.Codec = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]).annotate({ identifier: "Model.ReasoningField" }) + +export interface Compatibility extends Schema.Schema.Type {} +export const Compatibility = Schema.Struct({ + reasoningField: ReasoningField.pipe(optional), +}).annotate({ identifier: "Model.Compatibility" }) + export interface Capabilities extends Schema.Schema.Type {} export const Capabilities = Schema.Struct({ tools: Schema.Boolean, @@ -75,6 +86,7 @@ export const Info = Schema.Struct({ providerID: Provider.ID, family: Family.pipe(optional), name: Schema.String, + compatibility: Compatibility.pipe(optional), package: Provider.Package.pipe(optional), ...Provider.Overlays, capabilities: Capabilities, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 730b0b1ffb..cc56a83eab 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -3,8 +3,7 @@ export * as SessionEvent from "./session-event.js" import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -import { ToolContent } from "./llm.js" -import { FinishReason } from "./llm.js" +import { FinishReason, ToolContent } from "./llm.js" import { Model } from "./model.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" import { FileAttachment } from "./prompt.js" @@ -410,13 +409,9 @@ export namespace Tool { }) export type Called = typeof Called.Type - /** - * Replayable bounded running-tool state. Tools should checkpoint semantic - * transitions or at a bounded cadence, not persist every stdout/stderr chunk. - */ - export const Progress = Event.durable({ + /** Live replacement snapshot for a running tool. */ + export const Progress = Event.ephemeral({ type: "session.tool.progress", - ...options, schema: { ...ToolBase, structured: Schema.Record(Schema.String, Schema.Unknown), @@ -445,6 +440,8 @@ export namespace Tool { schema: { ...ToolBase, error: SessionError.Error, + content: Schema.NonEmptyArray(ToolContent).pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), result: Schema.Unknown.pipe(optional), executed: Schema.Boolean, resultState: SessionMessage.ProviderState.pipe(optional), diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 512528f556..89766d3d26 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -125,7 +125,6 @@ describe("public event manifest", () => { "session.tool.input.started.1", "session.tool.input.ended.1", "session.tool.called.1", - "session.tool.progress.1", "session.tool.success.1", "session.tool.failed.1", "session.reasoning.started.1", @@ -152,6 +151,8 @@ describe("public event manifest", () => { expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false) expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral") expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral") + expect(SessionEvent.Tool.Progress.durability).toBe("ephemeral") + expect(EventManifest.Server.get("session.tool.progress")).toBe(SessionEvent.Tool.Progress) expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false) expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated) expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index 1dedf08698..7a11eb8424 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Schema } from "effect" import { Model } from "../src/model.js" describe("Model.Ref", () => { @@ -20,3 +21,12 @@ describe("Model.Ref", () => { expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow() }) }) + +describe("Model.ReasoningField", () => { + test("accepts suggested and custom fields", () => { + const decode = Schema.decodeUnknownSync(Model.ReasoningField) + + for (const field of ["reasoning", "reasoning_content", "reasoning_text", "vendor_reasoning"]) + expect(decode(field)).toBe(field) + }) +}) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 60adaec4bc..90495c964a 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -11,7 +11,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt ManagedRuntime.make( createEmbeddedRoutes({ ...options, - client: options.client ?? "sdk", + app: { ...options.app, name: options.app?.name ?? "sdk" }, database: { path: ":memory:", ...options.database }, }).pipe(Layer.provide(HttpServer.layerServices)), ), diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 9ce482ff87..09980bd8cb 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -25,6 +25,27 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create() const location = (fixture: Fixture) => fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) }) +it.live("exposes app metadata to plugins", () => + withEmbedded("opencode-embedded-app-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create({ + app: { name: "test", version: "1.2.3", channel: "beta" }, + }) + const app = yield* Deferred.make<{ readonly name: string; readonly version: string; readonly channel: string }>() + yield* opencode.plugin({ + id: `app-${crypto.randomUUID()}`, + effect: (ctx) => Deferred.succeed(app, ctx.app).pipe(Effect.asVoid), + }) + yield* opencode.plugin.list({ location: location(fixture) }) + expect(yield* Deferred.await(app).pipe(Effect.timeout("4 seconds"))).toEqual({ + name: "test", + version: "1.2.3", + channel: "beta", + }) + }), + ), +) + it.live( "reloads every booted Location after SDK plugin registration", () => diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 927422cc9d..c8fef5b1a7 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -12,6 +12,12 @@ import { createClient } from "@hey-api/openapi-ts" const opencode = path.resolve(dir, "../../opencode") const client = path.resolve(dir, "../../client") +if (!(await Bun.file(path.join(opencode, "package.json")).exists())) { + await $`rm -rf dist` + await $`bun tsc` + process.exit(0) +} + await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) await $`bun -e ${` import { OpenApi } from "effect/unstable/httpapi" diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 19d32a6b10..785f277ed0 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -1,15 +1,18 @@ -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { ServerInfo } from "../server-info" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => handlers .handle("health.get", () => - Effect.succeed({ - healthy: true as const, - version: InstallationVersion, - pid: process.pid, + Effect.gen(function* () { + const info = yield* ServerInfo.Service + return { + healthy: true as const, + version: info.app.version ?? "unknown", + pid: process.pid, + } }), ) .handle("health.stop", () => Effect.succeed({ accepted: false })), diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 4ff968a592..b4f8eccd5b 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -4,7 +4,13 @@ import { Observability } from "@opencode-ai/util/observability" import { Schema } from "effect" export const ServerOptions = Schema.Struct({ - client: Schema.optional(Schema.String), + app: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + channel: Schema.optional(Schema.String), + }), + ), hostname: Schema.optional(Schema.String), port: Schema.optional( Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 6f57ed723d..483745b404 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,7 +1,6 @@ export * as ServerProcess from "./process" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" @@ -50,7 +49,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( // Request fibers may continue inbound trace context, but must not inherit the server startup parent. yield* bound.http .serve( - dispatch(password, status, application, shutdown).pipe( + dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe( HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }), ), HttpMiddleware.logger, @@ -153,6 +152,7 @@ function dispatch( status: Status.Interface, application: Ref.Ref>, shutdown: Deferred.Deferred, + version: string, ): App { const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" }) return Effect.gen(function* () { @@ -166,7 +166,7 @@ function dispatch( : undefined if (lifecycle !== undefined) { if (!(yield* authorizedRequest(request, auth))) return unauthorized() - return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void)) + return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version) } const state = yield* status.current const app = yield* Ref.get(application) @@ -189,8 +189,9 @@ const control = Effect.fnUntraced(function* ( route: "health" | "stop", status: Status.Interface, stop: () => void, + version: string, ) { - if (route === "health") return yield* healthResponse(status) + if (route === "health") return yield* healthResponse(status, version) const body = yield* request.json.pipe(Effect.option) const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none() if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 }) @@ -210,10 +211,10 @@ const control = Effect.fnUntraced(function* ( return HttpServerResponse.jsonUnsafe({ accepted }) }) -const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) { +const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) { const state = yield* status.current return HttpServerResponse.jsonUnsafe( - { healthy: true, version: InstallationVersion, pid: process.pid }, + { healthy: true, version, pid: process.pid }, { status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503, headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined, diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f8985cc007..edffdbd4f8 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,4 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" +import { App } from "@opencode-ai/core/app" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -6,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event" import { EventLogger } from "@opencode-ai/core/event-logger" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { Observability } from "@opencode-ai/util/observability" -import { Client } from "@opencode-ai/util/client" import { Credential } from "@opencode-ai/core/credential" import { Config } from "@opencode-ai/core/config" import { CommandV2 } from "@opencode-ai/core/command" @@ -15,12 +15,9 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Pty } from "@opencode-ai/core/pty" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" -import { SessionCompaction } from "@opencode-ai/core/session/compaction" -import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node" -import { SessionModelRequest } from "@opencode-ai/core/session/model-request" -import { SessionTitle } from "@opencode-ai/core/session/title" import { Shell } from "@opencode-ai/core/shell" import { Job } from "@opencode-ai/core/job" +import { MCP } from "@opencode-ai/core/mcp/index" import { Global } from "@opencode-ai/util/global" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" @@ -88,7 +85,7 @@ function makeRoutes( const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [Database.node, Database.configured(options.database)], - [Client.node, Client.configured(options.client)], + [App.node, App.configured(options.app)], [ModelsDev.node, ModelsDev.configured(options.models)], [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })], [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })], @@ -105,6 +102,15 @@ function makeRoutes( [CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })], [Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })], [Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })], + [ + MCP.node, + MCP.configured({ + clientInfo: { + name: options.app?.name ?? "opencode", + version: options.app?.version ?? "unknown", + }, + }), + ], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ] @@ -112,18 +118,24 @@ function makeRoutes( ? Layer.unwrap( Effect.gen(function* () { const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) - const simulation = yield* simulationReplacements() + const simulation = yield* simulationReplacements({ version: App.make(options.app).version }) return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation]) }), ) : AppNodeBuilder.build(applicationServices, replacements) + const observability = Observability.layer({ + ...options.observability, + client: options.app?.name, + version: options.app?.version, + channel: options.app?.channel, + }) return serviceLayer.pipe( Layer.flatMap((context) => { const services = Layer.succeedContext(context) const requestServices = Layer.merge( Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), - ServerInfo.layer(serviceURLs), + ServerInfo.layer(serviceURLs, options.app), ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers.pipe(Layer.provide(services))), @@ -133,11 +145,11 @@ function makeRoutes( Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), - Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))), HttpRouter.provideRequest(requestServices), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), ) }), + Layer.provide(observability), ) } diff --git a/packages/server/src/server-info.ts b/packages/server/src/server-info.ts index 7d1ba4b094..1f4f8c5afa 100644 --- a/packages/server/src/server-info.ts +++ b/packages/server/src/server-info.ts @@ -1,12 +1,14 @@ import { Context, Layer } from "effect" import { networkInterfaces } from "node:os" +import type { ServerOptions } from "./options" -export class Service extends Context.Service ReadonlyArray }>()( - "@opencode-ai/server/ServerInfo", -) {} +export class Service extends Context.Service< + Service, + { readonly urls: () => ReadonlyArray; readonly app: NonNullable } +>()("@opencode-ai/server/ServerInfo") {} -export function layer(urls: () => ReadonlyArray) { - return Layer.succeed(Service, Service.of({ urls })) +export function layer(urls: () => ReadonlyArray, app: ServerOptions["app"] = {}) { + return Layer.succeed(Service, Service.of({ urls, app })) } export function connectionURLs(value: string, requestedHostname?: string) { diff --git a/packages/server/test/options.test.ts b/packages/server/test/options.test.ts index 8b7d4665d7..2cae02d393 100644 --- a/packages/server/test/options.test.ts +++ b/packages/server/test/options.test.ts @@ -12,3 +12,9 @@ test("rejects ports outside the valid range", () => { expect(Option.isNone(decode({ port: -1 }))).toBe(true) expect(Option.isNone(decode({ port: 65_536 }))).toBe(true) }) + +test("accepts optional app metadata", () => { + expect( + Option.getOrThrow(decode({ app: { name: "sdk", version: "1.2.3", channel: "beta" } })).app, + ).toEqual({ name: "sdk", version: "1.2.3", channel: "beta" }) +}) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index eeec517e04..b462720bd3 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -10,6 +10,7 @@ it.live("allows browser preflight requests without credentials", () => hostname: "127.0.0.1", port: 0, password: "secret", + app: { version: "test-version" }, database: { path: ":memory:" }, }) const response = yield* Effect.promise(() => @@ -38,5 +39,6 @@ it.live("allows browser preflight requests without credentials", () => expect(health.status).toBe(200) expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) }), ) diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index bd430e0f1d..86014a1d1a 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -21,7 +21,7 @@ import { SimulatedProvider } from "./simulated-provider" * */ -export const simulationReplacements = Effect.fn("Simulation.replacements")(function* () { +export const simulationReplacements = Effect.fn("Simulation.replacements")(function* (app: { readonly version: string }) { // ModelsDev dies when its catalog fetch fails, so simulation answers it with // an empty catalog; providers come from seeded config instead. const models = SimulationNetwork.json("GET", "https://models.dev/api.json", {}) @@ -40,6 +40,7 @@ export const simulationReplacements = Effect.fn("Simulation.replacements")(funct Layer.provide( SimulatedProvider.layerDrive({ endpoint: manifest.endpoints.backend, + version: app.version, }), ), ) diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index 7d1a674701..ba64f21bca 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -1,4 +1,3 @@ -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Tool } from "@opencode-ai/plugin/v2/effect/tool" @@ -152,7 +151,7 @@ class ToolControllerError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -export const layerDrive = (options: { readonly endpoint: string }) => +export const layerDrive = (options: { readonly endpoint: string; readonly version: string }) => Layer.effect( Service, Effect.gen(function* () { @@ -277,7 +276,8 @@ export const layerDrive = (options: { readonly endpoint: string }) => label: "opencode drive backend websocket", data: () => ({}), decode: SimulationProtocol.Backend.decodeRequestEffect, - handle: (socket, request) => handle(driver, tools, fibers, activeController, controllerLock, socket, request), + handle: (socket, request) => + handle(driver, tools, fibers, activeController, controllerLock, socket, request, options.version), close: (socket) => Effect.all([releaseController(activeController, controllerLock, socket), tools.release(socket)], { discard: true, @@ -306,13 +306,14 @@ function handle( controllerLock: Semaphore.Semaphore, socket: ControlSocket, request: SimulationProtocol.Backend.Request, + version: string, ) { switch (request.method) { case "simulation.handshake": return SimulationProtocol.Handshake.dispatch( { role: "backend", - server: { name: "opencode", version: InstallationVersion }, + server: { name: "opencode", version }, capabilities: SimulationProtocol.Backend.Capabilities, }, request.params, diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 4aaabd882c..2318a27ada 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,17 +1,16 @@ -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Effect } from "effect" import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" import { SimulationRenderer } from "./renderer" -function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) { +function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, version: string) { switch (request.method) { case "simulation.handshake": return SimulationProtocol.Handshake.dispatch( { role: "ui", - server: { name: "opencode", version: InstallationVersion }, + server: { name: "opencode", version }, capabilities: SimulationProtocol.Frontend.Capabilities, }, request.params, @@ -59,13 +58,17 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) } } -export const start = Effect.fn("SimulationServer.start")(function* (harness: Harness, endpoint: string) { +export const start = Effect.fn("SimulationServer.start")(function* ( + harness: Harness, + endpoint: string, + version = "unknown", +) { return yield* SimulationControlServer.start({ endpoint, label: "opencode drive ui websocket", data: () => ({ drive: true as const }), decode: SimulationProtocol.Frontend.decodeRequestEffect, - handle: (_socket, request) => handle(harness, request), + handle: (_socket, request) => handle(harness, request, version), }) }) diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index 0843754ff1..26b1329e82 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -6,7 +6,7 @@ import { SimulationRenderer } from "./renderer" import { SimulationServer } from "./server" /** Drive-mode renderer and control-server acquisition. */ -export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig) { +export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig, version: string) { const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless" const manifest = yield* DriveManifest.resolve() const renderer = headless @@ -19,7 +19,7 @@ export const create = Effect.fn("Drive.create")(function* (options: CliRendererC }), ) if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows) - const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui) + const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui, version) yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)) return renderer }) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index b91922f138..8aac0cd723 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -685,7 +685,7 @@ function runProvider( } const providerLayer = (endpoint: string) => - SimulatedProvider.layerDrive({ endpoint }).pipe( + SimulatedProvider.layerDrive({ endpoint, version: "test" }).pipe( Layer.provide( Layer.succeed(SdkPlugins.Service, SdkPlugins.Service.of({ register: () => Effect.void, all: () => [] })), ), @@ -694,7 +694,7 @@ const providerLayer = (endpoint: string) => const toolLifecycleLayer = (endpoint: string) => { const provider = makeGlobalNode({ service: SimulatedProvider.Service, - layer: SimulatedProvider.layerDrive({ endpoint }), + layer: SimulatedProvider.layerDrive({ endpoint, version: "test" }), deps: [SdkPlugins.node], }) return AppNodeBuilder.build( diff --git a/packages/tui/package.json b/packages/tui/package.json index 8ced93ff1f..0fb182f8d3 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -30,7 +30,7 @@ "./editor": "./src/editor.ts", "./editor-zed": "./src/editor-zed.ts", "./mini": "./src/mini/index.ts", - "./mini/tool": "./src/mini/tool.ts", + "./mini/tool": "./src/mini/tool.public.ts", "./model-preference": "./src/model-preference.ts", "./runtime": "./src/runtime.tsx", "./terminal-win32": "./src/terminal-win32.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 27f561b69f..5e600b65e8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,4 +1,4 @@ -import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, useRenderer, useTerminalDimensions } from "@opentui/solid" import { registerOpencodeSpinner } from "./component/register-spinner" import { Deferred, Effect } from "effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service" @@ -32,10 +32,13 @@ import { } from "solid-js" import { TuiLifecycleProvider, + TuiAppProvider, TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, + useTuiApp, useTuiStartup, + type TuiApp, } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" @@ -45,8 +48,7 @@ import { EditorContextProvider } from "./context/editor" import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" -import { DevToolsSidebar } from "./component/devtools-sidebar" -import { DevTools } from "./devtools" +import { DevToolsBar } from "./component/devtools-bar" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" import { LocationProvider, useLocation } from "./context/location" @@ -86,8 +88,6 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" -const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) - registerOpencodeSpinner() const appGlobalBindingCommands = [ @@ -139,6 +139,7 @@ const appBindingCommands = [ ] as const export type TuiInput = { + app: TuiApp server: { endpoint: Endpoint service?: { @@ -224,7 +225,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { } if (process.env.OPENCODE_DRIVE) { const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend")) - return yield* Drive.create(options) + return yield* Drive.create(options, input.app.version) } return yield* Effect.acquireRelease( Effect.tryPromise({ @@ -254,12 +255,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const pluginRuntime = createPluginRuntime() yield* Effect.tryPromise(async () => { - const appStarted = performance.now() // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) - const modeStarted = performance.now() const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark" - themePerformance.set("Detect light/dark mode", `${(performance.now() - modeStarted).toFixed(2)} ms`) if (renderer.isDestroyed) return await render(() => { @@ -273,9 +271,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }} > (exit.epilogue = value)}> - } - > + + } + > - + + @@ -404,11 +403,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App(props: { pair?: DialogPairCredentials; started: number }) { +function App(props: { pair?: DialogPairCredentials }) { const log = useLog({ component: "app" }) + const app = useTuiApp() const startup = useTuiStartup() const config = useConfig() - const devtools = createMemo(() => config.data.debug?.devtools ?? false) + const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local") const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -428,11 +428,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const plugins = usePlugin() const clipboard = useClipboard() - createEffect(() => { - if (!themeState.ready) return - themePerformance.set("Total", `${(performance.now() - props.started).toFixed(2)} ms`) - }) - // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting // the same problem on every refresh while still re-alerting if the state changes. @@ -520,6 +515,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { }) const args = useArgs() + const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -537,6 +533,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { route.navigate({ type: "session", sessionID: args.sessionID, + prompt: startupPrompt, }) } }) @@ -559,12 +556,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const match = response.data[0]?.id if (!match) return if (!args.fork) { - route.navigate({ type: "session", sessionID: match }) + route.navigate({ type: "session", sessionID: match, prompt: startupPrompt }) return } void client.api.session .fork({ sessionID: match }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) .catch(toast.error) @@ -577,7 +574,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { forked = true void client.api.session .fork({ sessionID: args.sessionID }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) @@ -1103,9 +1100,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { : undefined } > - - - @@ -1134,10 +1128,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { - - - + + + diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx new file mode 100644 index 0000000000..fc168260a2 --- /dev/null +++ b/packages/tui/src/component/devtools-bar.tsx @@ -0,0 +1,533 @@ +import { TextAttributes } from "@opentui/core" +import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { open } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { monitorEventLoopDelay } from "node:perf_hooks" +import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type ParentProps } from "solid-js" +import { useClient } from "../context/client" +import { useConfig } from "../config" +import { useData } from "../context/data" +import { useLocation } from "../context/location" +import { useRoute } from "../context/route" +import { Keymap } from "../context/keymap" +import { useTheme } from "../context/theme" +import { DevTools } from "../devtools" +import { usePlugin } from "../plugin/context" +import { errorMessage } from "../util/error" + +const graphWidth = 23 +const sampleIntervalMilliseconds = 2_000 +const sampleRetentionMilliseconds = 30_000 +const statusWindowMilliseconds = 6_000 +type Panel = "server" | "theme" | "tools" | "ui" +type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }> +export type RuntimeStatus = "normal" | "medium" | "high" + +export function DevToolsBar() { + const client = useClient() + const config = useConfig() + const data = useData() + const location = useLocation() + const route = useRoute() + const plugins = usePlugin() + const theme = useTheme() + const keymap = Keymap.use() + const dimensions = useTerminalDimensions() + const { themeV2, mode, supports, setMode } = theme + const elevatedTheme = theme.contextual("elevated").themeV2 + const [panel, setPanel] = createSignal() + const [dumping, setDumping] = createSignal(false) + const [dumpPath, setDumpPath] = createSignal() + const [dumpError, setDumpError] = createSignal() + const [frontendSamples, setFrontendSamples] = createSignal([]) + const connected = createMemo(() => client.connection.status() === "connected") + const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt())) + const themePerformance = createMemo( + () => DevTools.data().find((group) => group.id === "theme-performance")?.entries ?? [], + ) + const groups = createMemo(() => DevTools.data().filter((group) => group.id !== "theme-performance")) + const [server] = createResource(connected, async () => { + const [health, info] = await Promise.all([client.api.health.get(), client.api.server.get()]) + return { + health, + address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown", + } + }) + const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next)) + const nextMode = () => (mode() === "dark" ? "light" : "dark") + const canSwitchMode = () => supports(nextMode()) + const runtime = createMemo(() => runtimeStatus(frontendSamples())) + const timing = () => config.data.debug?.timing ?? false + + const offEscape = keymap.intercept( + "key", + ({ event }) => { + if (!panel() || event.name !== "escape") return + event.preventDefault() + event.stopPropagation() + setPanel() + }, + { priority: 10 }, + ) + onCleanup(offEscape) + + onMount(() => { + const eventLoop = monitorEventLoopDelay({ resolution: 20 }) + let frontendCPU = process.cpuUsage() + let frontendTime = performance.now() + let frontendReady = false + eventLoop.enable() + const sample = () => { + const now = performance.now() + const cpu = process.cpuUsage(frontendCPU) + frontendCPU = process.cpuUsage() + setFrontendSamples((samples) => + [ + ...samples, + { + cpu: frontendReady ? cpuPercent(cpu.user + cpu.system, now - frontendTime) : 0, + memory: process.memoryUsage().rss, + delay: eventLoop.percentile(99) / 1_000_000, + time: now, + }, + ].filter((sample) => sample.time >= now - sampleRetentionMilliseconds), + ) + eventLoop.reset() + frontendReady = true + frontendTime = now + } + sample() + const timer = setInterval(sample, sampleIntervalMilliseconds) + onCleanup(() => { + clearInterval(timer) + eventLoop.disable() + }) + }) + + async function dump() { + setDumping(true) + setDumpPath() + setDumpError() + const routeData = route.data + const sessionID = routeData.type === "session" ? routeData.sessionID : undefined + const info = sessionID ? data.session.get(sessionID) : undefined + const sessionLocation = + info?.location ?? + (location.current + ? { directory: location.current.directory, workspaceID: location.current.workspaceID } + : undefined) + const details = server() + const backend = { + connected: connected(), + version: details?.health.version, + pid: details?.health.pid, + error: client.connection.error(), + } + const events = await (sessionID + ? (async () => { + const events: { readonly created: number }[] = [] + for await (const event of client.api.session.log({ sessionID, follow: false })) { + if (event.type !== "log.synced") events.push(event) + } + // Durable events stay in aggregate order even when their wall-clock timestamps differ. + client.connection.internal.history().forEach((event) => { + const index = events.findIndex((item) => item.created > event.created) + if (index === -1) { + events.push(event) + return + } + events.splice(index, 0, event) + }) + return events.slice(-100) + })().catch(() => client.connection.internal.history()) + : Promise.resolve([])) + const file = path.join(tmpdir(), `opencode-debug-${crypto.randomUUID()}.json`) + const output = + JSON.stringify( + { + backend, + session: sessionID + ? { + ...info, + id: sessionID, + projectID: info?.projectID ?? location.current?.project.id, + location: sessionLocation, + status: data.session.status(sessionID), + pending: data.session.pending.list(sessionID), + inputIDs: data.session.input.list(sessionID), + permissions: data.session.permission.list(sessionID) ?? [], + forms: data.session.form.list(sessionID) ?? [], + } + : undefined, + events, + mcp: { + servers: sessionLocation + ? (data.location.mcp.server.list(sessionLocation) ?? []) + : (data.location.mcp.server.list() ?? []), + resources: sessionLocation + ? (data.location.mcp.resource.list(sessionLocation) ?? []) + : (data.location.mcp.resource.list() ?? []), + }, + plugins: { + ready: plugins.ready(), + list: plugins.list().map((plugin) => ({ + name: "id" in plugin ? plugin.id : plugin.target, + ...plugin, + })), + }, + theme: { + name: theme.selected, + mode: theme.mode(), + }, + }, + null, + 2, + ) + "\n" + await open(file, "wx", 0o600) + .then((handle) => handle.writeFile(output).finally(() => handle.close())) + .then( + () => setDumpPath(file), + (error) => setDumpError(errorMessage(error)), + ) + setDumping(false) + } + + return ( + + + setPanel()} + /> + + toggle("server")}> + + {serverIndicator().icon} + + + {" "} + Server + + + + Server + + 0}> + + + {(error) => } + + {(value) => ( + <> + + + + + )} + + + Server details unavailable + + + + + toggle("ui")}> + + {statusIcon(runtime())} + + + {" "} + UI + + + + UI + + sample.delay)} + unit=" ms" + decimals={1} + /> + sample.cpu)} unit="%" /> + sample.memory / 1024 / 1024)} + unit=" MB" + decimals={0} + /> + + + + toggle("theme")}> + Theme + + + Theme + + + {(entry) => } + + setMode(nextMode())} hoverBackground> + Switch to {nextMode()} + + + + + + toggle("tools")}> + Tools + + + Tools + void dump()} disabled={dumping()} hoverBackground> + {dumping() ? "Writing debug snapshot..." : "Write debug snapshot"} + + + {(file) => ( + + {file()} + + )} + + + {(error) => ( + + {error()} + + )} + + + + Render + + + void config.update((draft) => { + draft.debug = { ...draft.debug, timing: !timing() } + }) + } + hoverBackground + > + {timing() ? "[x]" : "[ ]"} Time to first draw + + + + {(group) => ( + + + {group.title} + + {(entry) => } + + )} + + + + + + + + + ) +} + +function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) { + const { themeV2 } = useTheme() + const renderer = useRenderer() + return ( + { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick() + }} + > + {props.children} + + ) +} + +function PanelBox(props: ParentProps) { + const { themeV2 } = useTheme().contextual("elevated") + const renderer = useRenderer() + return ( + { + if (renderer.getSelection()?.getSelectedText()) return + event.stopPropagation() + }} + > + {props.children} + + ) +} + +function PanelTitle(props: ParentProps) { + const { themeV2 } = useTheme().contextual("elevated") + return ( + + {props.children} + + ) +} + +function Row(props: { label: string; value: string }) { + const { themeV2 } = useTheme().contextual("elevated") + return ( + + {props.label} + + {props.value} + + ) +} + +function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) { + const { themeV2 } = useTheme().contextual("elevated") + const [hovered, setHovered] = createSignal(false) + return ( + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseUp={(event) => { + event.stopPropagation() + if (!props.disabled) props.onClick() + }} + > + {props.children} + + ) +} + +function cpuPercent(microseconds: number, milliseconds: number) { + if (milliseconds <= 0) return 0 + return Math.max(0, microseconds / (milliseconds * 10)) +} + +function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) { + const { themeV2 } = useTheme().contextual("elevated") + const value = () => { + const value = props.values.at(-1) + if (value === undefined) return "--" + return `${value.toFixed(props.decimals ?? 1)}${props.unit}` + } + return ( + + + {props.label} + + + {brailleGraph(props.values)} + + + {value()} + + + ) +} + +export function runtimeStatus(samples: readonly Readonly<{ delay: number; time: number }>[]): RuntimeStatus { + const latest = samples.at(-1)?.time + if (latest === undefined) return "normal" + const delay = Math.max( + 0, + ...samples.filter((sample) => sample.time > latest - statusWindowMilliseconds).map((sample) => sample.delay), + ) + if (delay >= 100) return "high" + if (delay >= 20) return "medium" + return "normal" +} + +export function statusIcon(status: RuntimeStatus) { + if (status === "high") return "●" + if (status === "medium") return "⦿" + return "○" +} + +export function connectionIndicator(status: "connected" | "connecting" | "reconnecting", attempt: number) { + if (status === "connected") return { state: "connected" as const, icon: "✓" } + if (status === "reconnecting" && attempt >= 3) return { state: "disconnected" as const, icon: "×" } + return { state: "reconnecting" as const, icon: "↻" } +} + +export function brailleGraph(values: readonly number[], width = graphWidth) { + const min = Math.min(...values) + const range = Math.max(...values) - min + const points = [...Array(width * 2 - values.length).fill(values.at(0)), ...values].slice( + -width * 2, + ) + const dots = [ + [6, 2, 1, 0], + [7, 5, 4, 3], + ] + return Array.from({ length: width }, (_, index) => { + const bits = [points[index * 2], points[index * 2 + 1]].reduce((result, value, column) => { + if (value === undefined) return result + const height = 1 + Math.round((range === 0 ? 0 : (value - min) / range) * 3) + return dots[column].slice(0, height).reduce((bits, dot) => bits | (1 << dot), result) + }, 0) + return String.fromCodePoint(0x2800 + bits) + }).join("") +} diff --git a/packages/tui/src/component/devtools-sidebar.tsx b/packages/tui/src/component/devtools-sidebar.tsx deleted file mode 100644 index cca1965232..0000000000 --- a/packages/tui/src/component/devtools-sidebar.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { TextAttributes } from "@opentui/core" -import { createSignal, For } from "solid-js" -import { useTheme } from "../context/theme" -import { DevTools } from "../devtools" - -export function DevToolsSidebar() { - const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated") - const [modeHovered, setModeHovered] = createSignal(false) - const nextMode = () => (mode() === "dark" ? "light" : "dark") - const canSwitchMode = () => supports(nextMode()) - - return ( - - - - - Theme - - - - Mode - - setModeHovered(canSwitchMode())} - onMouseOut={() => setModeHovered(false)} - onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined} - > - {mode()} - - - - - {(group) => ( - - - - {group.title} - - - - {(entry) => ( - - {entry.key} - - {String(entry.value)} - - )} - - - )} - - - ) -} diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index bdacb53b45..ab84fdf623 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -35,7 +35,7 @@ const settings: Setting[] = [ title: "Animations", category: "Appearance", path: ["animations"], - default: true, + default: false, values: [false, true], labels: ["off", "on"], }, @@ -223,10 +223,10 @@ const settings: Setting[] = [ labels: ["off", "on"], }, { - title: "Timing", + title: "DevTools: Timing", category: "Debug", path: ["debug", "timing"], - default: false, + default: true, values: [false, true], labels: ["off", "on"], }, diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx index 43b24b2d0c..ed13a8ffe0 100644 --- a/packages/tui/src/component/dialog-debug.tsx +++ b/packages/tui/src/component/dialog-debug.tsx @@ -1,6 +1,5 @@ import { TextAttributes } from "@opentui/core" import { createMemo, createSignal, For } from "solid-js" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" @@ -9,6 +8,7 @@ import { useLocal } from "../context/local" import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" import { describeOS, describeTerminal } from "../util/system" +import { useTuiApp } from "../context/runtime" export function DialogDebug() { const { themeV2 } = useTheme() @@ -17,6 +17,7 @@ export function DialogDebug() { const local = useLocal() const clipboard = useClipboard() const toast = useToast() + const app = useTuiApp() const [copied, setCopied] = createSignal(false) dialog.setSize("large") @@ -24,7 +25,7 @@ export function DialogDebug() { const entries = createMemo(() => { const model = local.model.current() return [ - { label: "Version", value: `${InstallationVersion} (${InstallationChannel})` }, + { label: "Version", value: `${app.version} (${app.channel})` }, { label: "Date", value: new Date().toISOString() }, { label: "OS", value: describeOS() }, { label: "Terminal", value: describeTerminal() }, diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx index c17ee29573..d500e36c0d 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -3,14 +3,15 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { createSignal, For, Show } from "solid-js" import { getScrollAcceleration } from "../util/scroll" import { useClipboard } from "../context/clipboard" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { useExit } from "../context/exit" +import { useTuiApp } from "../context/runtime" import { describeOS, describeTerminal } from "../util/system" export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) { const term = useTerminalDimensions() const exit = useExit() const clipboard = useClipboard() + const app = useTuiApp() const [copied, setCopied] = createSignal(false) // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the @@ -42,7 +43,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: const message = props.error.message || "An unknown error occurred." const stack = props.error.stack || "No stack trace available." - const issueURL = buildIssueURL(message, stack) + const issueURL = buildIssueURL(message, stack, app.version) const copyReport = () => { void clipboard.write?.(issueURL.toString()).then(() => setCopied(true)) @@ -192,7 +193,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: ? "Report copied — paste it into a new GitHub issue." : "Copy the report and open a GitHub issue to help us fix this."} - OpenCode {InstallationVersion} + OpenCode {app.version} @@ -200,13 +201,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: ) } -function buildIssueURL(message: string, stack: string) { +function buildIssueURL(message: string, stack: string, version: string) { // Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue // form opens pre-filled. Populating os/terminal/reproduce keeps the report past // the contributing-guidelines compliance check, which pushes for system info. const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml") url.searchParams.set("title", `TUI crash: ${message}`) - url.searchParams.set("opencode-version", InstallationVersion) + url.searchParams.set("opencode-version", version) url.searchParams.set("os", describeOS()) url.searchParams.set("terminal", describeTerminal()) url.searchParams.set( diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7f5e081cff..ff4b516a88 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1300,11 +1300,16 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (leader()) return themeV2.border.default - if (store.mode === "shell") return themeV2.background.action.primary.default + if (store.mode === "shell") return themeV2.text.action.primary.selected const agent = local.agent.current() if (!agent) return themeV2.border.default return local.agent.color(agent.id) }) + const agentLabel = createMemo(() => { + if (store.mode === "shell") return "Shell" + const agent = local.agent.current() + return agent ? Locale.titlecase(agent.id) : undefined + }) const showVariant = createMemo(() => { const variants = local.model.variant.list() @@ -1313,7 +1318,7 @@ export function Prompt(props: PromptProps) { return !!current }) - const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled) + const agentMetaAlpha = createFadeIn(() => store.mode === "shell" || !!local.agent.current(), animationsEnabled) const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) const variantMetaAlpha = createFadeIn( () => !!local.agent.current() && store.mode === "normal" && showVariant(), @@ -1460,12 +1465,10 @@ export function Prompt(props: PromptProps) { /> - }> - {(agent) => ( + }> + {(label) => ( <> - - {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)} - + {label()} auto diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 373251a1b4..3d66818390 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -133,6 +133,12 @@ export const Info = Schema.Struct({ turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ description: "Show or hide the agent, model, and duration summary in scrollback", }), + footer: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ + description: "Show or hide persistent activity, model, usage, and context details in the footer", + }), + splash: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ + description: "Show or hide the entry and exit splash banners", + }), mono: Schema.optional(Schema.Boolean).annotate({ description: "Use monochrome ASCII output", }), @@ -145,7 +151,7 @@ export const Info = Schema.Struct({ ).annotate({ description: "In-product guidance settings" }), debug: Schema.optional( Schema.Struct({ - devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools sidebar" }), + devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }), timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }), }), ).annotate({ description: "Debugging settings" }), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 41e9b68236..ddf3823e75 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -643,8 +643,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, - structured: match.state.status === "running" ? match.state.structured : {}, - content: match.state.status === "running" ? match.state.content : [], + structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}), + content: event.data.content ?? (match.state.status === "running" ? match.state.content : []), result: event.data.result, } match.executed = event.data.executed || match.executed === true diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index ced1cf1a44..d59a63abe6 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -1,5 +1,11 @@ import { createComponent, createContext, type JSX, useContext } from "solid-js" +export type TuiApp = Readonly<{ + name: string + version: string + channel: string +}> + export type TuiPaths = Readonly<{ cwd: string home: string @@ -23,6 +29,7 @@ export type TuiLifecycle = Readonly<{ }> const PathsContext = createContext() +const AppContext = createContext() const TerminalEnvironmentContext = createContext() const StartupContext = createContext() const LifecycleContext = createContext() @@ -40,6 +47,10 @@ export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element return provider(PathsContext, props.value, () => props.children) } +export function TuiAppProvider(props: { value: TuiApp; children: JSX.Element }) { + return provider(AppContext, props.value, () => props.children) +} + export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) { return provider(TerminalEnvironmentContext, props.value, () => props.children) } @@ -62,6 +73,10 @@ export function useTuiPaths() { return required(PathsContext, "TuiPathsProvider") } +export function useTuiApp() { + return required(AppContext, "TuiAppProvider") +} + export function useTuiTerminalEnvironment() { return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider") } diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 13caa748c9..88f91cb50d 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -22,7 +22,7 @@ import { createComponentTheme, type ComponentTheme } from "../theme/v2/component import { resolveThemeFile } from "../theme/v2/resolve" import { migrateV1 } from "../theme/v2/v1-migrate" import { themeModes } from "../theme/v2/select" -import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useConfig } from "../config" @@ -99,7 +99,7 @@ const [store, setStore] = createStore({ subscribeThemes((themes) => setStore("themes", themes)) -export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ +const themeContext = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => { const renderer = useRenderer() @@ -162,7 +162,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ let systemThemeMode: "dark" | "light" | undefined let hasResolvedSystemTheme = false function resolveSystemTheme(mode: "dark" | "light" = store.mode) { - const started = performance.now() return renderer .getPalette({ size: 16 }) .then((colors: TerminalColors) => { @@ -186,7 +185,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setSystemTheme(undefined) if (store.active === "system") setStore("active", "opencode") }) - .finally(() => themePerformance.set("Resolve system palette", duration(performance.now() - started))) } let systemRefreshRunning = false @@ -272,14 +270,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeRefreshTimeouts.length = 0 }) + const initStarted = performance.now() const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode) const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode")) - const file = createMemo(() => { - const started = performance.now() - const result = migrateV1(source()) - themePerformance.set("Convert V1 to V2", duration(performance.now() - started)) - return result - }) + const file = createMemo(() => migrateV1(source())) const modes = createMemo(() => themeModes(file())) const mode = () => { const supported = modes() @@ -287,12 +281,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ return supported[0] ?? store.mode } const values = createMemo(() => resolveTheme(source(), mode())) - const valuesV2 = createMemo(() => { - const resolveStarted = performance.now() - const result = resolveThemeFile(file(), mode(), sourceName()) - themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted)) - return result - }) + const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) + valuesV2() + themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { elevated: createComponentTheme(() => { @@ -363,10 +354,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }) -function duration(milliseconds: number) { - return `${milliseconds.toFixed(2)} ms` -} +export const useTheme = themeContext.use +export const ThemeProvider = themeContext.provider +export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) { + const theme = useTheme() + return ( + + {props.children} + + ) +} export function createSyntaxStyleMemo(factory: () => SyntaxStyle) { const renderer = useRenderer() const retained = new Set() diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index ad66916a98..e8d39ee82f 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,8 +1,7 @@ import { Plugin } from "@opencode-ai/plugin/v2/tui" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" -import { useTuiPaths } from "../../context/runtime" +import { useTuiApp, useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" @@ -50,6 +49,7 @@ function Mcp(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) { const { themeV2 } = useTheme() + const app = useTuiApp() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] @@ -71,12 +71,12 @@ function View(props: { context: Plugin.Context }) { > - {InstallationVersion} + {app.version} ) diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index b152a4926f..2210cb3eca 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/packages/tui/src/mini/catalog.shared.ts @@ -34,6 +34,7 @@ function runAgent(input: CurrentAgent): RunAgent { return { id: input.id, name: input.name, + description: input.description, mode: input.mode, hidden: input.hidden, } @@ -85,67 +86,6 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ return [...grouped.values()] } -export async function waitForDefaultModel(input: { - sdk: OpenCodeClient - location: LocationRef - timeoutMs?: number - requestTimeoutMs?: number - active?: () => boolean - signal?: AbortSignal -}): Promise<{ providerID: string; modelID: string } | undefined> { - const deadline = Date.now() + (input.timeoutMs ?? 5_000) - while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) { - const controller = new AbortController() - const timeout = setTimeout( - () => controller.abort(), - Math.min(input.requestTimeoutMs ?? 1_000, Math.max(1, deadline - Date.now())), - ) - const abort = () => controller.abort() - input.signal?.addEventListener("abort", abort, { once: true }) - const model = await abortable( - input.sdk.model - .default(location(input.location), { signal: controller.signal }) - .then((result) => result.data) - .catch(() => undefined), - controller.signal, - ).finally(() => { - clearTimeout(timeout) - input.signal?.removeEventListener("abort", abort) - }) - if (model) return { providerID: model.providerID, modelID: model.id } - await wait(25, input.signal) - } -} - -function abortable(task: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve(undefined) - return new Promise((resolve) => { - const abort = () => { - signal.removeEventListener("abort", abort) - resolve(undefined) - } - signal.addEventListener("abort", abort, { once: true }) - void task.then((value) => { - signal.removeEventListener("abort", abort) - resolve(value) - }) - }) -} - -function wait(delay: number, signal?: AbortSignal) { - if (!signal) return new Promise((resolve) => setTimeout(resolve, delay)) - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, delay) - signal.addEventListener("abort", done, { once: true }) - function done() { - clearTimeout(timer) - signal?.removeEventListener("abort", done) - resolve() - } - }) -} - export async function loadRunAgents(sdk: OpenCodeClient, ref: LocationRef, signal?: AbortSignal): Promise { const result = await sdk.agent.list(location(ref), ...requestOptions(signal)) return result.data.map(runAgent) diff --git a/packages/tui/src/mini/demo.ts b/packages/tui/src/mini/demo.ts index e4c11c9813..a16ae991ac 100644 --- a/packages/tui/src/mini/demo.ts +++ b/packages/tui/src/mini/demo.ts @@ -16,8 +16,9 @@ // the synthetic tool parts through the same callbacks used by the live footer. import path from "path" import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import { parseSlashHead } from "../prompt/parse" import { writeSessionOutput } from "./stream" -import { toolCommit } from "./stream-v2.subagent" +import { toolCommit, toolFinalPhase } from "./stream-v2.subagent" import type { FooterApi, FooterView, @@ -110,7 +111,6 @@ const SAMPLE_TABLE = [ type Ref = { msg: string - part: string call: string tool: string input: Record @@ -126,7 +126,6 @@ type FormRequest = { type Perm = { ref: Ref done: { - title: string output: string metadata?: Record } @@ -203,7 +202,6 @@ function showSubagent( description: input.description, status: input.status, title: input.title, - lastUpdatedAt: Date.now(), }, ], details: { @@ -337,7 +335,6 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal): function make(state: State, tool: string, input: Record): Ref { return { msg: open(state), - part: take(state, "part", "part"), call: take(state, "call", "call"), tool, input, @@ -346,7 +343,7 @@ function make(state: State, tool: string, input: Record): Ref } function startTool(state: State, ref: Ref, structured: Record = {}): SessionMessageAssistantTool { - state.started.add(ref.part) + state.started.add(ref.call) const part = { type: "tool" as const, id: ref.call, @@ -386,12 +383,11 @@ function doneTool( state: State, ref: Ref, output: { - title: string output: string metadata?: Record }, ): void { - if (!state.started.has(ref.part)) startTool(state, ref) + if (!state.started.has(ref.call)) startTool(state, ref) const part: SessionMessageAssistantTool = { type: "tool", id: ref.call, @@ -404,11 +400,11 @@ function doneTool( }, time: { created: ref.start, ran: ref.start, completed: Date.now() }, } - present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")]) + present(state, [toolCommit(part, ref.msg, toolFinalPhase(part))]) } function failTool(state: State, ref: Ref, error: string): void { - if (!state.started.has(ref.part)) startTool(state, ref) + if (!state.started.has(ref.call)) startTool(state, ref) present(state, [ toolCommit( { @@ -443,7 +439,6 @@ async function emitBash(state: State, signal?: AbortSignal): Promise { startTool(state, ref) await wait(70, signal) doneTool(state, ref, { - title: "git status", output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, metadata: { exit: 0, @@ -458,7 +453,6 @@ function emitWrite(state: State): void { content: "export const demo = 42\n", }) doneTool(state, ref, { - title: "write", output: "", metadata: {}, }) @@ -470,7 +464,6 @@ function emitEdit(state: State): void { path: file, }) doneTool(state, ref, { - title: "edit", output: "", metadata: { files: [ @@ -490,7 +483,6 @@ function emitPatch(state: State): void { patchText: "*** Begin Patch\n*** End Patch", }) doneTool(state, ref, { - title: "patch", output: "", metadata: { files: [ @@ -517,10 +509,9 @@ function emitTask(state: State): void { agent: "explore", }) doneTool(state, ref, { - title: "Reducer touchpoints found", output: "", metadata: { - sessionID: "sub_demo_1", + sessionID: "ses_demo_child", status: "completed", output: "", }, @@ -542,7 +533,7 @@ function emitTask(state: State): void { time: { created: Date.now(), ran: Date.now() }, } satisfies SessionMessageAssistantTool showSubagent(state, { - sessionID: "sub_demo_1", + sessionID: "ses_demo_child", label: "Explore", description: "Scan run/* for reducer touchpoints", status: "completed", @@ -562,16 +553,7 @@ function emitTask(state: State): void { messageID: "sub_demo_msg_reasoning", partID: "sub_demo_reasoning_1", }, - { - kind: "tool", - text: "running read", - phase: "start", - source: "tool", - messageID: "sub_demo_msg_tool", - partID: "sub_demo_tool_1", - tool: "read", - part, - }, + toolCommit(part, "sub_demo_msg_tool", "start"), { kind: "assistant", text: "Footer updates flow through stream.ts into RunFooter", @@ -610,7 +592,6 @@ function emitQuestionTool(state: State): void { ], }) doneTool(state, ref, { - title: "question", output: "", metadata: { answers: [["Diff"], ["Usage", "custom-note"]], @@ -635,7 +616,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: [command], always: ["*"], done: { - title: "git status --short", output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, metadata: { exit: 0, @@ -658,7 +638,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: [target], always: [target], done: { - title: "read", output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), metadata: {}, }, @@ -677,7 +656,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: ["explore"], always: ["*"], done: { - title: "Footer spacing checked", output: "", metadata: { sessionID: "sub_demo_perm_1", @@ -707,7 +685,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { }, always: [`${dir}/**`], done: { - title: "read", output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, metadata: {}, }, @@ -726,7 +703,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: ["*"], always: ["*"], done: { - title: "Retry allowed", output: "Continuing after repeated failures.\n", metadata: {}, }, @@ -744,7 +720,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: [file], always: [file], done: { - title: "edit", output: "", metadata: { files: [{ file, status: "modified", patch: diff }], @@ -955,8 +930,9 @@ export function createRunDemo(input: Input) { const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { const text = line.text.trim() - const list = text.split(/\s+/) - const cmd = list[0] || "" + const head = parseSlashHead(text) + const list = head?.arguments.split(/\s+/).filter(Boolean) ?? [] + const cmd = head ? `/${head.name}` : "" clearSubagent(state.footer) @@ -966,7 +942,7 @@ export function createRunDemo(input: Input) { } if (cmd === "/permission") { - const kind = permissionKind(list[1]) + const kind = permissionKind(list[0]) if (!kind) { note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) return true @@ -977,7 +953,7 @@ export function createRunDemo(input: Input) { } if (cmd === "/form") { - const kind = formKind(list[1]) + const kind = formKind(list[0]) if (!kind) { note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`) return true @@ -988,8 +964,8 @@ export function createRunDemo(input: Input) { } if (cmd === "/fmt") { - const kind = (list[1] || "").toLowerCase() - const body = list.slice(2).join(" ") + const kind = (list[0] || "").toLowerCase() + const body = list.slice(1).join(" ") if (!kind) { note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) return true @@ -1032,7 +1008,6 @@ export function createRunDemo(input: Input) { clearBlocker(state) if (form.kind === "question") { doneTool(state, form.ref, { - title: "question", output: "", metadata: { answers: form.request.fields.map((field) => { @@ -1045,7 +1020,6 @@ export function createRunDemo(input: Input) { return true } doneTool(state, form.ref, { - title: form.request.title, output: `Form submitted: ${Object.entries(input.answer) .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`) .join("; ")}\n`, diff --git a/packages/tui/src/mini/entry.body.ts b/packages/tui/src/mini/entry.body.ts index 2d4a03f7f6..499ed091da 100644 --- a/packages/tui/src/mini/entry.body.ts +++ b/packages/tui/src/mini/entry.body.ts @@ -214,7 +214,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE } - return mono ? textBody(raw) : markdownBody(raw) + return markdownBody(raw) } if (commit.kind === "reasoning") { diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 869d6b342b..e7c8da0d8b 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -10,6 +10,7 @@ import type { FooterSubagentTab, MiniSettingChange, MiniSettings, + RunAgent, RunCommand, RunInput, RunProvider, @@ -21,11 +22,13 @@ type PanelEntry = RunFooterMenuItem & { } type CommandEntry = + | (PanelEntry & { action: "agent" }) | (PanelEntry & { action: "model" }) | (PanelEntry & { action: "editor" }) | (PanelEntry & { action: "skill" }) | (PanelEntry & { action: "queued" }) | (PanelEntry & { action: "subagent" }) + | (PanelEntry & { action: "status" }) | (PanelEntry & { action: "variant.cycle" }) | (PanelEntry & { action: "variant.list" }) | (PanelEntry & { action: "settings" }) @@ -39,6 +42,11 @@ type ModelEntry = PanelEntry & { current: boolean } +type AgentEntry = PanelEntry & { + id: string + current: boolean +} + type VariantEntry = PanelEntry & { variant: string | undefined current: boolean @@ -53,10 +61,6 @@ type SubagentEntry = PanelEntry & { current: boolean } -type QueuedEntry = PanelEntry & { - prompt: FooterQueuedPrompt -} - type SettingEntry = PanelEntry & { key: keyof MiniSettings } @@ -91,18 +95,6 @@ function countLabel(count: number, total: number, query: string) { return `${count}/${total}` } -function categoryRank(category: string) { - if (category === "Project Commands") { - return 0 - } - - if (category === "MCP Commands") { - return 1 - } - - return 2 -} - function subagentStatusLabel(status: FooterSubagentTab["status"]) { if (status === "completed") { return "done" @@ -356,6 +348,7 @@ export function RunCommandMenuBody(props: { variants: Accessor variantCycle: string onClose: () => void + onAgent: () => void onModel: () => void onEditor: () => void onSkill: () => void @@ -363,6 +356,7 @@ export function RunCommandMenuBody(props: { onQueued: () => void onVariant: () => void onVariantCycle: () => void + onStatus: () => void onSettings: () => void onCommand: (name: string) => void onNew: () => void @@ -372,7 +366,6 @@ export function RunCommandMenuBody(props: { const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["editor", "new", "settings"] const session: CommandEntry[] = [ { action: "editor", @@ -381,6 +374,12 @@ export function RunCommandMenuBody(props: { footer: "/editor", keywords: "editor compose draft external editor", }, + { + action: "status", + category: "Session", + display: "Show status", + keywords: "status activity model context usage footer", + }, ...(props.subagents().length > 0 ? [ { @@ -396,6 +395,14 @@ export function RunCommandMenuBody(props: { }, ] : []), + { + action: "slash", + category: "Session", + name: "compact", + display: "Compact session", + footer: "/compact", + keywords: "compact summarize session context", + }, { action: "slash", category: "Session", @@ -420,6 +427,11 @@ export function RunCommandMenuBody(props: { ] : [] const agent: CommandEntry[] = [ + { + action: "agent", + category: "Agent", + display: "Switch agent", + }, { action: "model", category: "Agent", @@ -457,29 +469,10 @@ export function RunCommandMenuBody(props: { ] : []), ] - const commands = (props.commands() ?? []) - .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) - .map( - (item) => - ({ - action: "slash", - category: item.source === "mcp" ? "MCP Commands" : "Project Commands", - name: item.name, - display: item.name, - footer: `/${item.name}`, - keywords: - item.source === "mcp" - ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` - : `/${item.name} ${item.name} ${item.description ?? ""}`, - }) satisfies CommandEntry, - ) - .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)) - return [ ...session, ...prompt, ...agent, - ...commands, { action: "settings", category: "System", @@ -491,6 +484,11 @@ export function RunCommandMenuBody(props: { ] }) const pick = (item: CommandEntry) => { + if (item.action === "agent") { + props.onAgent() + return + } + if (item.action === "model") { props.onModel() return @@ -526,6 +524,11 @@ export function RunCommandMenuBody(props: { return } + if (item.action === "status") { + props.onStatus() + return + } + if (item.action === "settings") { props.onSettings() return @@ -583,6 +586,67 @@ export function RunCommandMenuBody(props: { ) } +export function RunAgentSelectBody(props: { + theme: Accessor + agents: Accessor + current: Accessor + onClose: () => void + onSelect: (agent: string) => void + mono?: boolean +}) { + const entries = createMemo(() => + props + .agents() + .filter((agent) => agent.mode !== "subagent" && !agent.hidden) + .map((agent) => ({ + category: "", + display: agent.id, + description: agent.description, + footer: props.current() === agent.id ? "current" : undefined, + keywords: `${agent.id} ${agent.name} ${agent.description ?? ""}`, + id: agent.id, + current: props.current() === agent.id, + })), + ) + const controller = createSearchablePanelController({ + entries, + limit: PANEL_LIST_ROWS, + onClose: props.onClose, + onSelect: (item) => props.onSelect(item.id), + isCurrent: (item) => item.current, + }) + + return ( + + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No agents found" + border={false} + paddingLeft={panelPad(props.mono)} + paddingRight={panelPad(props.mono)} + grouped={false} + background + mono={props.mono} + /> + + ) +} + export function RunSettingsBody(props: { theme: Accessor settings: Accessor @@ -613,6 +677,20 @@ export function RunSettingsBody(props: { keywords: `turn summary agent model duration ${props.settings().turn_summary}`, key: "turn_summary", }, + { + category: "Terminal", + display: "Footer details", + footer: saving() === "footer" ? "saving" : props.settings().footer, + keywords: `footer status activity model context usage ${props.settings().footer}`, + key: "footer", + }, + { + category: "Terminal", + display: "Splash", + footer: saving() === "splash" ? "saving" : props.settings().splash, + keywords: `splash entry exit banner ${props.settings().splash}`, + key: "splash", + }, { category: "Terminal", display: "Monochrome UI", @@ -751,13 +829,12 @@ export function RunQueuedPromptSelectBody(props: { onRows?: (rows: number) => void mono?: boolean }) { - const entries = createMemo(() => + const entries = createMemo(() => props.prompts().map((prompt) => ({ category: "", display: prompt.prompt.text.replaceAll("\n", " "), footer: prompt.delivery, keywords: prompt.prompt.text, - prompt, })), ) const controller = createSearchablePanelController({ @@ -992,7 +1069,11 @@ export function RunModelSelectBody(props: { > + controller.query().trim() + ? controller.items().map((item) => ({ ...item, footer: item.providerName })) + : controller.items() + } selected={controller.menu.selected} offset={controller.menu.offset} rows={() => PANEL_LIST_ROWS} diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index 6d0ece4e14..8b8acf9848 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -31,13 +31,13 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit import { monoTruncateMiddle } from "./mono" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types" +import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types" const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_BOTTOM_ROWS = 1 export const TEXTAREA_MIN_ROWS = 1 -export const TEXTAREA_MAX_ROWS = 6 +const TEXTAREA_MAX_ROWS = 6 export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS type Mention = Extract @@ -65,7 +65,6 @@ type PromptInput = { agents: Accessor references: Accessor commands: Accessor - tuiConfig: RunTuiConfig state: Accessor view: Accessor prompt: Accessor @@ -142,7 +141,7 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { } } -export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) { +export function selectedCommand(text: string, command: RunPrompt["command"]) { if (!command) { return } @@ -152,14 +151,10 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com return } - // Bound drafts (e.g. the skill picker) may predate or omit the catalog - // source; resolve it at submit time so routing never degrades to a plain - // command for a skill entry. - const source = command.source ?? commands?.find((item) => item.name === command.name)?.source return { name: command.name, arguments: head.arguments, - ...(source ? { source } : {}), + ...(command.source ? { source: command.source } : {}), } } @@ -395,6 +390,12 @@ export function createPromptState(input: PromptInput): PromptState { description: "configure Mini transcript output", } satisfies SlashOption, { kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption, + { + kind: "slash", + name: "compact", + display: "/compact", + description: "summarize the session to reduce context usage", + } satisfies SlashOption, { kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption, ] const hidden = new Set(builtins.map((item) => item.name)) @@ -1134,7 +1135,7 @@ export function createPromptState(input: PromptInput): PromptState { return } - const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands()) + const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) if (!command && next.mode !== "shell" && isExitCommand(next.text)) { input.onExit() return diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index b21c249612..002cc62616 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -74,8 +74,7 @@ type RunFooterOptions = { agents: RunAgent[] references: RunReference[] wrote?: boolean - sessionID: () => string | undefined - agentLabel: string + agent: string | undefined modelLabel: string model: RunInput["model"] variant: string | undefined @@ -92,6 +91,7 @@ type RunFooterOptions = { onFormReply: (input: FormReply) => void | Promise onFormCancel: (input: FormCancel) => void | Promise onCycleVariant?: () => CycleResult | void + onAgentSelect?: (agent: string) => void onModelSelect?: (model: NonNullable) => CycleResult | void | Promise onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise onInterrupt?: () => void @@ -102,6 +102,11 @@ type RunFooterOptions = { subscribeThemeSignal: (listener: () => void) => () => void } +export function resolveRunAgent(agents: RunAgent[], current: string | undefined) { + const selectable = agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden) + return selectable.find((agent) => agent.id === current) ?? selectable.at(0) +} + const PERMISSION_ROWS = 12 const FORM_ROWS = 14 const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS @@ -170,6 +175,9 @@ export class RunFooter implements FooterApi { private setCommands: Setter private providers: Accessor private setProviders: Setter + private currentAgent: Accessor + private currentAgentID: Accessor + private setCurrentAgentID: Setter private currentModel: Accessor private setCurrentModel: Setter private variants: Accessor @@ -195,8 +203,7 @@ export class RunFooter implements FooterApi { private interruptTimeout: NodeJS.Timeout | undefined private exitTimeout: NodeJS.Timeout | undefined private noticeTimeout: NodeJS.Timeout | undefined - private noticeRestoreStatus = "" - private statusVersion = 0 + private turnAgent: string | undefined private requestExitHandler: (() => boolean) | undefined private scrollback: RunScrollbackStream private themes: RunTheme[] @@ -226,6 +233,7 @@ export class RunFooter implements FooterApi { const [state, setState] = createSignal({ phase: "idle", status: "", + notice: "", model: options.modelLabel, usage: "", first: options.first, @@ -249,6 +257,16 @@ export class RunFooter implements FooterApi { const [providers, setProviders] = createSignal() this.providers = providers this.setProviders = setProviders + const [selectedAgentID, setCurrentAgentID] = createSignal(options.agent) + const currentAgent = () => resolveRunAgent(this.agents(), selectedAgentID()) + this.currentAgentID = () => currentAgent()?.id ?? selectedAgentID() + this.setCurrentAgentID = setCurrentAgentID + this.currentAgent = () => { + const agent = currentAgent() + if (agent) return agent.name + const selected = selectedAgentID() + return selected ? Locale.titlecase(selected) : "Default" + } const [currentModel, setCurrentModel] = createSignal(options.model) this.currentModel = currentModel this.setCurrentModel = setCurrentModel @@ -307,12 +325,13 @@ export class RunFooter implements FooterApi { references: footer.references, commands: footer.commands, providers: footer.providers, + currentAgent: footer.currentAgent, + currentAgentID: footer.currentAgentID, currentModel: footer.currentModel, variants: footer.variants, currentVariant: footer.currentVariant, theme: footer.theme, mono: options.mono, - tuiConfig: options.tuiConfig, miniSettings: footer.miniSettings, history: footer.history, onSubmit: footer.handlePrompt, @@ -327,6 +346,7 @@ export class RunFooter implements FooterApi { onExitRequest: footer.handleExit, onRequestExit: footer.setRequestExitHandler, onExit: () => footer.close(), + onAgentSelect: footer.handleAgentSelect, onModelSelect: footer.handleModelSelect, onVariantSelect: footer.handleVariantSelect, onRows: footer.syncRows, @@ -380,7 +400,7 @@ export class RunFooter implements FooterApi { } if (next.type === "agent") { - this.options.agentLabel = Locale.titlecase(next.agent ?? "build") + this.setCurrentAgentID(next.agent) return } @@ -389,13 +409,15 @@ export class RunFooter implements FooterApi { } if (next.type === "turn.duration") { + const agent = this.turnAgent ?? this.currentAgent() + this.turnAgent = undefined if (this.miniSettings().turn_summary === "hide") return const current = this.currentModel() this.flush() this.flushing = this.flushing .then(() => this.scrollback.writeTurnSummary({ - agent: this.options.agentLabel, + agent, model: current ? modelInfo(this.providers(), current).model : this.state().model, duration: next.duration, }), @@ -451,8 +473,10 @@ export class RunFooter implements FooterApi { if (patch) { if (typeof patch.status === "string") { this.clearNoticeTimer() + patch.notice = "" } if (next.type === "turn.send") { + this.turnAgent = this.currentAgent() this.clearInterruptTimer() this.clearExitTimer() } @@ -481,12 +505,10 @@ export class RunFooter implements FooterApi { } const prev = this.state() - if (typeof next.status === "string") { - this.statusVersion++ - } const state = { phase: next.phase ?? prev.phase, status: typeof next.status === "string" ? next.status : prev.status, + notice: typeof next.notice === "string" ? next.notice : prev.notice, model: typeof next.model === "string" ? next.model : prev.model, usage: typeof next.usage === "string" ? next.usage : prev.usage, first: typeof next.first === "boolean" ? next.first : prev.first, @@ -594,6 +616,10 @@ export class RunFooter implements FooterApi { return this.theme() } + public currentMiniSettings(): MiniSettings { + return this.miniSettings() + } + private destroyTheme(theme: RunTheme): void { const index = this.themes.indexOf(theme) if (index === -1) { @@ -637,26 +663,13 @@ export class RunFooter implements FooterApi { } private setNotice(status: string): void { - const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status - this.clearNoticeTimer(false) - this.patch({ status }) - if (!status) { - this.noticeRestoreStatus = "" - return - } + this.clearNoticeTimer() + this.patch({ notice: status }) + if (!status) return - this.noticeRestoreStatus = restore - const version = this.statusVersion this.noticeTimeout = setTimeout(() => { this.noticeTimeout = undefined - if (this.isGone || version !== this.statusVersion) { - this.noticeRestoreStatus = "" - return - } - - const next = this.noticeRestoreStatus - this.noticeRestoreStatus = "" - this.patch({ status: next }) + this.patch({ notice: "" }) }, NOTICE_DURATION) } @@ -684,7 +697,7 @@ export class RunFooter implements FooterApi { ? this.base + PERMISSION_ROWS : type === "form" ? this.base + FORM_ROWS - : ["command", "skill", "model", "variant", "settings"].includes(route) + : ["command", "skill", "agent", "model", "variant", "settings"].includes(route) ? 1 + RUN_COMMAND_PANEL_ROWS : route === "queued-menu" || route === "subagent-menu" ? 1 + this.subagentMenuRows @@ -832,6 +845,13 @@ export class RunFooter implements FooterApi { .catch(() => {}) } + private handleAgentSelect = (agent: string): void => { + if (this.isClosed || this.currentAgentID() === agent) return + this.setCurrentAgentID(agent) + this.options.onAgentSelect?.(agent) + this.setNotice(`agent ${this.currentAgent()}`) + } + private handleVariantSelect = (variant: string | undefined): void => { if (this.isClosed) { return @@ -897,19 +917,9 @@ export class RunFooter implements FooterApi { this.interruptTimeout = undefined } - private clearNoticeTimer(reset = true): void { - if (!this.noticeTimeout) { - if (reset) { - this.noticeRestoreStatus = "" - } - return - } - - clearTimeout(this.noticeTimeout) + private clearNoticeTimer(): void { + if (this.noticeTimeout) clearTimeout(this.noticeTimeout) this.noticeTimeout = undefined - if (reset) { - this.noticeRestoreStatus = "" - } } private armInterruptTimer(): void { diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 68bb043243..956168565c 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -14,6 +14,7 @@ import { registerOpencodeSpinner } from "../component/register-spinner" import { createColors, createFrames } from "../ui/spinner" import { RUN_SUBAGENT_PANEL_ROWS, + RunAgentSelectBody, RunCommandMenuBody, RunModelSelectBody, RunQueuedPromptSelectBody, @@ -50,7 +51,6 @@ import type { RunPrompt, RunProvider, RunReference, - RunTuiConfig, } from "./types" import type { RunTheme } from "./theme" @@ -77,6 +77,8 @@ type RunFooterViewProps = { references: () => RunReference[] commands: () => RunCommand[] | undefined providers: () => RunProvider[] | undefined + currentAgent: () => string + currentAgentID: () => string | undefined currentModel: () => RunInput["model"] variants: () => string[] currentVariant: () => string | undefined @@ -86,7 +88,6 @@ type RunFooterViewProps = { queuedPrompts?: () => FooterQueuedPrompt[] theme: () => RunTheme mono: boolean - tuiConfig: RunTuiConfig miniSettings: () => MiniSettings history?: () => RunPrompt[] onSubmit: (input: RunPrompt) => boolean @@ -101,6 +102,7 @@ type RunFooterViewProps = { onExitRequest?: () => boolean onRequestExit?: (fn: (() => boolean) | undefined) => void onExit: () => void + onAgentSelect: (agent: string) => void onModelSelect: (model: NonNullable) => void onVariantSelect: (variant: string | undefined) => void onRows: (rows: number) => void @@ -136,6 +138,7 @@ export function RunFooterView(props: RunFooterViewProps) { const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent") const commanding = createMemo(() => active().type === "prompt" && route().type === "command") const skilling = createMemo(() => active().type === "prompt" && route().type === "skill") + const agenting = createMemo(() => active().type === "prompt" && route().type === "agent") const modeling = createMemo(() => active().type === "prompt" && route().type === "model") const varianting = createMemo(() => active().type === "prompt" && route().type === "variant") const setting = createMemo(() => active().type === "prompt" && route().type === "settings") @@ -147,6 +150,7 @@ export function RunFooterView(props: RunFooterViewProps) { selectingSubagent() || commanding() || skilling() || + agenting() || modeling() || varianting() || setting(), @@ -189,6 +193,7 @@ export function RunFooterView(props: RunFooterViewProps) { const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) const usage = createMemo(() => props.state().usage) + const footerDetails = createMemo(() => props.miniSettings().footer === "show") const interruptLabel = createMemo(() => { if (!interrupt()) { return @@ -217,6 +222,16 @@ export function RunFooterView(props: RunFooterViewProps) { color: createColors(options), } }) + const footerStatus = createMemo(() => { + const current = model() ?? props.state().model.trim() + const variant = props.currentVariant() + const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`] + if (current) details.push(variant ? `${current} ${variant}` : current) + if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage()) + if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`) + if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`) + return details.join(props.mono ? " - " : " · ") + }) const permission = createMemo | undefined>(() => { const view = active() return view.type === "permission" ? view : undefined @@ -259,6 +274,11 @@ export function RunFooterView(props: RunFooterViewProps) { props.onSubagentSelect?.(undefined) } + const openAgent = () => { + setRoute({ type: "agent" }) + props.onSubagentSelect?.(undefined) + } + const openSkillMenu = () => { if (props.commands() && skills().length === 0) { return @@ -329,7 +349,6 @@ export function RunFooterView(props: RunFooterViewProps) { agents: props.agents, references: props.references, commands: props.commands, - tuiConfig: props.tuiConfig, state: props.state, view: promptView, prompt, @@ -352,6 +371,7 @@ export function RunFooterView(props: RunFooterViewProps) { const shell = createMemo(() => prompt() && composer.shell()) const menu = createMemo(() => prompt() && composer.visible()) const stateStatus = createMemo(() => props.state().status.trim()) + const notice = createMemo(() => props.state().notice.trim()) const modeLabel = createMemo(() => { if (exiting()) { return "EXIT" @@ -375,9 +395,13 @@ export function RunFooterView(props: RunFooterViewProps) { return `Press ${clearShortcut() || "ctrl+c"} again to exit` } - if (busy()) { - return armed() ? "again to interrupt" : "interrupt" - } + if (busy() && armed()) return "again to interrupt" + + if (notice()) return notice() + + if (!footerDetails()) return shell() ? "Shell mode" : "" + + if (busy()) return "interrupt" if (stateStatus().length > 0) { return stateStatus() @@ -386,16 +410,17 @@ export function RunFooterView(props: RunFooterViewProps) { return shell() ? "Shell mode" : "" }) const activityMeta = createMemo(() => { - if (!responsive().statusline.showActivityMeta || usage().length === 0) { + if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) { return "" } return props.mono ? usage().replaceAll(" · ", " - ") : usage() }) const modelStatus = createMemo(() => { - const current = model() - if (!prompt() || !responsive().statusline.showModel || !current) return + const current = model() ?? props.state().model.trim() + if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showModel || !current) return return { + agent: props.currentAgent(), model: current, variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined, } @@ -409,7 +434,7 @@ export function RunFooterView(props: RunFooterViewProps) { return theme().highlight } - if (busy() || stateStatus().length > 0) { + if (busy() || notice().length > 0 || stateStatus().length > 0) { return theme().text } @@ -419,7 +444,7 @@ export function RunFooterView(props: RunFooterViewProps) { const hasActivityMeta = createMemo(() => activityMeta().length > 0) const hasModelStatus = createMemo(() => Boolean(modelStatus())) const contextHints = createMemo(() => { - if (!prompt() || shell() || !responsive().statusline.showContextHints) { + if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) { return [] } @@ -580,6 +605,7 @@ export function RunFooterView(props: RunFooterViewProps) { if ( current.type !== "command" && current.type !== "skill" && + current.type !== "agent" && current.type !== "model" && current.type !== "variant" && current.type !== "settings" && @@ -685,6 +711,7 @@ export function RunFooterView(props: RunFooterViewProps) { variants={props.variants} variantCycle={variantCycle()} onClose={closePanel} + onAgent={openAgent} onModel={openModel} onEditor={() => { closePanel() @@ -699,6 +726,10 @@ export function RunFooterView(props: RunFooterViewProps) { props.onCycle() closePanel() }} + onStatus={() => { + props.onStatus(footerStatus()) + closePanel() + }} onCommand={(name) => { composer.submitText(`/${name}`) closePanel() @@ -731,6 +762,19 @@ export function RunFooterView(props: RunFooterViewProps) { mono={props.mono} /> + + { + props.onAgentSelect(agent) + closePanel() + }} + mono={props.mono} + /> + - + - + {(label) => {label()} } @@ -890,6 +934,10 @@ export function RunFooterView(props: RunFooterViewProps) { flexShrink={1} > + + {info().agent} + {props.mono ? " - " : " · "} + {info().model} {(variant) => {variant()}} diff --git a/packages/tui/src/mini/footer.width.ts b/packages/tui/src/mini/footer.width.ts index 35670d0ef7..e7fd07b5bf 100644 --- a/packages/tui/src/mini/footer.width.ts +++ b/packages/tui/src/mini/footer.width.ts @@ -20,6 +20,7 @@ export function footerWidthPolicy(width: number) { }, statusline: { showActivityMeta: compact, + showAgent: compact, showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint, showModel: width >= FOOTER_WIDTH_BREAKPOINTS.model, showModelVariant: width >= FOOTER_WIDTH_BREAKPOINTS.modelVariant, diff --git a/packages/tui/src/mini/form.shared.ts b/packages/tui/src/mini/form.shared.ts index 7fdcd7fe51..e571dabbdc 100644 --- a/packages/tui/src/mini/form.shared.ts +++ b/packages/tui/src/mini/form.shared.ts @@ -14,7 +14,7 @@ import { import type { FormAnswerField } from "../util/form" import type { FormReply, MiniFormRequest } from "./types" -export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue } +export { formCustom, formLabel, formRows, formTextual, formValidateValue } export type FormBodyState = { formID: string @@ -97,7 +97,7 @@ export function formSetSelected(state: FormBodyState, selected: number): FormBod return { ...state, selected, error: "" } } -export function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState { +function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState { return { ...state, editing, error: "" } } diff --git a/packages/tui/src/mini/mono.ts b/packages/tui/src/mini/mono.ts index 389cc9d702..109daa0a4b 100644 --- a/packages/tui/src/mini/mono.ts +++ b/packages/tui/src/mini/mono.ts @@ -1,3 +1,12 @@ +import { + BoxRenderable, + RGBA, + type BorderCharacters, + type CliRendererExternalOutputEvent, + type MarkdownOptions, + type Renderable, +} from "@opentui/core" + const prefixes: Record = { 0x2192: "->", 0x2190: "<-", @@ -10,6 +19,90 @@ const prefixes: Record = { 0x27f3: "*", } +const markdown: Record = { + ...prefixes, + 0x00a0: " ", + 0x00b7: "-", + 0x2010: "-", + 0x2011: "-", + 0x2012: "-", + 0x2013: "-", + 0x2014: "--", + 0x2018: "'", + 0x2019: "'", + 0x201c: '"', + 0x201d: '"', + 0x2022: "*", + 0x2026: "...", + 0x2191: "up", + 0x2193: "down", +} + +const asciiBorder: BorderCharacters = { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", + topT: "+", + bottomT: "+", + leftT: "+", + rightT: "+", + cross: "+", +} + +export const monoMarkdownTableOptions = { + style: "columns" as const, + widthMode: "content" as const, + borders: false, +} + +export const monoMarkdownRenderNode: NonNullable = (token, context) => { + if (token.type !== "blockquote" && token.type !== "hr" && token.type !== "list") return + const renderable = context.defaultRender() + if (!renderable) return renderable + monoBorders(renderable) + return renderable +} + +function monoBorders(renderable: Renderable): void { + if (renderable instanceof BoxRenderable) renderable.customBorderChars = asciiBorder + renderable.getChildren().forEach(monoBorders) +} + +export function monoMarkdown(value: string, mono: boolean): string { + if (!mono) return value + return value.replace(/[^\t\n\x20-\x7e]/gu, (char) => markdown[char.codePointAt(0)!] ?? "?") +} + +export function monoSnapshot(event: CliRendererExternalOutputEvent): void { + const buffers = event.snapshot.buffers + const chars = buffers.char + for (let index = 0; index < chars.length; index += 1) { + const point = chars[index]! + if (point <= 0x7f) continue + const offset = index * 4 + event.snapshot.setCell( + index % event.snapshot.width, + Math.floor(index / event.snapshot.width), + monoCell(point), + RGBA.fromArray(buffers.fg.subarray(offset, offset + 4)), + RGBA.fromArray(buffers.bg.subarray(offset, offset + 4)), + buffers.attributes[index], + ) + } +} + +function monoCell(point: number): string { + const kind = point >>> 30 + if (kind === 2) return "?" + if (kind === 3) return " " + if (point === 0x2500) return "-" + if (point === 0x2502) return "|" + return markdown[point]?.[0] ?? "?" +} + export function monoPrefix(value: string, mono: boolean): string { if (!mono) return value const point = value.codePointAt(0) diff --git a/packages/tui/src/mini/permission.shared.ts b/packages/tui/src/mini/permission.shared.ts index fa4610efcb..e293d3ef17 100644 --- a/packages/tui/src/mini/permission.shared.ts +++ b/packages/tui/src/mini/permission.shared.ts @@ -76,7 +76,7 @@ export function permissionLabel(option: PermissionOption): string { export { permissionAlwaysLines } -export function permissionReply( +function permissionReply( sessionID: string, requestID: string, reply: PermissionReply["reply"], diff --git a/packages/tui/src/mini/prompt.shared.ts b/packages/tui/src/mini/prompt.shared.ts index 64924e0dae..be1e24db86 100644 --- a/packages/tui/src/mini/prompt.shared.ts +++ b/packages/tui/src/mini/prompt.shared.ts @@ -53,6 +53,11 @@ export function isNewCommand(input: string): boolean { return input.trim().toLowerCase() === "/new" } +export function isCompactCommand(input: string): boolean { + const text = input.trim().toLowerCase() + return text === "/compact" || text === "/summarize" +} + export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState { const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy) const next: RunPrompt[] = [] diff --git a/packages/tui/src/mini/runtime.boot.ts b/packages/tui/src/mini/runtime.boot.ts index 3828e9a454..3a65ce19c0 100644 --- a/packages/tui/src/mini/runtime.boot.ts +++ b/packages/tui/src/mini/runtime.boot.ts @@ -89,6 +89,8 @@ export function resolveMiniSettings(config?: { mini?: Partial }): thinking: config?.mini?.thinking ?? "hide", shell_output: config?.mini?.shell_output ?? "hide", turn_summary: config?.mini?.turn_summary ?? "show", + footer: config?.mini?.footer ?? "show", + splash: config?.mini?.splash ?? "show", mono: config?.mini?.mono ?? false, } } diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 280cb28517..315335da82 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -11,7 +11,7 @@ import path from "path" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { isDefaultTitle } from "../util/session" -import { Locale } from "../util/locale" +import { monoSnapshot } from "./mono" import { entrySplash, exitSplash, splashMeta } from "./splash" import { resolveRunTheme } from "./theme" import type { @@ -45,11 +45,6 @@ type CycleResult = { variants?: string[] } -type FooterLabels = { - agentLabel: string - modelLabel: string -} - export type LifecycleInput = { host: MiniHost getDirectory: () => string @@ -70,6 +65,7 @@ export type LifecycleInput = { onFormReply: (input: FormReply) => void | Promise onFormCancel: (input: FormCancel) => void | Promise onCycleVariant?: () => CycleResult | void + onAgentSelect?: (agent: string) => void onModelSelect?: (model: NonNullable) => CycleResult | void | Promise onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise onInterrupt?: () => void @@ -82,6 +78,7 @@ export type Lifecycle = { footer: FooterApi onResize(fn: () => void): () => void refreshTheme(): void + setTitle(title?: string): void resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise } @@ -122,14 +119,6 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) { } } -function footerLabels(input: Pick): FooterLabels { - const agentLabel = Locale.titlecase(input.agent ?? "build") - return { - agentLabel, - modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "", - } -} - function directoryLabel(directory: string, home: string) { const resolved = path.resolve(directory) const display = @@ -184,6 +173,13 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { + if (input.host.platform !== "linux") return + if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode") + renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`) + } + setTitle(input.sessionTitle) const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono) renderer.setBackgroundColor(theme.background) const state: SplashState = { @@ -196,22 +192,19 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) @@ -225,15 +218,17 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise input.sessionID), - ...labels, + agent: input.agent, + modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model", model: input.model, variant: input.variant, first: input.first, history: input.history, theme, mono, - wrote, + // The transcript always starts one row below the terminal's prior output, + // even when the entry splash itself is hidden. + wrote: wrote || miniSettings.splash === "hide", tuiConfig, miniSettings: { current: miniSettings, @@ -243,6 +238,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) - const show = renderer.isDestroyed ? false : next.showExit - if (!renderer.isDestroyed && show) { + if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") { const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history) wroteExit = queueSplash( @@ -338,6 +333,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() + if (input.host.platform === "linux") renderer.setTerminalTitle("") + if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot) shutdown(renderer) if (!wroteExit) { input.host.stdout.write("\n") @@ -350,6 +347,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise void onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise onNewSession?: () => void | Promise + onCompact?: () => void | Promise admit: (prompt: RunPrompt, signal: AbortSignal) => Promise settle: () => Promise run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise @@ -126,6 +127,24 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } + if (prompt.mode !== "shell" && isCompactCommand(prompt.text)) { + emit( + { + type: "stream.patch", + patch: { + phase: "running", + status: "compacting session", + }, + }, + { + phase: "running", + status: "compacting session", + }, + ) + await input.onCompact?.() + continue + } + const sent = prompt.mode === "shell" ? prompt @@ -251,7 +270,11 @@ export async function runPromptQueue(input: QueueInput): Promise { active.mode !== "shell" && prompt.mode !== "shell" && prompt.command?.source !== "skill" && - !isNewCommand(prompt.text) + !isNewCommand(prompt.text) && + !isCompactCommand(prompt.text) && + !state.queue.some( + (item) => item.mode !== "shell" && (isNewCommand(item.text) || isCompactCommand(item.text)), + ) ) { const sent = { ...prompt, messageID: SessionMessage.ID.create() } const admission = state.admission @@ -265,7 +288,7 @@ export async function runPromptQueue(input: QueueInput): Promise { } state.queue.push(prompt) - if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { + if (prompt.mode !== "shell" && (isNewCommand(prompt.text) || isCompactCommand(prompt.text))) { drain() return } diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index 34bd286259..041d3bc28c 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -11,7 +11,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import type { LocationRef } from "@opencode-ai/client/promise" import type { Config } from "../config" -import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared" +import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" import { resolveMiniSettings, resolveModelInfo, @@ -115,6 +115,7 @@ type RuntimeState = { shown: boolean aborting: boolean model: RunInput["model"] + defaultModel: RunInput["model"] providers: RunProvider[] variants: string[] activeVariant: string | undefined @@ -212,6 +213,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep shown: !session.first, aborting: false, model: ctx.model ?? session.model, + defaultModel: undefined, providers: [], variants: [], activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []), @@ -286,20 +288,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep await settleForm(next.sessionID, next.formID) }, onCycleVariant: () => { - if (!state.model || state.variants.length === 0) { + const model = state.model ?? state.defaultModel + if (!model || state.variants.length === 0) { return { status: "no variants available", } } + if (!state.model) state.model = model state.activeVariant = cycleVariant(state.activeVariant, state.variants) - void input.host.preferences.saveVariant(state.model, state.activeVariant) + void input.host.preferences.saveVariant(model, state.activeVariant) return { status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default", - modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers), + modelLabel: formatModelLabel(model, state.activeVariant, state.providers), variant: state.activeVariant, } }, + onAgentSelect: (agent) => { + state.agent = agent + }, onModelSelect: async (model) => { if (state.model?.providerID === model.providerID && state.model.modelID === model.modelID) { return @@ -335,7 +342,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } }, onVariantSelect: async (variant) => { - if (!state.model || state.variants.length === 0) { + const model = state.model ?? state.defaultModel + if (!model || state.variants.length === 0) { return { status: "no variants available", } @@ -347,11 +355,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } } + if (!state.model) state.model = model state.activeVariant = variant - void input.host.preferences.saveVariant(state.model, state.activeVariant) + void input.host.preferences.saveVariant(model, state.activeVariant) return { status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default", - modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers), + modelLabel: formatModelLabel(model, state.activeVariant, state.providers), variant: state.activeVariant, variants: state.variants, } @@ -443,6 +452,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep if (footer.isClosed || runtimeController.signal.aborted) return state.sessionID = next.sessionID state.sessionTitle = next.sessionTitle ?? state.sessionTitle + shell.setTitle(state.sessionTitle) state.agent = next.agent state.location = next.location state.model = next.model @@ -492,39 +502,20 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const signal = AbortSignal.any([runtimeController.signal, controller.signal]) modelAttempt = controller try { - if (selected) { - const info = await abortable(resolveModelInfo(sdk, state.location, signal), signal) - if ( - !info || - !currentModelLoad(generation, sdk) || - state.model?.providerID !== selected.providerID || - state.model.modelID !== selected.modelID - ) - return - applyModelInfo(info, session.variant, { sdk, generation, signal }, true, savedVariant) + const info = await abortable(resolveModelInfo(sdk, state.location, signal), signal) + if ( + !info || + !currentModelLoad(generation, sdk) || + (selected && + (state.model?.providerID !== selected.providerID || state.model.modelID !== selected.modelID)) + ) return - } - - const model = await waitForDefaultModel({ - sdk, - location: state.location, - active: () => currentModelLoad(generation, sdk), - signal, - }) - if (!currentModelLoad(generation, sdk)) return - const [fallbackSavedVariant, info] = await Promise.all([ - input.host.preferences.resolveVariant(model), - abortable(resolveModelInfo(sdk, state.location, signal), signal), - ]) - if (!info || !currentModelLoad(generation, sdk)) return - if (model && !state.model) state.model = model - const boot = !!model && state.model?.providerID === model.providerID && state.model.modelID === model.modelID applyModelInfo( info, - boot ? session.variant : state.activeVariant, + selected ? session.variant : state.activeVariant, { sdk, generation, signal }, - boot, - fallbackSavedVariant, + !!selected, + savedVariant, ) } finally { if (modelAttempt === controller) modelAttempt = undefined @@ -620,7 +611,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep ) { if (!currentClient(attempt)) return state.providers = info.providers - state.variants = variantsFor(state.providers, state.model) + const model = state.model ?? state.defaultModel + state.variants = variantsFor(state.providers, model) state.activeVariant = boot ? resolveVariant(ctx.variant, current, saved, state.variants) : current && !state.variants.includes(current) @@ -629,11 +621,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep if (footer.isClosed) return footer.event({ type: "models", providers: info.providers }) footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) - if (state.model) + if (model) footer.event({ type: "model", - model: formatModelLabel(state.model, state.activeVariant, state.providers), - selection: state.model, + model: formatModelLabel(model, state.activeVariant, state.providers), + selection: model, }) } @@ -645,6 +637,50 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep task: Promise } | undefined + let defaultModelLoad: Promise | undefined + let defaultModelQueued = false + const loadDefaultModel = (attempt: ClientAttempt) => { + if (state.model || !currentClient(attempt)) return + if (defaultModelLoad) { + defaultModelQueued = true + return + } + defaultModelQueued = false + defaultModelLoad = attempt.sdk.model + .default( + { + location: { + directory: state.location.directory, + workspace: state.location.workspaceID, + }, + }, + { signal: attempt.signal }, + ) + .then(async (result) => { + if (!result.data || state.model || !currentClient(attempt)) return + const model = { providerID: result.data.providerID, modelID: result.data.id } + const changed = + state.defaultModel?.providerID !== model.providerID || state.defaultModel.modelID !== model.modelID + const saved = changed ? await input.host.preferences.resolveVariant(model) : undefined + if (state.model || !currentClient(attempt)) return + state.defaultModel = model + state.variants = variantsFor(state.providers, model) + if (changed) + state.activeVariant = resolveVariant(ctx.variant, state.activeVariant, saved, state.variants) + if (state.activeVariant) state.model = model + footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) + footer.event({ + type: "model", + model: formatModelLabel(model, state.activeVariant, state.providers), + selection: model, + }) + }) + .catch(() => {}) + .finally(() => { + defaultModelLoad = undefined + if (defaultModelQueued) loadDefaultModel(clientAttempt()) + }) + } const requestCatalogRefresh = (signal?: AbortSignal): Promise => { const attempt = clientAttempt(signal) if (!currentClient(attempt)) return Promise.resolve() @@ -676,6 +712,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep if (!currentClient(attempt)) return if (catalog) applyCatalog(catalog, attempt) if (info) applyModelInfo(info, state.activeVariant, attempt) + loadDefaultModel(attempt) } })() refresh.task = task @@ -760,6 +797,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep replayLimit: input.replayLimit, footer, onCommit: rememberLocal, + onSessionTitle: (title) => { + state.sessionTitle = title + shell.setTitle(title) + }, trace: log, onCatalogRefresh: requestCatalogRefresh, contextLimit: (model) => @@ -876,6 +917,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) }, onAdmissionError: renderPromptError, + onCompact: async () => { + await state.sdk.session.compact({ sessionID: state.sessionID }, formRequestOptions(state.location)) + }, settle: async () => { const next = await ensureStream() await next.handle.waitForIdle() @@ -903,6 +947,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep state.shown = false state.sessionID = created.sessionID state.sessionTitle = created.sessionTitle + shell.setTitle(state.sessionTitle) state.agent = created.agent ?? state.agent state.location = created.location state.model = created.model diff --git a/packages/tui/src/mini/scrollback.surface.ts b/packages/tui/src/mini/scrollback.surface.ts index 293335238f..9d81c2bb98 100644 --- a/packages/tui/src/mini/scrollback.surface.ts +++ b/packages/tui/src/mini/scrollback.surface.ts @@ -14,6 +14,7 @@ import { type ScrollbackSurface, } from "@opentui/core" import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" +import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { turnSummaryCommit } from "./turn-summary" import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer" @@ -179,7 +180,8 @@ export class RunScrollbackStream { width: "100%", streaming: true, internalBlockMode: "top-level", - tableOptions: { widthMode: "content" }, + tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" }, + renderNode: this.mono ? monoMarkdownRenderNode : undefined, fg: entryColor(commit, this.theme), treeSitterClient, }) @@ -281,7 +283,7 @@ export class RunScrollbackStream { } const renderable = active.renderable - renderable.content = active.content + renderable.content = monoMarkdown(active.content, this.mono) renderable.streaming = !done await active.surface.settle() this.releasePendingThemes() @@ -395,6 +397,7 @@ export class RunScrollbackStream { commit, body: staticBody(commit, body, spaced), theme: this.theme, + opts: { mono: this.mono }, }), ) this.markRendered(commit) diff --git a/packages/tui/src/mini/scrollback.writer.tsx b/packages/tui/src/mini/scrollback.writer.tsx index 436397248a..c74f09168b 100644 --- a/packages/tui/src/mini/scrollback.writer.tsx +++ b/packages/tui/src/mini/scrollback.writer.tsx @@ -2,6 +2,7 @@ import { createScrollbackWriter } from "@opentui/solid" import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core" import { Match, Switch, createMemo } from "solid-js" import { entryBody, entryFlags } from "./entry.body" +import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" @@ -30,7 +31,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm return Boolean(current && next && current === next) } -export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout { +function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout { if (commit.kind === "tool") { if (body.type === "structured" || body.type === "markdown") { return "block" @@ -239,9 +240,10 @@ export function RunEntryContent(props: { width="100%" syntaxStyle={syntax()} streaming={streaming()} - content={markdown()!.content} + content={monoMarkdown(markdown()!.content, props.opts?.mono === true)} fg={color()} - tableOptions={{ widthMode: "content" }} + tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }} + renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined} /> diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index 164aa9a179..ea3b566af4 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -194,7 +194,6 @@ function tab(child: ChildState): FooterSubagentTab { status: child.status, background: child.background ? true : undefined, title: child.title, - lastUpdatedAt: child.lastUpdatedAt, } } @@ -838,8 +837,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac ? { status: "error", input: part && part.state.status !== "streaming" ? part.state.input : {}, - structured: part && part.state.status !== "streaming" ? part.state.structured : {}, - content: part && part.state.status !== "streaming" ? part.state.content : [], + structured: + event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}), + content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []), error: event.data.error, result: event.data.result, } @@ -958,15 +958,16 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac if (pendingCalls.has(key)) pendingCalls.set(key, event.data.input) return } - if (event.type === "session.tool.failed") { - pendingCalls.delete(sourceKey(event.data.assistantMessageID, event.data.callID)) + if ( + event.type !== "session.tool.progress" && + event.type !== "session.tool.success" && + event.type !== "session.tool.failed" + ) return - } - if (event.type !== "session.tool.progress" && event.type !== "session.tool.success") return const key = sourceKey(event.data.assistantMessageID, event.data.callID) const pending = pendingCalls.get(key) - if (event.type === "session.tool.success") pendingCalls.delete(key) - const found = childSessionID(record(event.data.structured)) + if (event.type !== "session.tool.progress") pendingCalls.delete(key) + const found = childSessionID(record(event.type === "session.tool.failed" ? event.data.metadata : event.data.structured)) if (!found) return const child = admitChild(found.sessionID) if (!child) return @@ -1084,11 +1085,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input.emit() }, snapshot() { - const tabs = [...children.values()].map(tab).toSorted((a, b) => { + const tabs = [...children.values()].toSorted((a, b) => { const active = Number(b.status === "running") - Number(a.status === "running") if (active !== 0) return active return b.lastUpdatedAt - a.lastUpdatedAt - }) + }).map(tab) const child = selected ? children.get(selected) : undefined const details: Record = child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {} diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index b90f5e682d..496bda83b3 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -46,6 +46,7 @@ type StreamInput = { replayLimit?: number footer: FooterApi onCommit?: (commit: StreamCommit) => void + onSessionTitle?: (title: string) => void trace?: Trace signal?: AbortSignal onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise @@ -379,7 +380,19 @@ async function resolveSelectedModel( .then((response) => response.model) if (session) return { ...session, variant: next.variant } - const fallback = await sdk.model.default(undefined, { signal: next.signal }).then((response) => response.data) + const fallback = await sdk.model + .default( + input.location + ? { + location: { + directory: input.location.directory, + workspace: input.location.workspaceID, + }, + } + : undefined, + { signal: next.signal }, + ) + .then((response) => response.data) if (!fallback) return return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } } @@ -828,6 +841,10 @@ export async function createSessionTransport(input: StreamInput): Promise +type ToolDict = Record type PatchFile = { status?: string @@ -49,6 +48,7 @@ type PatchFile = { } type ToolInput = ToolDict & { + id?: string path?: string pattern?: string url?: string @@ -67,6 +67,7 @@ type ToolInput = ToolDict & { } type ToolMetadata = ToolDict & { + name?: string count?: number matches?: number diff?: string @@ -76,7 +77,7 @@ type ToolMetadata = ToolDict & { exit?: number } -export type ToolFrame = { +type ToolFrame = { directory?: string raw: string name: string @@ -92,7 +93,7 @@ export type ToolFrame = { } } -export type ToolInline = { +type ToolInline = { icon: string title: string description?: string @@ -100,7 +101,7 @@ export type ToolInline = { body?: string } -export type ToolProps = { +type ToolProps = { input: ToolInput metadata: ToolMetadata frame: ToolFrame @@ -164,7 +165,7 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri function normalizeInput(name: string, value: unknown) { const input = dict(value) - const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath) + const path = typeof input.path === "string" ? input.path : text(input.filePath) const agent = typeof input.agent === "string" ? input.agent : text(input.subagent_type) return { ...input, @@ -189,7 +190,7 @@ function normalizeFile(value: unknown): PatchFile | undefined { : legacy === "move" ? "moved" : legacy) - const patch = typeof file.patch === "string" ? file.patch : text(file.diff) || undefined + const patch = typeof file.patch === "string" ? file.patch : undefined const deletions = finiteNumber(file.deletions) return { ...file, @@ -212,11 +213,6 @@ function normalizeStructured(name: string, value: unknown) { ...structured, ...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}), ...(name === "subagent" && sessionID ? { sessionID } : {}), - ...(name === "shell" && - finiteNumber(structured.exit) === undefined && - finiteNumber(structured.exitCode) !== undefined - ? { exit: finiteNumber(structured.exitCode) } - : {}), } } @@ -416,9 +412,10 @@ function runTask(p: ToolProps): ToolInline { } function runSkill(p: ToolProps): ToolInline { + const name = p.metadata.name ?? p.input.id ?? "" return { icon: "→", - title: `Skill "${p.input.name ?? ""}"`, + title: `Skill "${name}"`, } } @@ -671,7 +668,7 @@ function scrollShellFinal(p: ToolProps): string { return fail(p.frame) } - const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code) + const code = p.metadata.exit const time = span(p.frame) if (code === undefined) { if (!time) { @@ -819,7 +816,7 @@ function scrollLspStart(p: ToolProps): string { } function scrollSkillStart(p: ToolProps): string { - return `→ Skill "${p.input.name ?? ""}"` + return `→ Skill "${p.metadata.name ?? p.input.id ?? ""}"` } function scrollGlobStart(p: ToolProps): string { @@ -1110,7 +1107,7 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame } } -export function toolFrame(commit: StreamCommit, raw: string): ToolFrame { +function toolFrame(commit: StreamCommit, raw: string): ToolFrame { const current = commit.part ? frame(commit.part, commit.directory) : undefined return { directory: commit.directory, @@ -1195,7 +1192,7 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string { return fallbackFinal(ctx) } -export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined { +function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined { const ctx = toolFrame(commit, raw) const draw = rule(ctx.name)?.snap if (!draw) { diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 2ed619abe4..2ee854010b 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -53,7 +53,7 @@ export type RunCommand = { source?: string } -export type RunProviderModel = { +type RunProviderModel = { name?: string cost?: { input: number @@ -94,6 +94,7 @@ export type FooterQueuedPrompt = { export type RunAgent = { id: string name: string + description?: string mode: "subagent" | "primary" | "all" hidden: boolean } @@ -159,13 +160,14 @@ export type MiniHost = { export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error" // Whether the assistant is actively processing a turn. -export type FooterPhase = "idle" | "running" +type FooterPhase = "idle" | "running" // Full snapshot of footer status bar state. Every update replaces the whole // object in the SolidJS signal so the view re-renders atomically. export type FooterState = { phase: FooterPhase status: string + notice: string model: string usage: string first: boolean @@ -188,14 +190,14 @@ export type ScrollbackOptions = { mono?: boolean } -export type ToolCodeSnapshot = { +type ToolCodeSnapshot = { kind: "code" title: string content: string file?: string } -export type ToolDiffSnapshot = { +type ToolDiffSnapshot = { kind: "diff" items: Array<{ title: string @@ -205,14 +207,14 @@ export type ToolDiffSnapshot = { }> } -export type ToolTaskSnapshot = { +type ToolTaskSnapshot = { kind: "task" title: string rows: string[] tail: string } -export type ToolQuestionSnapshot = { +type ToolQuestionSnapshot = { kind: "question" items: Array<{ question: string @@ -223,15 +225,7 @@ export type ToolQuestionSnapshot = { export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot -export type MiniToolState = - | { status: "pending"; input: Record; raw?: string } - | { - status: "running" - input: Record - title?: string - metadata?: Record - time: { start: number } - } +type MiniToolState = | { status: "completed" input: Record @@ -293,6 +287,7 @@ export type FooterPromptRoute = | { type: "subagent"; sessionID: string } | { type: "command" } | { type: "skill" } + | { type: "agent" } | { type: "model" } | { type: "variant" } | { type: "settings" } @@ -304,7 +299,6 @@ export type FooterSubagentTab = { status: "running" | "completed" | "cancelled" | "error" background?: boolean title?: string - lastUpdatedAt: number } export type FooterSubagentDetail = { @@ -392,12 +386,14 @@ export type FormCancel = { location?: LocationRef } -export type RunTuiConfig = Pick +export type RunTuiConfig = Pick export type MiniSettings = { thinking: "show" | "hide" shell_output: "show" | "hide" turn_summary: "show" | "hide" + footer: "show" | "hide" + splash: "show" | "hide" mono: boolean } @@ -407,11 +403,11 @@ export type MiniSettingChange = { // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. -export type StreamPhase = "start" | "progress" | "final" +type StreamPhase = "start" | "progress" | "final" -export type StreamSource = "assistant" | "reasoning" | "tool" | "system" +type StreamSource = "assistant" | "reasoning" | "tool" | "system" -export type StreamToolState = "running" | "completed" | "error" +type StreamToolState = "running" | "completed" | "error" // A single append-only commit to scrollback. The transport produces these from // V2 events, and RunFooter.append() queues them for the next diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx index 428ef80ecf..4a8ac9ed8f 100644 --- a/packages/tui/src/routes/session/dialog-fork.tsx +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -16,7 +16,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov const client = useClient() const route = useRoute() const toast = useToast() - const [pending, setPending] = createSignal(false) + const [pending, setPending] = createSignal(!!props.messageID) const fork = async (messageID?: string) => { setPending(true) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d2f3543c0e..69c9682ec1 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -22,7 +22,7 @@ import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner, SPINNER_FRAMES } from "../../component/spinner" -import { useTheme } from "../../context/theme" +import { ThemeContextProvider, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { @@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" +import { useArgs } from "../../context/args" addDefaultParsers(parsers.parsers) @@ -120,6 +121,7 @@ export function Session() { const { navigate } = useRoute() const data = useData() const local = useLocal() + const args = useArgs() const paths = useTuiPaths() const configState = useConfig() const config = configState.data @@ -216,6 +218,7 @@ export function Session() { const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) + const [synced, setSynced] = createSignal(false) const clearMessageNavigation = () => { setNavigationSlack(0) @@ -242,6 +245,7 @@ export function Session() { createEffect(() => { if (client.connection.status() !== "connected") return + setSynced(false) const sessionID = route.sessionID void (async () => { await Promise.all([ @@ -261,6 +265,7 @@ export function Session() { } editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + setSynced(true) })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -273,15 +278,25 @@ export function Session() { }) let seeded = false + let sent = false let scroll: ScrollBoxRenderable - let prompt: PromptRef | undefined + const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { - prompt = r + setPrompt(r) promptRef.set(r) if (seeded || !route.prompt || !r) return seeded = true r.set(route.prompt) } + + createEffect(() => { + const current = prompt() + if (sent || !current || !synced() || !local.model.ready) return + if (!local.agent.current() || !local.model.current()) return + if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return + sent = true + current.submit() + }) const dialog = useDialog() const renderer = useRenderer() const unavailable = (feature: string) => { @@ -526,7 +541,7 @@ export function Session() { void client.api.session.revert .stage({ sessionID: route.sessionID, messageID: message.id }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) - prompt?.set({ + prompt()?.set({ ...projectedPromptInput(message), pasted: [], }) @@ -747,23 +762,6 @@ export function Session() { options.format === "markdown" ? formatSessionTranscript(sessionData, messages(), options.thinking) : await (async () => { - if (options.debug) { - const events: { readonly created: number }[] = [] - for await (const event of client.api.session.log({ sessionID: sessionData.id, follow: false })) { - if (event.type !== "log.synced") events.push(event) - } - // Durable events stay in aggregate order even when their wall-clock timestamps differ. - client.connection.internal.history().forEach((event) => { - const index = events.findIndex((item) => item.created > event.created) - if (index === -1) { - events.push(event) - return - } - events.splice(index, 0, event) - }) - return JSON.stringify({ info: sessionData, events }, null, 2) + EOL - } - const messages: unknown[] = [] let cursor: string | undefined do { @@ -2352,15 +2350,26 @@ function StatusBadge(props: { children: string }) { ) } -function BlockTool(props: { +type BlockToolProps = { title?: string path?: { label: string; value: string } children?: JSX.Element onClick?: () => void part?: SessionMessageAssistantTool spinner?: boolean -}) { - const { themeV2 } = useTheme().contextual("elevated") +} + +function BlockTool(props: BlockToolProps) { + const parentTheme = useTheme() + return ( + + + + ) +} + +function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) { + const { themeV2 } = useTheme() const ctx = use() const data = useData() const renderer = useRenderer() @@ -2380,7 +2389,7 @@ function BlockTool(props: { gap={1} backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default} customBorderChars={SplitBorder.customBorderChars} - borderColor={themeV2.background.default} + borderColor={props.borderColor} onMouseOver={() => props.onClick && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -2643,8 +2652,7 @@ function WebFetch(props: ToolProps) { function WebSearch(props: ToolProps) { return ( - {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "} - ({finiteNumber(props.metadata.numResults)} results) + {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}" ) } diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index 104aa04be2..13d67d5808 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json" export type DialogExportOptionsProps = { defaultThinking: boolean - onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; debug: boolean; thinking: boolean }) => void + onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void onCancel?: () => void } -type Active = ExportFormat | "debug" | "thinking" | "copy" | "export" +type Active = ExportFormat | "thinking" | "copy" | "export" export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() @@ -21,7 +21,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const { themeV2: overlayTheme } = useTheme().contextual("overlay") const [store, setStore] = createStore({ format: "markdown" as ExportFormat, - debug: false, thinking: props.defaultThinking, active: "markdown" as Active, }) @@ -30,7 +29,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { props.onConfirm?.({ action, format: store.format, - debug: store.debug, thinking: store.thinking, }) @@ -39,7 +37,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { setStore("format", store.active) return } - if (store.active === "debug") setStore("debug", !store.debug) if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "copy" || store.active === "export") confirm(store.active) } @@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const order: Active[] = store.format === "markdown" ? ["markdown", "json", "thinking", "copy", "export"] - : ["markdown", "json", "debug", "copy", "export"] + : ["markdown", "json", "copy", "export"] setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, }, @@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { - - { - setStore("active", "debug") - setStore("debug", !store.debug) - }} - > - - {store.debug ? "[x]" : "[ ]"} - - - Events (debug) - - - return new Promise<{ action: "copy" | "export" format: ExportFormat - debug: boolean thinking: boolean } | null>((resolve) => { dialog.replace( diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 7c6584f250..a07e3e6b12 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -28,6 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const { run } = await import("../src/app") const task = Effect.runPromise( run({ + app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, packages: { resolve: async () => undefined }, @@ -68,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after setTitle(title) } const events = createEventStream() + let promptRequests = 0 const calls = createFetch((url) => { const session = { id: "dummy", @@ -87,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/prompt") { + promptRequests++ + return json({ data: {} }) + } }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) @@ -100,6 +106,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after const { run } = await import("../src/app") const task = Effect.runPromise( run({ + app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, packages: { resolve: async () => undefined }, @@ -122,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode2 -s dummy") + expect(promptRequests).toBe(0) } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -129,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after mock.restore() } }) + +test("session startup prompt is submitted exactly once", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventStream() + const cwd = process.cwd() + const location = { directory: cwd, project: { id: "project", directory: cwd } } + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory: cwd }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const bodies: unknown[] = [] + const promptSubmitted = Promise.withResolvers() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/agent") + return json({ + location, + data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], + }) + if (url.pathname === "/api/model") + return json({ + location, + data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], + }) + if (url.pathname === "/api/session/dummy/prompt") { + bodies.push(await request.json()) + promptSubmitted.resolve() + return json({ data: {} }) + } + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: { sessionID: "dummy", prompt: "RESUME_READY" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await Promise.race([ + promptSubmitted.promise, + Bun.sleep(2000).then(() => { + throw new Error("startup prompt was not submitted") + }), + ]) + await Bun.sleep(20) + setup.renderer.destroy() + await task + + expect(bodies).toHaveLength(1) + expect(bodies[0]).toMatchObject({ text: "RESUME_READY" }) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +}) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d392704dc4..88b2175f26 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2332,7 +2332,6 @@ test("settles pending tools when a live failure arrives", async () => { id: "evt_progress_1", created: 0, type: "session.tool.progress", - durable: durable("session-1", 5), data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", diff --git a/packages/tui/test/devtools.test.ts b/packages/tui/test/devtools.test.ts deleted file mode 100644 index 5997c8aa4e..0000000000 --- a/packages/tui/test/devtools.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test } from "bun:test" -import { DevTools } from "../src/devtools" - -test("registers and updates grouped DevTools data", () => { - const group = DevTools.register({ id: "test", title: "Test data" }) - - group.set("Duration", "1.00 ms") - group.set("Duration", "2.00 ms") - group.set("Count", 2) - - expect(DevTools.data().find((item) => item.id === "test")).toEqual({ - id: "test", - title: "Test data", - entries: [ - { key: "Duration", value: "2.00 ms" }, - { key: "Count", value: 2 }, - ], - }) -}) diff --git a/packages/tui/test/mini/catalog.shared.test.ts b/packages/tui/test/mini/catalog.shared.test.ts index 334454c3aa..fbfdf40089 100644 --- a/packages/tui/test/mini/catalog.shared.test.ts +++ b/packages/tui/test/mini/catalog.shared.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" -import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared" +import { loadRunReferences, runProviders } from "../../src/mini/catalog.shared" import { catalogModel, catalogProvider } from "./fixture/catalog" afterEach(() => { @@ -8,26 +8,6 @@ afterEach(() => { }) describe("run catalog shared", () => { - test("resolves the catalog-selected model for the footer", async () => { - const client = OpenCode.make({ baseUrl: "https://opencode.test" }) - const selected = spyOn(client.model, "default").mockImplementation( - () => - Promise.resolve({ - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: { id: "gpt-5", providerID: "openai" }, - }) as never, - ) - - await expect(waitForDefaultModel({ sdk: client, location: { directory: "/tmp" } })).resolves.toEqual({ - providerID: "openai", - modelID: "gpt-5", - }) - expect(selected).toHaveBeenCalledWith( - { location: { directory: "/tmp", workspace: undefined } }, - { signal: expect.any(AbortSignal) }, - ) - }) - test("loads visible project references from the current reference catalog", async () => { const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const list = spyOn(client.reference, "list").mockImplementation( diff --git a/packages/tui/test/mini/fixture/tui-runtime.ts b/packages/tui/test/mini/fixture/tui-runtime.ts deleted file mode 100644 index 0334ea5abe..0000000000 --- a/packages/tui/test/mini/fixture/tui-runtime.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { resolve, type Info, type Resolved } from "../../../src/config" -import { TuiKeybind } from "../../../src/config/keybind" - -type ResolvedInput = Omit & { - attention?: Partial - keybinds?: Partial - leader_timeout?: number -} - -export function createTuiResolvedConfig(input: ResolvedInput = {}) { - const { leader_timeout, ...current } = input - return resolve( - { - ...current, - leader: leader_timeout === undefined ? undefined : { timeout: leader_timeout }, - }, - { terminalSuspend: process.platform !== "win32" }, - ) -} diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index 4e1cf95ac6..cca3526576 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -12,6 +12,7 @@ test("down opens subagents from an empty prompt", async () => { const [state] = createSignal({ phase: "idle", status: "", + notice: "", model: "gpt-5", usage: "", first: false, @@ -26,7 +27,6 @@ test("down opens subagents from an empty prompt", async () => { label: "Explore", description: "Inspect the keymap", status: "running", - lastUpdatedAt: 1, }, ], details: {}, @@ -47,6 +47,8 @@ test("down opens subagents from an empty prompt", async () => { references={() => []} commands={() => []} providers={() => undefined} + currentAgent={() => "Build"} + currentAgentID={() => "build"} currentModel={() => undefined} variants={() => []} currentVariant={() => undefined} @@ -54,8 +56,14 @@ test("down opens subagents from an empty prompt", async () => { view={view} subagent={subagents} theme={() => RUN_THEME_FALLBACK} - tuiConfig={config} - miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })} + miniSettings={() => ({ + thinking: "hide", + shell_output: "hide", + turn_summary: "show", + footer: "show", + splash: "show", + mono: false, + })} mono={false} onSubmit={() => true} onPermissionReply={() => {}} @@ -66,6 +74,7 @@ test("down opens subagents from an empty prompt", async () => { onEditorOpen={async () => undefined} onInputClear={() => {}} onExit={() => {}} + onAgentSelect={() => {}} onModelSelect={() => {}} onVariantSelect={() => {}} onRows={() => {}} diff --git a/packages/tui/test/mini/footer.test.ts b/packages/tui/test/mini/footer.test.ts index e59813f9f7..8fbf7fc686 100644 --- a/packages/tui/test/mini/footer.test.ts +++ b/packages/tui/test/mini/footer.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" -import { coalesceProgressCommit } from "../../src/mini/footer" -import type { StreamCommit } from "../../src/mini/types" +import { coalesceProgressCommit, resolveRunAgent } from "../../src/mini/footer" +import type { RunAgent, StreamCommit } from "../../src/mini/types" function progress(input: Partial = {}): StreamCommit { return { @@ -23,3 +23,16 @@ test("coalesces progress only within the same message and tool state", () => { progress({ text: "onetwo", directory: "/latest" }), ) }) + +test("resolves the first selectable agent when none is selected", () => { + const agents: RunAgent[] = [ + { id: "task", name: "Task", mode: "subagent", hidden: false }, + { id: "secret", name: "Secret", mode: "primary", hidden: true }, + { id: "build", name: "Build", mode: "primary", hidden: false }, + { id: "plan", name: "Plan", mode: "primary", hidden: false }, + ] + + expect(resolveRunAgent(agents, undefined)?.id).toBe("build") + expect(resolveRunAgent(agents, "plan")?.id).toBe("plan") + expect(resolveRunAgent(agents, "missing")?.id).toBe("build") +}) diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index c77cc50c76..f86287124e 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -8,6 +8,7 @@ import { Keymap } from "../../src/context/keymap" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS, + RunAgentSelectBody, RunCommandMenuBody, RunModelSelectBody, RunQueuedPromptSelectBody, @@ -26,6 +27,7 @@ import type { FooterView, MiniSettingChange, MiniSettings, + RunAgent, RunCommand, RunInput, RunPrompt, @@ -35,7 +37,7 @@ import type { } from "../../src/mini/types" import { selectedCommand } from "../../src/mini/footer.prompt" import { RejectField } from "../../src/mini/footer.permission" -import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" const tuiConfig = createTuiResolvedConfig() @@ -87,7 +89,6 @@ function subagent(input: { label: input.label, description: input.description, status: input.status ?? "running", - lastUpdatedAt: 1, } satisfies FooterSubagentTab } @@ -95,13 +96,14 @@ function footerState(input: Partial = {}) { return createSignal({ phase: "idle", status: "", + notice: "", model: "gpt-5", usage: "", first: false, interrupt: 0, exit: 0, ...input, - })[0] + }) } async function renderFooter( @@ -110,6 +112,7 @@ async function renderFooter( commands?: RunCommand[] theme?: () => RunTheme providers?: RunProvider[] + currentAgent?: string currentModel?: RunInput["model"] currentVariant?: string subagents?: FooterSubagentState @@ -122,6 +125,7 @@ async function renderFooter( onFormReply?: (input: unknown) => void miniSettings?: MiniSettings mono?: boolean + onStatus?: (status: string) => void onMiniSettingChange?: (change: MiniSettingChange) => void } = {}, ) { @@ -129,10 +133,17 @@ async function renderFooter( const [subagents] = createSignal( input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] }, ) - const state = footerState(input.state) + const [state, setState] = footerState(input.state) const config = input.tuiConfig ?? tuiConfig const [miniSettings] = createSignal( - input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false }, + input.miniSettings ?? { + thinking: "hide", + shell_output: "hide", + turn_summary: "show", + footer: "show", + splash: "show", + mono: false, + }, ) function Harness() { return ( @@ -144,6 +155,8 @@ async function renderFooter( references={() => []} commands={() => input.commands ?? []} providers={() => input.providers} + currentAgent={() => input.currentAgent ?? "Build"} + currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"} currentModel={() => input.currentModel} variants={() => []} currentVariant={() => input.currentVariant} @@ -152,7 +165,6 @@ async function renderFooter( subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} mono={input.mono ?? false} - tuiConfig={config} miniSettings={miniSettings} onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} @@ -163,11 +175,12 @@ async function renderFooter( onEditorOpen={async () => undefined} onInputClear={() => {}} onExit={() => {}} + onAgentSelect={() => {}} onModelSelect={() => {}} onVariantSelect={() => {}} onRows={() => {}} onLayout={() => {}} - onStatus={() => {}} + onStatus={(status) => input.onStatus?.(status)} onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)} /> @@ -186,6 +199,7 @@ async function renderFooter( return { ...app, setView, + setState, cleanup() { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() @@ -194,6 +208,16 @@ async function renderFooter( } } +test("direct footer shows the generic default model before resolution", async () => { + const app = await renderFooter({ state: { model: "Default model" } }) + try { + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Default model") + } finally { + app.cleanup() + } +}) + test("direct footer preserves a partial multi-field form draft across permission preemption", async () => { const request: FormInfo = { id: "frm_preempted", @@ -354,7 +378,7 @@ test("run entry content updates when live commit text changes", async () => { } }) -test("direct command panel renders grouped command palette", async () => { +test("direct command panel renders grouped actions without catalog commands", async () => { const [commands] = createSignal([ command({ name: "review", description: "Review code" }), command({ name: "deploy", description: "Deploy prompt", source: "mcp" }), @@ -362,6 +386,7 @@ test("direct command panel renders grouped command palette", async () => { ]) const [subagents] = createSignal([]) const [variants] = createSignal(["high", "minimal"]) + let status = 0 const app = await testRender( () => ( @@ -374,6 +399,7 @@ test("direct command panel renders grouped command palette", async () => { variants={variants} variantCycle="ctrl+t" onClose={() => {}} + onAgent={() => {}} onModel={() => {}} onEditor={() => {}} onSkill={() => {}} @@ -381,6 +407,7 @@ test("direct command panel renders grouped command palette", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onStatus={() => status++} onSettings={() => {}} onCommand={() => {}} onNew={() => {}} @@ -406,7 +433,9 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).toContain("Prompt") expect(frame).toContain("Open editor") expect(frame).toContain("/editor") - expect(frame).toContain("Switch model") + expect(frame).toContain("Show status") + expect(frame).toContain("Compact session") + expect(frame).toContain("/compact") expect(frame).toContain("Skills") expect(frame).toContain("/skills") expect(frame.match(/\bAgent\b/g)?.length).toBe(1) @@ -417,6 +446,27 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).not.toContain("Cycle reasoning effort for future turns") expect(frame).not.toContain("Review code") expect(frame).not.toContain("Commands 8") + + await app.mockInput.typeText("agent") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Switch agent") + + app.mockInput.pressKey("u", { ctrl: true }) + await app.mockInput.typeText("review") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("No results found") + + app.mockInput.pressKey("u", { ctrl: true }) + await app.mockInput.typeText("deploy") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("No results found") + + app.mockInput.pressKey("u", { ctrl: true }) + await app.mockInput.typeText("status") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Show status") + app.mockInput.pressEnter() + expect(status).toBe(1) } finally { app.renderer.destroy() } @@ -427,6 +477,8 @@ test("direct settings panel changes Mini transcript preferences", async () => { thinking: "hide", shell_output: "hide", turn_summary: "show", + footer: "show", + splash: "show", mono: false, }) const app = await testRender( @@ -454,6 +506,8 @@ test("direct settings panel changes Mini transcript preferences", async () => { expect(frame).toContain("Thinking") expect(frame).toContain("Shell") expect(frame).toContain("Turn summary") + expect(frame).toContain("Footer details") + expect(frame).toContain("Splash") expect(frame).toContain("Monochrome UI") expect(frame).toContain("left/right change") expect(frame).not.toMatch(/[^\x00-\x7F]/) @@ -461,14 +515,34 @@ test("direct settings panel changes Mini transcript preferences", async () => { app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() - expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", mono: false }) + expect(settings()).toEqual({ + thinking: "show", + shell_output: "hide", + turn_summary: "show", + footer: "show", + splash: "show", + mono: false, + }) app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() - expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", mono: false }) + expect(settings()).toEqual({ + thinking: "show", + shell_output: "hide", + turn_summary: "hide", + footer: "show", + splash: "show", + mono: false, + }) + + app.mockInput.pressKey("ARROW_DOWN") + app.mockInput.pressKey("ARROW_DOWN") + app.mockInput.pressKey("ARROW_RIGHT") + await app.renderOnce() + expect(settings().splash).toBe("hide") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") @@ -582,6 +656,7 @@ test("direct command panel shows subagent entry when available", async () => { variants={variants} variantCycle="ctrl+t" onClose={() => {}} + onAgent={() => {}} onModel={() => {}} onEditor={() => {}} onSkill={() => {}} @@ -589,6 +664,7 @@ test("direct command panel shows subagent entry when available", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onStatus={() => {}} onSettings={() => {}} onCommand={() => {}} onNew={() => {}} @@ -631,6 +707,7 @@ test("direct command panel keeps completed subagents available", async () => { variants={variants} variantCycle="ctrl+t" onClose={() => {}} + onAgent={() => {}} onModel={() => {}} onEditor={() => {}} onSkill={() => {}} @@ -638,6 +715,7 @@ test("direct command panel keeps completed subagents available", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onStatus={() => {}} onSettings={() => {}} onCommand={() => {}} onNew={() => {}} @@ -960,27 +1038,17 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async ( } }) -test("selectedCommand backfills the catalog source for bound drafts", () => { - const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })] - - // The skill picker binds `/name ` drafts; older drafts may lack source. - expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({ - name: "opencode-ts", - arguments: "fix it", - source: "skill", - }) - // An explicit source wins without a catalog lookup. +test("selectedCommand validates the bound command and refreshes its arguments", () => { expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({ name: "opencode-ts", arguments: "", source: "skill", }) - // Plain commands stay untagged. - expect( - selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [ - command({ name: "deploy", description: "Deploy" }), - ]), - ).toEqual({ name: "deploy", arguments: "prod" }) + expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({ + name: "deploy", + arguments: "prod", + }) + expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined() }) test("direct footer tags skill slash submissions with their catalog source", async () => { @@ -1085,6 +1153,7 @@ test("direct footer shows authoritative pending work while running", async () => const [state] = createSignal({ phase: "running", status: "", + notice: "", model: "gpt-5", usage: "", first: false, @@ -1108,6 +1177,8 @@ test("direct footer shows authoritative pending work while running", async () => references={() => []} commands={() => []} providers={() => undefined} + currentAgent={() => "Build"} + currentAgentID={() => "build"} currentModel={() => ({ providerID: "opencode", modelID: "a-model-name-long-enough-to-force-responsive-truncation", @@ -1126,8 +1197,14 @@ test("direct footer shows authoritative pending work while running", async () => }, ]} theme={() => RUN_THEME_FALLBACK} - tuiConfig={tuiConfig} - miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })} + miniSettings={() => ({ + thinking: "hide", + shell_output: "hide", + turn_summary: "show", + footer: "show", + splash: "show", + mono: false, + })} mono={false} onSubmit={() => true} onPermissionReply={() => {}} @@ -1138,6 +1215,7 @@ test("direct footer shows authoritative pending work while running", async () => onEditorOpen={async () => undefined} onInputClear={() => {}} onExit={() => {}} + onAgentSelect={() => {}} onModelSelect={() => {}} onVariantSelect={() => {}} onRows={() => {}} @@ -1196,12 +1274,14 @@ test("direct footer shows authoritative pending work while running", async () => test("direct footer progressively adds model details after the command hint", async () => { for (const expected of [ - { width: 24, model: false, variant: false }, - { width: 32, model: true, variant: false }, - { width: 40, model: true, variant: true }, + { width: 24, agent: false, model: false, variant: false }, + { width: 32, agent: false, model: true, variant: false }, + { width: 40, agent: false, model: true, variant: true }, + { width: 80, agent: true, model: true, variant: true }, ]) { const app = await renderFooter({ providers: [provider()], + currentAgent: "Plan", currentModel: { providerID: "opencode", modelID: "gpt-5" }, currentVariant: "xhigh", width: expected.width, @@ -1213,6 +1293,7 @@ test("direct footer progressively adds model details after the command hint", as expect({ width: expected.width, command: frame.includes("ctrl+p cmd"), + agent: frame.includes("Plan"), model: frame.includes("GPT-5"), variant: frame.includes("xhigh"), }).toEqual({ ...expected, command: true }) @@ -1301,6 +1382,58 @@ test("direct footer shows full usage metadata when room is available", async () } }) +test("direct footer hides routine activity and shows explicit notices", async () => { + let status = "" + const app = await renderFooter({ + state: { usage: "159.6K (16%) · $4.23" }, + currentAgent: "Plan", + miniSettings: { + thinking: "hide", + shell_output: "hide", + turn_summary: "show", + footer: "hide", + splash: "show", + mono: true, + }, + mono: true, + onStatus: (value) => (status = value), + width: 160, + }) + + try { + await app.renderOnce() + const initial = app.captureCharFrame() + expect(initial).toContain("ctrl+p cmd") + expect(initial).not.toContain("Plan") + expect(initial).not.toContain("gpt-5") + expect(initial).not.toContain("159.6K") + + app.setState((state) => ({ ...state, phase: "running", status: "assistant responding" })) + await app.renderOnce() + const changed = app.captureCharFrame() + const statusline = footerStatusline(app.renderer.root) + + expect(changed).not.toContain("running") + expect(changed).not.toContain("assistant responding") + expect(changed).not.toContain("interrupt") + expect(changed).not.toContain("gpt-5") + expect(changed).not.toContain("159.6K") + expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined() + + app.mockInput.pressKey("p", { ctrl: true }) + await app.renderOnce() + await app.mockInput.typeText("status") + app.mockInput.pressEnter() + expect(status).toBe("running - agent Plan - gpt-5 - 159.6K (16%) - $4.23") + + app.setState((state) => ({ ...state, notice: "variant high" })) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("variant high") + } finally { + app.cleanup() + } +}) + test("direct footer does not label normal mode as build", async () => { const app = await renderFooter() @@ -1365,7 +1498,10 @@ test("direct permission rejection submits through keymap return binding", async }) test("direct model panel renders current model selector", async () => { - const [providers] = createSignal([provider()]) + const [providers] = createSignal([ + provider(), + { id: "openai", name: "OpenAI", models: { "gpt-5": model({ id: "gpt-5", name: "GPT-5" }) } }, + ]) const [current] = createSignal({ providerID: "opencode", modelID: "gpt-5" }) const app = await testRender( @@ -1402,6 +1538,61 @@ test("direct model panel renders current model selector", async () => { expect(frame).not.toContain("┃") expect(frame).not.toContain("Old Model") expectPaletteList(list, 2) + + "gpt-5".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + const search = app.captureCharFrame() + + expect(search.match(/GPT-5/g)).toHaveLength(2) + expect(search).toContain("opencode") + expect(search).toContain("OpenAI") + } finally { + app.renderer.destroy() + } +}) + +test("direct agent panel shows eligible agents and marks the current agent", async () => { + const [agents] = createSignal([ + { id: "build", name: "Build", description: "Build software", mode: "all", hidden: false }, + { id: "review", name: "Review", description: "Review changes", mode: "primary", hidden: false }, + { id: "explore", name: "Explore", mode: "subagent", hidden: false }, + { id: "secret", name: "Secret", mode: "all", hidden: true }, + ]) + const [current] = createSignal("review") + let selected: string | undefined + + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + agents={agents} + current={current} + onClose={() => {}} + onSelect={(agent) => (selected = agent)} + /> + + ), + { + width: 100, + height: RUN_COMMAND_PANEL_ROWS, + }, + ) + + try { + await app.renderOnce() + const frame = app.captureCharFrame() + + expect(frame).toContain("Select agent") + expect(frame).toContain("build") + expect(frame).toContain("review") + expect(frame).toContain("Review changes") + expect(frame).toContain("current") + expect(frame).not.toContain("explore") + expect(frame).not.toContain("secret") + + app.mockInput.pressEnter() + expect(selected).toBe("review") } finally { app.renderer.destroy() } diff --git a/packages/tui/test/mini/footer.width.test.ts b/packages/tui/test/mini/footer.width.test.ts index fa8ec5d31a..244ec83cb3 100644 --- a/packages/tui/test/mini/footer.width.test.ts +++ b/packages/tui/test/mini/footer.width.test.ts @@ -10,12 +10,14 @@ describe("run footer width", () => { const narrow = footerWidthPolicy(79) expect(narrow.dialog.narrow).toBe(true) expect(narrow.statusline.showActivityMeta).toBe(false) + expect(narrow.statusline.showAgent).toBe(false) expect(narrow.statusline.showContextHints).toBe(false) expect(narrow.statusline.contextHintLimit).toBe(0) const compact = footerWidthPolicy(80) expect(compact.dialog.narrow).toBe(false) expect(compact.statusline.showActivityMeta).toBe(true) + expect(compact.statusline.showAgent).toBe(true) expect(compact.statusline.showContextHints).toBe(true) expect(compact.statusline.contextHintLimit).toBe(1) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 3fbf558012..734d2a4923 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -3,7 +3,7 @@ import { OpenCode } from "@opencode-ai/client/promise" import type { Resolved } from "../../src/config" import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot" import { catalogModel, catalogProvider } from "./fixture/catalog" -import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" function config(input?: { leader?: string @@ -21,7 +21,7 @@ function config(input?: { }): Resolved { const bind = input?.bindings return createTuiResolvedConfig({ - leader_timeout: input?.leaderTimeout, + leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout }, keybinds: { ...(input?.leader && { leader: input.leader }), ...(bind?.commandList && { command_list: bind.commandList }), @@ -95,26 +95,37 @@ describe("run runtime boot", () => { const result = await resolveRunTuiConfig( createTuiResolvedConfig({ theme: { mode: "light" }, - leader_timeout: 450, - session: { thinking: "show" }, + leader: { timeout: 450 }, }), ) expect(result.theme).toEqual({ mode: "light" }) expect(result.leader.timeout).toBe(450) - expect(result.session?.thinking).toBe("show") expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", turn_summary: "show", + footer: "show", + splash: "show", mono: false, }) expect( - resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide", mono: true } }), + resolveMiniSettings({ + mini: { + thinking: "show", + shell_output: "show", + turn_summary: "hide", + footer: "hide", + splash: "hide", + mono: true, + }, + }), ).toEqual({ thinking: "show", shell_output: "show", turn_summary: "hide", + footer: "hide", + splash: "hide", mono: true, }) }) diff --git a/packages/tui/test/mini/runtime.queue.test.ts b/packages/tui/test/mini/runtime.queue.test.ts index 78ce2913c5..c7fe59fba8 100644 --- a/packages/tui/test/mini/runtime.queue.test.ts +++ b/packages/tui/test/mini/runtime.queue.test.ts @@ -80,6 +80,63 @@ describe("run runtime queue", () => { ]) }) + test.each(["/compact", "/summarize"])("treats %s as a local compaction command", async (command) => { + const ui = createFooterApiFixture() + const seen: string[] = [] + let compacted = 0 + + const task = runPromptQueue({ + footer: ui.api, + onCompact: async () => { + compacted += 1 + }, + run: async (input) => { + seen.push(input.text) + ui.api.close() + }, + }) + + ui.submit(command) + ui.submit("hello") + await task + + expect(compacted).toBe(1) + expect(seen).toEqual(["hello"]) + expect(ui.commits.map((item) => item.text)).toEqual(["hello"]) + }) + + test("keeps prompts submitted after an in-flight /compact behind the compaction barrier", async () => { + const ui = createFooterApiFixture() + const active = Promise.withResolvers() + const order: string[] = [] + + const task = runPromptQueue({ + footer: ui.api, + onCompact: async () => { + order.push("compact") + }, + admit: async (prompt) => { + order.push(`admit:${prompt.text}`) + }, + run: async (prompt) => { + order.push(`run:${prompt.text}`) + if (prompt.text === "first") await active.promise + if (prompt.text === "later") ui.api.close() + }, + }) + + ui.submit("first") + await Promise.resolve() + ui.submit("/compact") + ui.submit("later") + await Promise.resolve() + expect(order).toEqual(["run:first"]) + + active.resolve() + await task + expect(order).toEqual(["run:first", "compact", "run:later"]) + }) + test("shell mode submits /exit as a shell command", async () => { const ui = createFooterApiFixture() const seen: RunPrompt[] = [] diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index 2185aec127..e55e150bc0 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -5,7 +5,6 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle" import type { FooterEvent, MiniHost } from "../../src/mini/types" import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog" import { createFooterApiFixture } from "./fixture/footer-api" -import { createTuiResolvedConfig } from "./fixture/tui-runtime" function defer() { let resolve!: (value: T | PromiseLike) => void @@ -49,6 +48,107 @@ afterEach(() => { }) describe("run interactive runtime", () => { + test("resolves the default model reactively without blocking catalog startup", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const events: FooterEvent[] = [] + const ui = createFooterApiFixture({ events }) + const api = ui.api + const selected = defer>>() + const catalogLoaded = defer() + const model = catalogModel({ + id: "resolved", + providerID: "test", + name: "Resolved Model", + variants: ["low", "high"], + }) + let lifecycle!: LifecycleInput + let turnAgent: string | undefined + let turnModel: { providerID: string; modelID: string } | undefined + let refreshCatalog: (() => Promise) | undefined + stubCatalogLists(sdk, { + providers: [catalogProvider("test", "Test Provider")], + models: [model], + }) + const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise) + + const task = runInteractiveDeferredMode( + { + host: host(), + sdk, + directory: "/tmp", + target: async () => ({ + sessionID: "ses_root", + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + agent: "build", + model: undefined, + variant: undefined, + resume: false, + }), + agent: "build", + model: undefined, + variant: undefined, + files: [], + }, + { + createRuntimeLifecycle: async (input) => { + lifecycle = input + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + setTitle: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + streamTransport: Promise.resolve({ + createSessionTransport: async (input) => { + refreshCatalog = () => Promise.resolve(input.onCatalogRefresh?.()) + await refreshCatalog() + catalogLoaded.resolve() + return { + runPromptTurn: async (input) => { + turnAgent = input.agent + turnModel = input.model + api.close() + }, + queuePromptTurn: async () => {}, + waitForIdle: async () => {}, + interruptActiveTurn: async () => {}, + selectSubagent: () => {}, + replayOnResize: async () => false, + close: async () => {}, + } + }, + formatUnknownError: (error: unknown) => String(error), + }), + }, + ) + + await catalogLoaded.promise + expect(events.some((event) => event.type === "model")).toBe(false) + await refreshCatalog?.() + expect(defaultModel).toHaveBeenCalledTimes(1) + selected.resolve({ + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + data: model, + }) + while (defaultModel.mock.calls.length < 2) await Bun.sleep(0) + while (!events.some((event) => event.type === "model")) await Bun.sleep(0) + expect(events).toContainEqual({ + type: "model", + model: "Resolved Model · Test Provider", + selection: { providerID: "test", modelID: "resolved" }, + }) + expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" }) + lifecycle.onAgentSelect?.("review") + ui.submit("hello") + while (!turnModel) await Bun.sleep(0) + expect(turnAgent).toBe("review") + expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" }) + await task + }) + test("routes form responses to their owners with global location and local settlement", async () => { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const api = footer() @@ -84,6 +184,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -180,6 +281,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -199,6 +301,7 @@ describe("run interactive runtime", () => { const lifecycleStarted = defer() const painted = defer() const events: FooterEvent[] = [] + const titles: Array = [] const api = footer(events) api.idle = () => painted.promise const event = api.event @@ -265,6 +368,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: (title) => titles.push(title), resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -282,6 +386,7 @@ describe("run interactive runtime", () => { history: [{ text: "previous prompt", parts: [] }], }) expect(events).toContainEqual({ type: "agent", agent: "review" }) + expect(titles).toEqual(["Resume"]) expect(events).toContainEqual({ type: "model", model: "Little Frank · OpenAI · high", @@ -344,6 +449,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: async (input) => { closedTitle = input.sessionTitle @@ -423,6 +529,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index 98182c7eda..5db71b1045 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/packages/tui/test/mini/scrollback.surface.test.ts @@ -1,7 +1,8 @@ import { afterEach, expect, test } from "bun:test" import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise" -import { RGBA, SyntaxStyle } from "@opentui/core" +import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" +import { monoSnapshot } from "../../src/mini/mono" import { RunScrollbackStream } from "../../src/mini/scrollback.surface" import { entryGroupKey } from "../../src/mini/scrollback.writer" import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme" @@ -67,6 +68,7 @@ async function setup( wrote?: boolean theme?: RunTheme onThemeRelease?: (theme: RunTheme) => void + mono?: boolean } = {}, ) { const out = await createTestRenderer({ @@ -77,6 +79,7 @@ async function setup( consoleMode: "disabled", }) active.push(out.renderer) + if (input.mono) out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot) const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) treeSitterClient.setMockResult({ highlights: [] }) @@ -87,6 +90,7 @@ async function setup( treeSitterClient, wrote: input.wrote ?? false, onThemeRelease: input.onThemeRelease, + mono: input.mono, }), } } @@ -203,6 +207,42 @@ test("theme swaps preserve streamed markdown parser state", async () => { } }) +test("renders monochrome scrollback as ASCII markdown", async () => { + const out = await setup({ mono: true, width: 60 }) + const output: string[] = [] + out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => { + output.push(decoder.decode(event.snapshot.getRealCharBytes(true))) + }) + + try { + await out.scrollback.append(assistant("# H")) + expect(Reflect.get(out.scrollback, "active")?.renderable).toBeInstanceOf(MarkdownRenderable) + await out.scrollback.append(assistant('éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |')) + await out.scrollback.complete() + out.renderer.writeToScrollback((ctx) => ({ + root: new TextRenderable(ctx.renderContext, { + content: "plain │ emoji 🙂", + width: ctx.width, + height: 1, + }), + width: ctx.width, + height: 1, + trailingNewline: false, + })) + + const rendered = output.join("").replace(/ +\n/g, "\n") + expect(rendered).toContain("# H?ading ->") + expect(rendered).toContain('| "quote"') + expect(rendered).toContain("------------------------------------------------------------") + expect(rendered).toContain("? ?") + expect(rendered).toContain("plain ? emoji ?") + expect(rendered).not.toMatch(/[^\x00-\x7f]/) + } finally { + out.scrollback.destroy() + destroy(claim(out.renderer)) + } +}) + function user(text: string): StreamCommit { return { kind: "user", diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 371611972e..f2c72ea6f0 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -200,6 +200,31 @@ afterEach(() => { }) describe("V2 mini transport", () => { + test("reports session title changes", async () => { + const events = feed() + events.push(connected()) + const titles: string[] = [] + const transport = await createSessionTransport({ + sdk: sdk({ streams: [events] }), + sessionID: "ses_1", + thinking: false, + footer: footer().api, + onSessionTitle: (title) => titles.push(title), + }) + + events.push({ + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: durable("ses_1", 1), + data: { sessionID: "ses_1", title: "Greeting" }, + }) + + while (titles.length === 0) await Bun.sleep(0) + expect(titles).toEqual(["Greeting"]) + await transport.close() + }) + test("formats footer usage with compact tokens and context percentage", async () => { const events = feed() events.push(connected()) @@ -599,13 +624,17 @@ describe("V2 mini transport", () => { ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, ) await transport.queuePromptTurn({ - agent: undefined, + agent: "review", model: undefined, variant: undefined, prompt: { messageID: "msg_next", text: "another", parts: [] }, files: [], includeFiles: false, }) + expect(client.session.switchAgent).toHaveBeenCalledWith( + { sessionID: "ses_1", agent: "review" }, + expect.anything(), + ) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) events.push({ id: "evt_earlier_admission", @@ -2015,7 +2044,6 @@ describe("V2 mini transport", () => { id: "evt_progress", created: 3, type: "session.tool.progress", - durable: durable("ses_1", 2), data: { sessionID: "ses_1", assistantMessageID: "msg_progress", @@ -2034,6 +2062,8 @@ describe("V2 mini transport", () => { assistantMessageID: "msg_progress", callID: "call_progress", error: { type: "unknown", message: "boom" }, + metadata: { checkpoint: 1 }, + content: [{ type: "text", text: "partial" }], executed: true, }, }) @@ -2060,12 +2090,13 @@ describe("V2 mini transport", () => { const ui = footer() const transport = await createSessionTransport({ sdk: client, + location: { directory: "/project", workspaceID: "wrk_1" }, sessionID: "ses_1", thinking: false, footer: ui.api, }) spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never) - spyOn(client.model, "default").mockImplementation( + const defaultModel = spyOn(client.model, "default").mockImplementation( () => ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, @@ -2113,6 +2144,10 @@ describe("V2 mini transport", () => { { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, { signal: undefined }, ) + expect(defaultModel).toHaveBeenCalledWith( + { location: { directory: "/project", workspace: "wrk_1" } }, + { signal: undefined }, + ) await transport.close() }) @@ -2684,7 +2719,7 @@ describe("V2 mini transport", () => { }) await transport.runPromptTurn({ - agent: undefined, + agent: "review", model: undefined, variant: undefined, prompt: { @@ -2697,6 +2732,10 @@ describe("V2 mini transport", () => { includeFiles: true, }) + expect(client.session.switchAgent).toHaveBeenCalledWith( + { sessionID: "ses_1", agent: "review" }, + expect.anything(), + ) expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" }) expect(command).not.toHaveBeenCalled() expect(prompt).not.toHaveBeenCalled() @@ -2806,6 +2845,70 @@ describe("V2 mini transport", () => { await transport.close() }) + test("discovers a subagent from its terminal failure snapshot", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events], messages: { ses_child_failed: [] } }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + footer: ui.api, + }) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + events.push({ + id: "evt_failed_subagent_input", + created: 1, + type: "session.tool.input.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_subagent", + callID: "call_failed_subagent", + name: "subagent", + }, + }) + events.push({ + id: "evt_failed_subagent_called", + created: 2, + type: "session.tool.called", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_subagent", + callID: "call_failed_subagent", + input: { agent: "explore", description: "Inspect failure", prompt: "inspect" }, + executed: true, + }, + }) + events.push({ + id: "evt_failed_subagent", + created: 3, + type: "session.tool.failed", + durable: durable("ses_1", 2), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_subagent", + callID: "call_failed_subagent", + error: { type: "unknown", message: "subagent failed" }, + metadata: { sessionID: "ses_child_failed", status: "running" }, + executed: true, + }, + }) + + while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed"))) + await Bun.sleep(0) + expect(states().at(-1)?.tabs).toMatchObject([ + { + sessionID: "ses_child_failed", + label: "Explore", + description: "Inspect failure", + }, + ]) + await transport.close() + }) + test("discovers current subagents from progress and reduces descendant tool state", async () => { const events = feed() events.push(connected()) @@ -2847,7 +2950,6 @@ describe("V2 mini transport", () => { id: "evt_subagent_progress", created: 3, type: "session.tool.progress", - durable: durable("ses_1", 2), data: { sessionID: "ses_1", assistantMessageID: "msg_subagent", @@ -2899,7 +3001,6 @@ describe("V2 mini transport", () => { id: "evt_child_tool_progress", created: 6, type: "session.tool.progress", - durable: durable("ses_child_progress", 2), data: { sessionID: "ses_child_progress", assistantMessageID: "msg_child_tool", @@ -2930,6 +3031,8 @@ describe("V2 mini transport", () => { assistantMessageID: "msg_child_tool", callID: "call_child_shell", error: { type: "unknown", message: "child boom" }, + metadata: { checkpoint: "child" }, + content: [{ type: "text", text: "child partial" }], executed: true, }, }) diff --git a/packages/tui/test/mini/tool.test.ts b/packages/tui/test/mini/tool.test.ts index 5f9d1860f6..fb005cdf13 100644 --- a/packages/tui/test/mini/tool.test.ts +++ b/packages/tui/test/mini/tool.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { normalizeTool, toolOutputText, toolPath } from "../../src/mini/tool" +import { normalizeTool, toolInlineInfo, toolOutputText, toolPath, toolScroll } from "../../src/mini/tool" +import { canonicalToolPart } from "./fixture/tool-part" describe("Mini tool presentation", () => { test("uses V2 shell output without the model-facing status", () => { @@ -33,7 +34,7 @@ describe("Mini tool presentation", () => { type: "update", filePath: "/tmp/project/src/a.ts", relativePath: "src/a.ts", - diff: "@@ -1 +1 @@\n-old\n+new", + patch: "@@ -1 +1 @@\n-old\n+new", }, ], }, @@ -73,6 +74,61 @@ describe("Mini tool presentation", () => { ).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } }) }) + test("renders the skill name from structured metadata with the input id as fallback", () => { + const skill = (structured: { name?: string }) => ({ + type: "tool" as const, + id: "call-skill", + name: "skill", + state: { + status: "completed" as const, + input: { id: "tigerstyle" }, + structured, + content: [], + }, + time: { created: 1, ran: 1, completed: 2 }, + }) + + expect(toolInlineInfo(skill({ name: "effect" })).title).toBe('Skill "effect"') + expect(toolInlineInfo(skill({})).title).toBe('Skill "tigerstyle"') + expect( + toolScroll("start", { + directory: "/work/project", + raw: "", + name: "skill", + input: { id: "tigerstyle" }, + meta: { name: "effect" }, + state: {}, + status: "completed", + error: "", + output: "", + time: {}, + }), + ).toBe('→ Skill "effect"') + }) + + test("renders compact search metadata", () => { + expect( + toolInlineInfo( + canonicalToolPart("glob", { + status: "completed", + input: { pattern: "*.ts" }, + structured: { count: 3 }, + content: [], + }), + ).description, + ).toBe("3 matches") + expect( + toolInlineInfo( + canonicalToolPart("grep", { + status: "completed", + input: { pattern: "needle" }, + structured: { matches: 1 }, + content: [], + }), + ).description, + ).toBe("1 match") + }) + test("keeps segment-safe contained tool paths relative", () => { expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt") expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt") diff --git a/packages/updates/README.md b/packages/updates/README.md new file mode 100644 index 0000000000..e4ca686b40 --- /dev/null +++ b/packages/updates/README.md @@ -0,0 +1,26 @@ +# OpenCode Updates + +The updates Worker serves all selected artifacts for a channel. + +```sh +curl 'https://update.opencode.ai/api/latest' +curl 'https://update.opencode.ai/api/latest/cli' +curl 'https://update.opencode.ai/api/latest/cli/npm' +``` + +The `/admin*` route must be protected by a Cloudflare Access self-hosted application. Configure the application with: + +- Public hostname: `update.opencode.ai` +- Path: `admin*` +- Policy: allow the OpenCode team identity group + +The Worker has `workers_dev` and preview URLs disabled so the custom hostname is its only public entry point. + +GitHub Actions publishes artifacts through `POST /api/publish` using a short-lived OIDC token with audience `https://update.opencode.ai`. The Worker accepts only tokens signed by GitHub for repository ID `975734319`, owner ID `66570915`, and `.github/workflows/publish.yml` on configured publishing refs. + +Apply migrations and deploy from this directory: + +```sh +bun run db:migrate +bun run deploy +``` diff --git a/packages/updates/migrations/0001_artifact.sql b/packages/updates/migrations/0001_artifact.sql new file mode 100644 index 0000000000..a501c8583e --- /dev/null +++ b/packages/updates/migrations/0001_artifact.sql @@ -0,0 +1,14 @@ +CREATE TABLE artifact ( + channel TEXT NOT NULL, + name TEXT NOT NULL, + distribution TEXT NOT NULL, + version TEXT NOT NULL, + metadata TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)), + time_updated INTEGER NOT NULL, + PRIMARY KEY (channel, name, distribution, version) +) WITHOUT ROWID; + +CREATE UNIQUE INDEX artifact_active +ON artifact (channel, name, distribution) +WHERE active = 1; diff --git a/packages/updates/migrations/0002_artifact_time_created.sql b/packages/updates/migrations/0002_artifact_time_created.sql new file mode 100644 index 0000000000..c3bd52023c --- /dev/null +++ b/packages/updates/migrations/0002_artifact_time_created.sql @@ -0,0 +1,3 @@ +ALTER TABLE artifact ADD COLUMN time_created INTEGER NOT NULL DEFAULT 0; + +UPDATE artifact SET time_created = time_updated WHERE time_created = 0; diff --git a/packages/updates/package.json b/packages/updates/package.json new file mode 100644 index 0000000000..5225434eb7 --- /dev/null +++ b/packages/updates/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/updates", + "version": "1.18.4", + "private": true, + "type": "module", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "db:migrate": "wrangler d1 migrations apply opencode-updates --remote", + "test": "bun test", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "jose": "6.0.11" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "wrangler": "4.110.0" + } +} diff --git a/packages/updates/src/index.test.ts b/packages/updates/src/index.test.ts new file mode 100644 index 0000000000..029785c927 --- /dev/null +++ b/packages/updates/src/index.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { channelsForRef, validGitHubClaims } from "./index" + +const claims = { + repository: "anomalyco/opencode", + repository_id: "975734319", + repository_owner_id: "66570915", + workflow_ref: "anomalyco/opencode/.github/workflows/publish.yml@refs/heads/dev", + ref: "refs/heads/dev", + sha: "abc123", + run_id: "123", + run_attempt: "1", + actor: "opencode-agent", +} + +describe("GitHub publish authorization", () => { + test("allows the publish workflow from the repository", () => { + expect(validGitHubClaims(claims)).toBe(true) + expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"]) + }) + + test("rejects another repository or workflow", () => { + expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false) + expect(validGitHubClaims({ ...claims, workflow_ref: "anomalyco/opencode/.github/workflows/other.yml@refs/heads/dev" })).toBe(false) + }) + + test("rejects unconfigured refs", () => { + const ref = "refs/heads/untrusted" + expect(validGitHubClaims({ ...claims, ref, workflow_ref: `anomalyco/opencode/.github/workflows/publish.yml@${ref}` })).toBe(false) + }) +}) diff --git a/packages/updates/src/index.ts b/packages/updates/src/index.ts new file mode 100644 index 0000000000..2ec5f64d2b --- /dev/null +++ b/packages/updates/src/index.ts @@ -0,0 +1,391 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose" + +interface Env { + DB: D1Database +} + +type ArtifactRow = { + channel: string + name: string + distribution: string + version: string + metadata: string + active: number + time_created: number + time_updated: number +} + +type Artifact = Omit & { + metadata: unknown + active: boolean +} + +type ArtifactInput = Pick & { + metadata: unknown +} + +const identifier = /^[a-zA-Z0-9._-]{1,64}$/ +const version = /^[a-zA-Z0-9.+_-]{1,128}$/ +const select = "SELECT channel, name, distribution, version, metadata, active, time_created, time_updated FROM artifact" +const audience = "https://update.opencode.ai" +const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks")) + +export default { + async fetch(request, env): Promise { + const url = new URL(request.url) + + if (url.pathname === "/") return json({ service: "opencode-updates" }) + if (url.pathname === "/admin" && request.method === "GET") return admin(request, env) + if (url.pathname === "/admin/activate" && request.method === "POST") return activateArtifact(request, env) + if (url.pathname === "/api/publish" && request.method === "POST") return publishArtifact(request, env) + if (request.method !== "GET") return new Response("Method not allowed", { status: 405 }) + + const segments = url.pathname.split("/").filter(Boolean) + if (segments.length === 2 && segments[0] === "api" && validIdentifier(segments[1])) { + return channel(env.DB, segments[1]) + } + if ( + segments.length === 3 && + segments[0] === "api" && + validIdentifier(segments[1]) && + validIdentifier(segments[2]) + ) { + return artifactName(env.DB, segments[1], segments[2]) + } + if ( + segments.length === 4 && + segments[0] === "api" && + validIdentifier(segments[1]) && + validIdentifier(segments[2]) && + validIdentifier(segments[3]) + ) { + return artifactDistribution(env.DB, segments[1], segments[2], segments[3]) + } + return new Response("Not found", { status: 404 }) + }, +} satisfies ExportedHandler + +async function channel(db: D1Database, channel: string) { + const result = await db + .prepare(`${select} WHERE channel = ? AND active = 1 ORDER BY name, distribution`) + .bind(channel) + .all() + if (!result.results.length) return json({ error: "Channel not found" }, 404) + return cached({ channel, artifacts: result.results.map(decodeArtifact) }) +} + +async function artifactName(db: D1Database, channel: string, name: string) { + const result = await db + .prepare(`${select} WHERE channel = ? AND name = ? AND active = 1 ORDER BY distribution`) + .bind(channel, name) + .all() + if (!result.results.length) return json({ error: "Artifact not found" }, 404) + return cached({ channel, name, artifacts: result.results.map(decodeArtifact) }) +} + +async function artifactDistribution(db: D1Database, channel: string, name: string, distribution: string) { + const artifact = await db + .prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND active = 1`) + .bind(channel, name, distribution) + .first() + if (!artifact) return json({ error: "Artifact not found" }, 404) + return cached(decodeArtifact(artifact)) +} + +async function admin(request: Request, env: Env) { + const url = new URL(request.url) + const requestedPage = Number.parseInt(url.searchParams.get("page") ?? "1", 10) + const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1 + const pageSize = 100 + const count = await env.DB.prepare("SELECT COUNT(*) AS total FROM artifact").first<{ total: number }>() + const pages = Math.max(1, Math.ceil((count?.total ?? 0) / pageSize)) + const currentPage = Math.min(page, pages) + const result = await env.DB + .prepare(`${select} ORDER BY time_created DESC LIMIT ? OFFSET ?`) + .bind(pageSize, (currentPage - 1) * pageSize) + .all() + const rows = result.results + .map( + (artifact) => ` + ${escape(artifact.channel)} + ${escape(artifact.name)} + ${escape(artifact.distribution)} + ${escape(artifact.version)} + ${new Date(artifact.time_created).toISOString()} + ${artifact.active ? 'Active' : 'Inactive'} + + ${ + artifact.active + ? "" + : `
+ + + + + +
` + } + + `, + ) + .join("") + + return new Response( + ` + + + + + OpenCode Updates + + + + +
+
+

Release control

Artifacts

+ ${escape(request.headers.get("Cf-Access-Authenticated-User-Email") ?? "Cloudflare Access pending")} +
+
+
+ +`, + { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } }, + ) +} + +async function publishArtifact(request: Request, env: Env) { + const claims = await verifyGitHub(request) + if (claims instanceof Response) return claims + const input: unknown = await request.json().catch(() => undefined) + const artifact = parseArtifact(isRecord(input) ? input : {}) + if (artifact instanceof Response) return artifact + if (!channelsForRef(claims.ref).includes(artifact.channel)) return json({ error: "Channel is not allowed" }, 403) + if (!isRecord(artifact.metadata)) return json({ error: "Metadata must be an object" }, 400) + await activate(env.DB, [ + { + ...artifact, + metadata: { + ...artifact.metadata, + github: { + sha: claims.sha, + run_id: claims.run_id, + run_attempt: claims.run_attempt, + actor: claims.actor, + ref: claims.ref, + }, + }, + }, + ]) + return json({ published: true }) +} + +async function activateArtifact(request: Request, env: Env) { + const invalid = validMutation(request) + if (invalid) return invalid + const form = await request.formData() + const key = parseKey({ + channel: form.get("channel"), + name: form.get("name"), + distribution: form.get("distribution"), + version: form.get("version"), + }) + if (key instanceof Response) return key + const exists = await env.DB.prepare( + "SELECT 1 FROM artifact WHERE channel = ? AND name = ? AND distribution = ? AND version = ?", + ) + .bind(key.channel, key.name, key.distribution, key.version) + .first() + if (!exists) return json({ error: "Artifact not found" }, 404) + await env.DB.batch([ + deactivateStatement(env.DB, key), + env.DB + .prepare( + "UPDATE artifact SET active = 1, time_updated = ? WHERE channel = ? AND name = ? AND distribution = ? AND version = ?", + ) + .bind(Date.now(), key.channel, key.name, key.distribution, key.version), + ]) + return Response.redirect(new URL("/admin", request.url), 303) +} + +function activate(db: D1Database, artifacts: ArtifactInput[]) { + return db.batch( + artifacts.flatMap((artifact) => { + const time = Date.now() + return [ + deactivateStatement(db, artifact), + db + .prepare( + `INSERT INTO artifact (channel, name, distribution, version, metadata, active, time_created, time_updated) + VALUES (?, ?, ?, ?, ?, 1, ?, ?) + ON CONFLICT (channel, name, distribution, version) DO UPDATE SET + metadata = excluded.metadata, active = 1, time_updated = excluded.time_updated`, + ) + .bind( + artifact.channel, + artifact.name, + artifact.distribution, + artifact.version, + JSON.stringify(artifact.metadata), + time, + time, + ), + ] + }), + ) +} + +function deactivateStatement(db: D1Database, artifact: Pick) { + return db + .prepare("UPDATE artifact SET active = 0 WHERE channel = ? AND name = ? AND distribution = ? AND active = 1") + .bind(artifact.channel, artifact.name, artifact.distribution) +} + +function parseArtifact(input: Record): ArtifactInput | Response { + const key = parseKey(input) + if (key instanceof Response) return key + const metadata = typeof input.metadata === "string" ? parseMetadata(input.metadata) : input.metadata + if (metadata === undefined) return json({ error: "Metadata must be valid JSON" }, 400) + return { ...key, metadata } +} + +function parseKey(input: Record): Omit | Response { + if ( + !validIdentifier(input.channel) || + !validIdentifier(input.name) || + !validIdentifier(input.distribution) || + !validVersion(input.version) + ) + return json({ error: "Invalid artifact" }, 400) + return { + channel: input.channel, + name: input.name, + distribution: input.distribution, + version: input.version, + } +} + +function decodeArtifact(row: ArtifactRow): Artifact { + return { ...row, metadata: decodeMetadata(row.metadata), active: row.active === 1 } +} + +function decodeMetadata(input: string) { + return parseMetadata(input) ?? null +} + +function parseMetadata(input: string): unknown | undefined { + try { + return JSON.parse(input) + } catch { + return + } +} + +async function verifyGitHub(request: Request) { + const authorization = request.headers.get("Authorization") + if (!authorization?.startsWith("Bearer ")) return json({ error: "Unauthorized" }, 401) + const result = await jwtVerify(authorization.slice("Bearer ".length), githubKeys, { + issuer: "https://token.actions.githubusercontent.com", + audience, + }).catch(() => undefined) + if (!result || !validGitHubClaims(result.payload)) return json({ error: "Unauthorized" }, 401) + return result.payload +} + +type GitHubClaims = JWTPayload & { + repository: string + repository_id: string + repository_owner_id: string + workflow_ref: string + ref: string + sha: string + run_id: string + run_attempt: string + actor: string +} + +export function validGitHubClaims(claims: JWTPayload): claims is GitHubClaims { + if (claims.repository !== "anomalyco/opencode") return false + if (claims.repository_id !== "975734319") return false + if (claims.repository_owner_id !== "66570915") return false + if (typeof claims.workflow_ref !== "string" || typeof claims.ref !== "string") return false + if (claims.workflow_ref !== `anomalyco/opencode/.github/workflows/publish.yml@${claims.ref}`) return false + if (!channelsForRef(claims.ref).length) return false + return [claims.sha, claims.run_id, claims.run_attempt, claims.actor].every((value) => typeof value === "string") +} + +export function channelsForRef(ref: string) { + if (ref === "refs/heads/dev") return ["dev", "latest"] + if (ref === "refs/heads/v2") return ["next"] + if (ref === "refs/heads/beta") return ["beta"] + if (ref === "refs/heads/ci") return ["ci"] + if (ref === "refs/heads/fix/npm-native-binary-install") return ["fix/npm-native-binary-install"] + const snapshot = ref.match(/^refs\/heads\/(snapshot-[a-zA-Z0-9._-]+)$/)?.[1] + return snapshot ? [snapshot] : [] +} + +function validMutation(request: Request) { + const origin = request.headers.get("Origin") + if (origin && origin !== new URL(request.url).origin) return json({ error: "Invalid origin" }, 403) +} + +function validIdentifier(input: unknown): input is string { + return typeof input === "string" && identifier.test(input) +} + +function validVersion(input: unknown): input is string { + return typeof input === "string" && version.test(input) +} + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + +function cached(value: unknown) { + return json(value, 200, { "Cache-Control": "public, max-age=60" }) +} + +function json(value: unknown, status = 200, headers?: HeadersInit) { + return Response.json(value, { status, headers }) +} + +function escape(value: string) { + return value.replace(/[&<>"']/g, (character) => { + if (character === "&") return "&" + if (character === "<") return "<" + if (character === ">") return ">" + if (character === '"') return """ + return "'" + }) +} diff --git a/packages/updates/tsconfig.json b/packages/updates/tsconfig.json new file mode 100644 index 0000000000..8cdcc84d7f --- /dev/null +++ b/packages/updates/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "lib": ["ES2023", "WebWorker"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "types": ["@cloudflare/workers-types", "bun"] + }, + "include": ["src"] +} diff --git a/packages/updates/wrangler.jsonc b/packages/updates/wrangler.jsonc new file mode 100644 index 0000000000..988ea80282 --- /dev/null +++ b/packages/updates/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "opencode-updates", + "main": "src/index.ts", + "compatibility_date": "2026-07-21", + "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "update.opencode.ai", + "custom_domain": true + } + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "opencode-updates", + "database_id": "058debd8-1572-4c4a-8781-2c5a0e33cb46", + "migrations_dir": "migrations" + } + ], + "observability": { + "enabled": true + } +} diff --git a/packages/util/src/client.ts b/packages/util/src/client.ts deleted file mode 100644 index 8375e23616..0000000000 --- a/packages/util/src/client.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * as Client from "./client.js" - -import { Context, Layer } from "effect" -import { makeGlobalNode } from "./effect/app-node.js" - -export const Name = Context.Reference("@opencode/Client/Name", { - defaultValue: () => "cli", -}) - -export const layer = (name = "cli") => Layer.succeed(Name, name) - -export const configured = (name?: string) => makeGlobalNode({ service: Name, layer: layer(name), deps: [] }) - -export const node = configured() diff --git a/packages/util/src/installation/version.ts b/packages/util/src/installation/version.ts deleted file mode 100644 index d142b46a8b..0000000000 --- a/packages/util/src/installation/version.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare const OPENCODE_VERSION: string -declare const OPENCODE_CHANNEL: string - -export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" -export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" -export const InstallationLocal = InstallationChannel === "local" diff --git a/packages/util/src/observability.ts b/packages/util/src/observability.ts index f356aa03fb..f2474e3e7f 100644 --- a/packages/util/src/observability.ts +++ b/packages/util/src/observability.ts @@ -7,11 +7,13 @@ import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" import { Logging } from "./observability/logging.js" import { Otlp } from "./observability/otlp.js" -import { Client } from "./client.js" export const Options = Schema.Struct({ endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.String), + client: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + channel: Schema.optional(Schema.String), }) export type Options = typeof Options.Type @@ -21,24 +23,29 @@ export function layer( headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, }, ) { - const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( + const app = { + client: options.client ?? "opencode", + version: options.version ?? "unknown", + channel: options.channel ?? "local", + } + const local = Logger.layer(Logging.loggers(app.channel === "local", app.channel), { mergeWithExisting: false }).pipe( Layer.provide(NodeFileSystem.layer), Layer.orDie, Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), ) return Layer.unwrap( Effect.gen(function* () { - const client = yield* Client.Name - const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options, client)], { - mergeWithExisting: false, - }).pipe( + const logs = Logger.layer( + [...Logging.loggers(app.channel === "local", app.channel), ...Otlp.loggers(options, app)], + { mergeWithExisting: false }, + ).pipe( Layer.provide(NodeFileSystem.layer), Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer), Layer.orDie, Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), ) - return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, client))) + return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, app))) }), ).pipe(Layer.catchCause(() => local)) } diff --git a/packages/util/src/observability/logging.ts b/packages/util/src/observability/logging.ts index b83c0cc150..b8e162bad3 100644 --- a/packages/util/src/observability/logging.ts +++ b/packages/util/src/observability/logging.ts @@ -1,7 +1,6 @@ import { Formatter, Logger, type LogLevel } from "effect" import path from "path" import { Global } from "../global.js" -import { InstallationChannel, InstallationLocal } from "../installation/version.js" import { runID } from "./shared.js" function formatter(id: string = runID) { @@ -47,7 +46,7 @@ function format(input: unknown) { return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value) } -export function file(local = InstallationLocal, channel = InstallationChannel) { +export function file(local = true, channel = "local") { if (!local) return path.join(Global.Path.log, "opencode.log") return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`) } @@ -70,8 +69,9 @@ export function minimumLogLevel() { return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO } -export function loggers() { - return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()] +export function loggers(local = true, channel = "local") { + const logger = fileLogger(file(local, channel)) + return process.env.OPENCODE_PRINT_LOGS === "1" ? [logger, stderrLogger] : [logger] } export * as Logging from "./logging.js" diff --git a/packages/util/src/observability/otlp.ts b/packages/util/src/observability/otlp.ts index 9ac4b7ce6d..e9f700d308 100644 --- a/packages/util/src/observability/otlp.ts +++ b/packages/util/src/observability/otlp.ts @@ -1,6 +1,5 @@ import { Layer } from "effect" import { OtlpLogger } from "effect/unstable/observability" -import { InstallationChannel, InstallationVersion } from "../installation/version.js" import { runID } from "./shared.js" export interface Options { @@ -8,6 +7,12 @@ export interface Options { readonly headers?: string } +export interface App { + readonly client: string + readonly version: string + readonly channel: string +} + function parseHeaders(value?: string) { return value ? value.split(",").reduce( @@ -37,36 +42,36 @@ function resourceAttributes() { } } -export function resource(client = "cli"): { +export function resource(app: App = { client: "opencode", version: "unknown", channel: "local" }): { serviceName: string serviceVersion: string attributes: Record } { return { serviceName: "opencode", - serviceVersion: InstallationVersion, + serviceVersion: app.version, attributes: { ...resourceAttributes(), - "deployment.environment.name": InstallationChannel, - "opencode.client": client, + "deployment.environment.name": app.channel, + "opencode.client": app.client, "opencode.run": runID, "service.instance.id": runID, }, } } -export function loggers(options: Options | undefined, client: string) { +export function loggers(options: Options | undefined, app: App) { if (!options?.endpoint) return [] return [ OtlpLogger.make({ url: `${options.endpoint}/v1/logs`, - resource: resource(client), + resource: resource(app), headers: parseHeaders(options.headers), }), ] } -export async function tracingLayer(options: Options | undefined, client: string) { +export async function tracingLayer(options: Options | undefined, app: App) { if (!options?.endpoint) return Layer.empty const NodeSdk = await import("@effect/opentelemetry/NodeSdk") const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") @@ -80,7 +85,7 @@ export async function tracingLayer(options: Options | undefined, client: string) context.setGlobalContextManager(manager) return NodeSdk.layer(() => ({ - resource: resource(client), + resource: resource(app), spanProcessor: new SdkBase.BatchSpanProcessor( new OTLP.OTLPTraceExporter({ url: `${options.endpoint}/v1/traces`, diff --git a/packages/util/src/patch.ts b/packages/util/src/patch.ts index d3aeb73fd9..843e14efa1 100644 --- a/packages/util/src/patch.ts +++ b/packages/util/src/patch.ts @@ -13,8 +13,10 @@ export class BoundaryError extends Schema.TaggedErrorClass()("Pat export class InvalidHunkError extends Schema.TaggedErrorClass()("Patch.InvalidHunkError", { line: Schema.String, lineNumber: Schema.Number, + reason: Schema.optional(Schema.String), }) { override get message() { + if (this.reason) return `Invalid hunk at line ${this.lineNumber}: ${this.reason}` return `Invalid hunk at line ${this.lineNumber}: '${this.line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'` } } @@ -57,51 +59,48 @@ export function parse(patchText: string): Result.Result, Par while (index < end) { const line = lines[index]! const header = line.trim() - if (header.startsWith("*** Add File:")) { - const path = header.slice("*** Add File:".length).trim() - if (!path) { - index++ - continue - } + if ( + index === begin + 1 && + header.startsWith("*** Environment ID:") && + header.slice("*** Environment ID:".length).trim() + ) { + index++ + continue + } + if (header.startsWith("*** Add File: ")) { + const path = header.slice("*** Add File: ".length).trim() const parsed = parseAdd(lines, index + 1, end) + if ("error" in parsed) return Result.fail(parsed.error) hunks.push({ type: "add", path, contents: parsed.content }) index = parsed.next continue } - if (header.startsWith("*** Delete File:")) { - const path = header.slice("*** Delete File:".length).trim() - if (!path) { - index++ - continue - } + if (header.startsWith("*** Delete File: ")) { + const path = header.slice("*** Delete File: ".length).trim() hunks.push({ type: "delete", path }) index++ continue } - if (header.startsWith("*** Update File:")) { - const path = header.slice("*** Update File:".length).trim() - if (!path) { - index++ - continue - } + if (header.startsWith("*** Update File: ")) { + const path = header.slice("*** Update File: ".length).trim() let next = index + 1 let movePath: string | undefined - if (lines[next]?.startsWith("*** Move to:")) { - movePath = lines[next]!.slice("*** Move to:".length).trim() + while (lines[next]?.trimEnd() === "*** End of File") next++ + const move = lines[next]?.trimEnd() + if (move === "*** Move to:" || move?.startsWith("*** Move to: ")) { + movePath = move.slice("*** Move to: ".length).trim() + if (!movePath) { + return Result.fail(new InvalidHunkError({ line: lines[next]!.trim(), lineNumber: next + 1 })) + } next++ } - const parsed = parseUpdate(lines, next, end) + const parsed = parseUpdate(lines, next, end, path, index) + if ("error" in parsed) return Result.fail(parsed.error) hunks.push({ type: "update", path, movePath, chunks: parsed.chunks }) index = parsed.next continue } - index++ - } - if (hunks.length === 0) { - const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "") - if (invalid !== -1) { - return Result.fail(new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 })) - } + return Result.fail(new InvalidHunkError({ line: header, lineNumber: index + 1 })) } return Result.succeed(hunks) } @@ -123,47 +122,166 @@ export function joinBom(text: string, bom: boolean) { return bom ? `\uFEFF${stripped}` : stripped } -function parseAdd(lines: ReadonlyArray, start: number, end: number) { +function parseAdd( + lines: ReadonlyArray, + start: number, + end: number, +): { content: string; next: number } | { error: InvalidHunkError } { const content: string[] = [] let index = start - while (index < end && !lines[index]!.startsWith("***")) { - if (lines[index]!.startsWith("+")) content.push(lines[index]!.slice(1)) + while (index < end && !isBoundary(lines[index]!.trim())) { + if (!lines[index]!.startsWith("+")) { + return { error: new InvalidHunkError({ line: lines[index]!.trim(), lineNumber: index + 1 }) } + } + content.push(lines[index]!.slice(1)) index++ } return { content: content.join("\n"), next: index } } -function parseUpdate(lines: ReadonlyArray, start: number, end: number) { - const chunks: UpdateFileChunk[] = [] +function parseUpdate( + lines: ReadonlyArray, + start: number, + end: number, + path: string, + hunk: number, +): { chunks: ReadonlyArray; next: number } | { error: InvalidHunkError } { + const chunks: Array<{ + oldLines: string[] + newLines: string[] + changeContext?: string + endOfFile?: boolean + }> = [] let index = start - while (index < end && !lines[index]!.startsWith("***")) { - if (!lines[index]!.startsWith("@@")) { + let afterEndOfFile = false + while (index < end) { + const line = lines[index]! + const updateLine = line.trimEnd() + if (afterEndOfFile) { + if (updateLine === "") { + index++ + continue + } + if (updateLine === "@@" || updateLine.startsWith("@@ ")) afterEndOfFile = false + else if (isBoundary(updateLine)) break + else { + return { + error: new InvalidHunkError({ + line, + lineNumber: index + 1, + reason: `Expected update hunk to start with a @@ context marker, got: '${line}'`, + }), + } + } + } + if (updateLine === "*** End of File") { + const chunk = chunks.at(-1) + if (chunk && chunk.oldLines.length === 0 && chunk.newLines.length === 0) { + return { + error: new InvalidHunkError({ + line: updateLine, + lineNumber: index + 1, + reason: "Update hunk does not contain any lines", + }), + } + } + if (chunk) { + chunk.endOfFile = true + afterEndOfFile = true + } index++ continue } - const changeContext = lines[index]!.slice(2).trim() || undefined - const oldLines: string[] = [] - const newLines: string[] = [] - let endOfFile = false - index++ - while (index < end && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) { - const line = lines[index]! - if (line.startsWith(" ")) { - oldLines.push(line.slice(1)) - newLines.push(line.slice(1)) - } else if (line.startsWith("-")) oldLines.push(line.slice(1)) - else if (line.startsWith("+")) newLines.push(line.slice(1)) + if (isBoundary(updateLine)) break + if (updateLine === "@@" || updateLine.startsWith("@@ ")) { + const previous = chunks.at(-1) + if (previous && previous.oldLines.length === 0 && previous.newLines.length === 0) { + return { + error: new InvalidHunkError({ + line, + lineNumber: index + 1, + reason: `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`, + }), + } + } + chunks.push({ + oldLines: [], + newLines: [], + changeContext: updateLine === "@@" ? undefined : updateLine.slice("@@ ".length), + }) index++ + continue } - if (lines[index]?.trim() === "*** End of File") { - endOfFile = true + if (chunks.length === 0) chunks.push({ oldLines: [], newLines: [] }) + const chunk = chunks.at(-1)! + if (line === "") { + chunk.oldLines.push("") + chunk.newLines.push("") index++ + continue + } + if (line.startsWith(" ")) { + chunk.oldLines.push(line.slice(1)) + chunk.newLines.push(line.slice(1)) + index++ + continue + } + if (line.startsWith("-")) { + chunk.oldLines.push(line.slice(1)) + index++ + continue + } + if (line.startsWith("+")) { + chunk.newLines.push(line.slice(1)) + index++ + continue + } + const populated = chunk.oldLines.length > 0 || chunk.newLines.length > 0 + return { + error: new InvalidHunkError({ + line, + lineNumber: index + 1, + reason: populated + ? `Expected update hunk to start with a @@ context marker, got: '${line}'` + : `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`, + }), + } + } + if (chunks.length === 0) { + return { + error: new InvalidHunkError({ + line: lines[hunk]!.trim(), + lineNumber: hunk + 1, + reason: `Update file hunk for path '${path}' is empty`, + }), + } + } + const last = chunks.at(-1)! + if (last.oldLines.length === 0 && last.newLines.length === 0) { + const line = lines[index]!.trim() + return { + error: new InvalidHunkError({ + line, + lineNumber: index + 1, + reason: + line === "*** End Patch" + ? "Update hunk does not contain any lines" + : `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`, + }), } - chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined }) } return { chunks, next: index } } +function isBoundary(line: string) { + return ( + line === "*** End Patch" || + line.startsWith("*** Add File: ") || + line.startsWith("*** Delete File: ") || + line.startsWith("*** Update File: ") + ) +} + function computeReplacements(lines: ReadonlyArray, path: string, chunks: ReadonlyArray) { const replacements: Array]> = [] let lineIndex = 0 @@ -231,5 +349,4 @@ const normalize = (value: string) => .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text } -const stripHeredoc = (input: string) => - input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input +const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input diff --git a/script/publish.ts b/script/publish.ts index cfb3aace13..3d74fd6812 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -3,6 +3,7 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" import { fileURLToPath } from "url" +import { UpdateArtifact } from "./update-artifact" console.log("=== publishing ===\n") @@ -82,4 +83,13 @@ if (Script.release && !Script.preview) { if (Script.release) { await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}` + const repo = process.env.GH_REPO + if (!repo) throw new Error("GH_REPO is required") + await UpdateArtifact.publish({ + channel: Script.channel, + name: "desktop", + distribution: "github", + version: Script.version, + metadata: await UpdateArtifact.desktopMetadata(Script.version, repo), + }) } diff --git a/script/update-artifact.ts b/script/update-artifact.ts new file mode 100644 index 0000000000..89a50837eb --- /dev/null +++ b/script/update-artifact.ts @@ -0,0 +1,104 @@ +type Artifact = { + channel: string + name: string + distribution: string + version: string + metadata: Record +} + +type DesktopFile = { + url: string + sha512: string + size: number + blockMapSize?: number +} + +export namespace UpdateArtifact { + export async function publish(artifact: Artifact) { + if (process.env.GITHUB_ACTIONS !== "true") { + console.log("skipped update artifact publication outside GitHub Actions") + return + } + const requestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + if (!requestURL || !requestToken) throw new Error("GitHub Actions OIDC is unavailable") + + const url = new URL(requestURL) + url.searchParams.set("audience", "https://update.opencode.ai") + const tokenResponse = await fetch(url, { headers: { Authorization: `Bearer ${requestToken}` } }) + if (!tokenResponse.ok) throw new Error(`Failed to request GitHub OIDC token: ${tokenResponse.status}`) + const token: unknown = await tokenResponse.json() + if (!isRecord(token) || typeof token.value !== "string") throw new Error("GitHub OIDC response did not include a token") + + const response = await fetch("https://update.opencode.ai/api/publish", { + method: "POST", + headers: { + Authorization: `Bearer ${token.value}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(artifact), + }) + if (response.ok) return + throw new Error(`Failed to publish update artifact: ${response.status} ${await response.text()}`) + } + + export async function desktopMetadata(version: string, repo: string) { + const directory = process.env.RUNNER_TEMP ?? "/tmp" + const entries = await Promise.all( + [ + ["desktop.yml", "latest.yml"], + ["desktop-mac.yml", "latest-mac.yml"], + ["desktop-linux.yml", "latest-linux.yml"], + ["desktop-linux-arm64.yml", "latest-linux-arm64.yml"], + ].map(async ([name, source]) => { + const file = Bun.file(`${directory}/${source}`) + if (!(await file.exists())) return + return [name, parseDesktop(await file.text(), version, repo)] as const + }), + ) + const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined)) + if (!Object.keys(manifests).length) throw new Error("No desktop update metadata found") + return { manifests } + } +} + +function parseDesktop(content: string, version: string, repo: string) { + const lines = content.split("\n") + const found = lines.find((line) => line.startsWith("version:"))?.slice("version:".length).trim() + if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`) + const releaseDate = lines + .find((line) => line.startsWith("releaseDate:")) + ?.slice("releaseDate:".length) + .trim() + .replace(/^['"]|['"]$/g, "") + if (!releaseDate) throw new Error("Desktop metadata did not include a release date") + + const files: DesktopFile[] = [] + lines.forEach((line) => { + const value = line.trim() + if (value.startsWith("- url:")) { + const name = value.slice("- url:".length).trim() + files.push({ + url: name.startsWith("http") + ? name + : `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(name)}`, + sha512: "", + size: 0, + }) + return + } + const current = files.at(-1) + if (!current) return + if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim() + if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim()) + if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim()) + }) + if (!files.length || files.some((file) => !file.sha512 || !file.size)) { + throw new Error("Desktop metadata contained an incomplete file") + } + return { files, releaseDate } +} + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +}