mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 08:56:22 +00:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f736e6f111 | ||
|
|
e91480201a | ||
|
|
a995cd8396 | ||
|
|
fabc387e85 | ||
|
|
768e3d45a2 | ||
|
|
0674ddf043 | ||
|
|
41b66bc560 | ||
|
|
49a5baa059 | ||
|
|
1cf576ab55 | ||
|
|
f7d0f69e6c | ||
|
|
a66e6cd55e | ||
|
|
93b3725b14 | ||
|
|
371e344ba6 | ||
|
|
6559dcb01b | ||
|
|
35dbdfe287 | ||
|
|
cd504dc66a | ||
|
|
c4f2926ffb | ||
|
|
aa48433fa0 | ||
|
|
8da7526616 | ||
|
|
7a4ad68af6 | ||
|
|
2960c61f9c | ||
|
|
7ad705225e | ||
|
|
5045b3ce18 | ||
|
|
4306c07b34 | ||
|
|
7de1e86b5d | ||
|
|
a6fd1f8b22 | ||
|
|
52685d4517 | ||
|
|
30d1049942 | ||
|
|
21adcb4969 | ||
|
|
c72b535dee | ||
|
|
8d1a9799f4 | ||
|
|
86ba09c6e0 | ||
|
|
41cb354c3e | ||
|
|
23f3f8b6ca | ||
|
|
baab05727d | ||
|
|
541937d124 | ||
|
|
0da55f1cef | ||
|
|
ac76382211 | ||
|
|
c30285c148 | ||
|
|
b274224af1 | ||
|
|
7a050a19a1 | ||
|
|
16601775f1 | ||
|
|
0991e8b5a5 | ||
|
|
6d791dfe67 | ||
|
|
f268956c75 | ||
|
|
3290a39667 | ||
|
|
51d69b26a0 | ||
|
|
8ff1ef1a62 | ||
|
|
90cd910f52 | ||
|
|
c3342ca812 | ||
|
|
7ca047b2b9 | ||
|
|
b78c2ea7b4 | ||
|
|
0e143437c8 | ||
|
|
bff58fc387 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.1
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"name": "@opencode-ai/ai",
|
||||
"version": "1.17.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -338,7 +339,6 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -350,7 +350,6 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
@@ -695,6 +694,8 @@
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -1065,7 +1066,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -1187,7 +1188,7 @@
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
@@ -3485,7 +3486,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -5865,6 +5866,8 @@
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
@@ -5873,8 +5876,6 @@
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
@@ -5921,9 +5922,9 @@
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -6383,8 +6384,6 @@
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
@@ -7245,10 +7244,6 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -235,6 +235,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ENTERPRISE_SALES_INBOX_EMAIL,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
|
||||
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
|
||||
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
|
||||
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
|
||||
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
|
||||
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
|
||||
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
|
||||
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
@@ -159,7 +159,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "../../route/auth.js"
|
||||
import { AIError, AuthenticationError } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
/**
|
||||
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
|
||||
* which provider facades configure as route auth instead of SigV4. STS-vended
|
||||
* credentials should be refreshed by the consumer (rebuild the model) before
|
||||
* they expire; the route does not refresh.
|
||||
* which provider facades configure as route auth instead of SigV4.
|
||||
*/
|
||||
export interface Credentials {
|
||||
readonly region: string
|
||||
@@ -17,6 +16,44 @@ export interface Credentials {
|
||||
readonly sessionToken?: string
|
||||
}
|
||||
|
||||
/** Static credentials or an effect resolved before every request. */
|
||||
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
|
||||
|
||||
export interface DefaultChainOptions {
|
||||
readonly region: string
|
||||
/** Shared config profile passed to the AWS default chain. */
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials through the AWS default provider chain: environment
|
||||
* variables, shared config and SSO caches, web identity tokens, process
|
||||
* credentials, and container or instance metadata. A fresh chain runs on every
|
||||
* request so credentials rotated on disk without an expiration (for example
|
||||
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
|
||||
* the SDK's own memoization would otherwise pin them for the process lifetime.
|
||||
*/
|
||||
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
|
||||
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
|
||||
return {
|
||||
region: options.region,
|
||||
accessKeyId: identity.accessKeyId,
|
||||
secretAccessKey: identity.secretAccessKey,
|
||||
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
|
||||
}
|
||||
},
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new AuthenticationError({
|
||||
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const signRequest = (input: {
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
@@ -48,16 +85,17 @@ const signRequest = (input: {
|
||||
|
||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
source: CredentialSource | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
return Effect.gen(function* () {
|
||||
if (!credentials) {
|
||||
if (!source) {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
)
|
||||
}
|
||||
const credentials = Effect.isEffect(source) ? yield* source : source
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
const signed = yield* signRequest({
|
||||
url: input.url,
|
||||
@@ -74,4 +112,35 @@ export const sigV4 = (
|
||||
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
|
||||
export const auth = sigV4(undefined)
|
||||
|
||||
export const resolveRegion = (input: {
|
||||
readonly region?: string
|
||||
readonly credentials?: { readonly region: string }
|
||||
}) =>
|
||||
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
|
||||
|
||||
export interface ResolveAuthInput {
|
||||
readonly apiKey?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
export interface ResolveAuthOptions {
|
||||
readonly service?: string
|
||||
readonly name?: string
|
||||
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
|
||||
readonly mode?: "bearer" | "sigv4"
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer wins over SigV4 and explicit static credentials win over the default
|
||||
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
|
||||
* The region is applied to the SigV4 scope so it always matches the endpoint host.
|
||||
*/
|
||||
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
|
||||
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
|
||||
if (apiKey !== undefined) return Auth.bearer(apiKey)
|
||||
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
|
||||
return sigV4(defaultChain({ region, profile: input.profile }), options)
|
||||
}
|
||||
|
||||
export * as BedrockAuth from "./bedrock-auth.js"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: Credentials
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||
const region = BedrockAuth.resolveRegion(input)
|
||||
return route.with({
|
||||
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||
auth:
|
||||
input.apiKey === undefined
|
||||
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
|
||||
: Auth.bearer(input.apiKey),
|
||||
auth: BedrockAuth.resolveAuth(input, region, {
|
||||
service: "bedrock-mantle",
|
||||
name: "Bedrock Mantle",
|
||||
mode: input.auth,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
|
||||
const {
|
||||
apiKey: _,
|
||||
auth: _auth,
|
||||
baseURL: _baseURL,
|
||||
credentials: _credentials,
|
||||
profile: _profile,
|
||||
region: _region,
|
||||
...rest
|
||||
} = input
|
||||
return rest
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
const fromSettings = (settings: Settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
) => fromSettings(settings).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
) => fromSettings(settings).responses(modelID)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly headers?: Record<string, string>
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: BedrockCredentials
|
||||
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
|
||||
readonly region?: string
|
||||
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
|
||||
readonly baseURL?: string
|
||||
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: BedrockCredentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
|
||||
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
/**
|
||||
* Run an effect with `process.env` overrides, restoring the previous values
|
||||
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
|
||||
* credential providers, where Effect `ConfigProvider` layers do not apply.
|
||||
* `undefined` removes a variable for the duration of the effect.
|
||||
*/
|
||||
export const withProcessEnv =
|
||||
(env: Record<string, string | undefined>) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
|
||||
apply(env)
|
||||
return previous
|
||||
}),
|
||||
() => effect,
|
||||
(previous) => Effect.sync(() => apply(previous)),
|
||||
)
|
||||
|
||||
const apply = (env: Record<string, string | undefined>) => {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
continue
|
||||
}
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
type LanguageModel,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import {
|
||||
eventSummary,
|
||||
expectWeatherToolLoop,
|
||||
@@ -95,6 +98,41 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
|
||||
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
|
||||
)
|
||||
|
||||
// Clears every input the AWS default chain reads so tests do not pick up the
|
||||
// developer's profiles, SSO cache, or instance metadata.
|
||||
const noAmbientAWS = {
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_SECRET_ACCESS_KEY: undefined,
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_DEFAULT_REGION: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
}
|
||||
|
||||
const captureHeaders = (target: LanguageModel) =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<Headers> = []
|
||||
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers)
|
||||
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return seen[0]!
|
||||
})
|
||||
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects requests with no auth path", () =>
|
||||
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const unsignedModel = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
profile: "opencode-test-missing-profile",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
|
||||
}),
|
||||
expect(error.reason._tag).toBe("Authentication")
|
||||
expect(error.message).toContain("AWS default credential chain failed")
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
|
||||
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const signed = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
region: "eu-west-1",
|
||||
credentials: {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
|
||||
const headers = yield* captureHeaders(signed)
|
||||
|
||||
expect(prepared.route).toBe("bedrock-converse")
|
||||
expect(prepared.model).toBe(signed)
|
||||
}),
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBeNull()
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(chained)
|
||||
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: "chain-session-token",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const first = yield* captureHeaders(chained)
|
||||
const second = yield* captureHeaders(chained).pipe(
|
||||
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
|
||||
)
|
||||
|
||||
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
|
||||
Effect.gen(function* () {
|
||||
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(bearer)
|
||||
|
||||
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
|
||||
auth: "sigv4",
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
})
|
||||
|
||||
for (const target of [configured, fromSettings]) {
|
||||
const headers = yield* captureHeaders(target)
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_REGION: "ap-southeast-2",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
|
||||
Effect.gen(function* () {
|
||||
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
|
||||
|
||||
const fromDefault = yield* Effect.sync(() =>
|
||||
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
|
||||
).pipe(withProcessEnv({ AWS_REGION: undefined }))
|
||||
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
|
||||
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
|
||||
)
|
||||
|
||||
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("signs with the Mantle service using default-chain credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<string | undefined> = []
|
||||
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers.get("authorization") ?? undefined)
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("supports bearer authentication and custom base URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
|
||||
@@ -1,16 +1,139 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
for (const alternate of ["none", "queue", "steer"]) {
|
||||
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--toolbar-overflow", {
|
||||
args: { alternate },
|
||||
globals: { direction },
|
||||
})
|
||||
const controls = component.locator('[data-slot="composer-controls"]')
|
||||
const actions = component.locator('[data-slot="composer-actions"]')
|
||||
const submit = component.locator('[data-action="composer-submit"]')
|
||||
const add = component.locator('[data-action="composer-attach"]')
|
||||
const agent = controls.getByRole("button", { name: "Choose agent" })
|
||||
const variant = controls.getByRole("button", { name: "Choose model variant" })
|
||||
await expect(controls).toHaveCSS("overflow-x", "auto")
|
||||
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
|
||||
await expect(controls).toHaveCSS("direction", direction)
|
||||
await expect(controls).toHaveCSS("padding-inline-start", "0px")
|
||||
await expect(controls).toHaveCSS("padding-inline-end", "0px")
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
|
||||
alternate === "none" ? 0 : 1,
|
||||
)
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
for (const width of [1024, 360]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await expect
|
||||
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeGreaterThan(0)
|
||||
const fixed = await submit.boundingBox()
|
||||
const fixedAdd = await add.boundingBox()
|
||||
const viewport = await controls.boundingBox()
|
||||
const action = await actions.boundingBox()
|
||||
expect(fixed).not.toBeNull()
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(action).not.toBeNull()
|
||||
expect(fixedAdd).not.toBeNull()
|
||||
if (!fixed || !viewport || !action || !fixedAdd) return
|
||||
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
|
||||
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
|
||||
1,
|
||||
)
|
||||
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
|
||||
direction === "ltr" ? action.x - 12 : viewport.x - 12,
|
||||
1,
|
||||
)
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft = 0
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "true")
|
||||
await expect(controls).toHaveCSS(
|
||||
"mask-image",
|
||||
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
|
||||
)
|
||||
const first = await agent.boundingBox()
|
||||
if (!first) throw new Error("Missing agent control")
|
||||
expect(
|
||||
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
|
||||
).toBeCloseTo(0, 0)
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft =
|
||||
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "true")
|
||||
const scrolled = (await controls.boundingBox())!
|
||||
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
|
||||
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
|
||||
await controls.hover()
|
||||
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
|
||||
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
|
||||
expect(await submit.boundingBox()).toEqual(fixed)
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
|
||||
// The fade disappears at the endpoint instead of reserving padding.
|
||||
await variant.focus()
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft =
|
||||
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "false")
|
||||
const last = await variant.boundingBox()
|
||||
if (!last) throw new Error("Missing variant control")
|
||||
expect(
|
||||
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
|
||||
).toBeLessThan(1)
|
||||
expect(
|
||||
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
|
||||
).toBeLessThan(1)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(variant).toBeFocused()
|
||||
await expect(submit).toBeInViewport()
|
||||
await expect(add).toBeInViewport()
|
||||
await add.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
}
|
||||
|
||||
if (alternate !== "none") {
|
||||
const width = (await controls.boundingBox())!.width
|
||||
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
|
||||
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("does not mask or pad controls when they fit", async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const controls = component.locator('[data-slot="composer-controls"]')
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(controls).toHaveCSS("mask-image", "none")
|
||||
await expect(controls).toHaveCSS("padding-inline-start", "0px")
|
||||
await expect(controls).toHaveCSS("padding-inline-end", "0px")
|
||||
})
|
||||
|
||||
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
|
||||
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
|
||||
for (const [theme, background] of [
|
||||
["light", "rgb(255, 255, 255)"],
|
||||
["dark", "rgb(36, 36, 36)"],
|
||||
] as const) {
|
||||
story(`raises the docked composer only in dark mode (${theme})`, async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft", { globals: { theme } })
|
||||
await expect(component.locator('[data-component="composer"]')).toHaveCSS("background-color", background)
|
||||
})
|
||||
}
|
||||
|
||||
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
await component.locator('[data-action="composer-attach"]').click()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
story(`shimmers once over the visible wordmark (${theme}, ${direction})`, async ({ mount, page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme, direction } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
|
||||
const shimmer = logo.locator(".wordmark-shimmer")
|
||||
const base = logo.locator("svg").first()
|
||||
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
|
||||
await expect(logo.locator("g[mask]")).toHaveCount(0)
|
||||
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
|
||||
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
|
||||
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = 0
|
||||
})
|
||||
})
|
||||
await expect(reveal).toHaveCSS("opacity", "1")
|
||||
await expect(reveal).toHaveCSS("animation-name", "none")
|
||||
await expect(shimmer).toHaveCSS("mask-position", "100% 0px")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => (animation.currentTime = 400))
|
||||
})
|
||||
await expect(shimmer).toHaveCSS("mask-position", "50% 0px")
|
||||
await expect(shimmer).toHaveCSS("opacity", theme === "dark" ? "0.0672" : "0.45")
|
||||
const swept = await logo.screenshot({ path: testInfo.outputPath("wordmark-shimmer.png") })
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())
|
||||
})
|
||||
await expect(reveal).toHaveCSS("opacity", "1")
|
||||
await expect(shimmer).toHaveCSS("opacity", "0")
|
||||
await expect(shimmer).toHaveCSS("mask-position", "0% 0px")
|
||||
const settled = await logo.screenshot({ path: testInfo.outputPath("wordmark-settled.png") })
|
||||
const brightness = await page.evaluate(
|
||||
async (screenshots) => {
|
||||
const pixels = await Promise.all(
|
||||
screenshots.map(async (screenshot) => {
|
||||
const image = new Image()
|
||||
image.src = `data:image/png;base64,${screenshot}`
|
||||
await image.decode()
|
||||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("Cannot read screenshot pixels")
|
||||
context.drawImage(image, 0, 0)
|
||||
return context.getImageData(0, 0, image.width, image.height).data
|
||||
}),
|
||||
)
|
||||
return {
|
||||
min: pixels[0].reduce(
|
||||
(min, value, index) => (index % 4 === 3 ? min : Math.min(min, value - pixels[1][index])),
|
||||
0,
|
||||
),
|
||||
max: pixels[0].reduce(
|
||||
(max, value, index) => (index % 4 === 3 ? max : Math.max(max, value - pixels[1][index])),
|
||||
0,
|
||||
),
|
||||
}
|
||||
},
|
||||
[swept.toString("base64"), settled.toString("base64")],
|
||||
)
|
||||
expect(brightness.min).toBeGreaterThanOrEqual(0)
|
||||
expect(brightness.max).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
}
|
||||
|
||||
story(`shows a static wordmark with reduced motion (${theme})`, async ({ mount, page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize({ width: 360, height: 640 })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
|
||||
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
|
||||
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
|
||||
expect(await logo.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true)
|
||||
})
|
||||
}
|
||||
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: current.directory,
|
||||
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -490,20 +490,24 @@ async function openDraft(
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === `/api/worktree/${projectID}`) {
|
||||
if (path === "/api/worktree") {
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
calls.push("worktree")
|
||||
worktreeRequests.push(request.postDataJSON())
|
||||
}
|
||||
if (path === "/api/session") calls.push("session")
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
|
||||
})
|
||||
await page.route(`**/api/worktree/${projectID}`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
// Keep the real HTTP response pending until the test has checked the preview.
|
||||
const response = await worktree.promise
|
||||
if (response.status === 200) project.sandboxes.push(workspace)
|
||||
await route.fulfill({ ...response, headers })
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
// Keep the real HTTP response pending until the test has checked the preview.
|
||||
const response = await worktree.promise
|
||||
if (response.status === 200) project.sandboxes.push(workspace)
|
||||
await route.fulfill({ ...response, headers })
|
||||
},
|
||||
)
|
||||
await page.route("**/api/session", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: Record<string, unknown> = route.request().postDataJSON()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.use({
|
||||
deviceScaleFactor: 2,
|
||||
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
|
||||
launchOptions: { args: ["--force-device-scale-factor=2"] },
|
||||
})
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -396,7 +396,12 @@ async function mockServers(
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -87,16 +87,24 @@ test("recording a new session shortcut stays in settings until recording finishe
|
||||
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/worktree/*", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
|
||||
await route.fallback()
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === "/api/worktree" &&
|
||||
new URL(request.url()).searchParams.get("location[directory]") === directory &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
@@ -110,16 +118,57 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
|
||||
const refresh = Promise.withResolvers<void>()
|
||||
await page.route("**/api/worktree/*", async (route) => {
|
||||
await refresh.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
await refresh.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
|
||||
const removed = new Set<string>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory },
|
||||
...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })),
|
||||
],
|
||||
})
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
removed.add(route.request().postDataJSON().directory)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fallback()
|
||||
},
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
|
||||
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
|
||||
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
|
||||
await expect(remove).toBeEnabled()
|
||||
const deleting = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE",
|
||||
)
|
||||
await remove.click()
|
||||
const request = await deleting
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
const mcps = Promise.withResolvers<void>()
|
||||
await page.route("**/api/mcp", async (route) => {
|
||||
|
||||
@@ -225,19 +225,90 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(sidebar).toHaveCSS("width", "130px")
|
||||
await expect(sidebar).toHaveCSS("width", "140px")
|
||||
|
||||
await tabB.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
for (const count of [0, 26]) {
|
||||
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, directory, count }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_navigation_${index}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server, directory: sessionA.directory, count },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
|
||||
for (const width of [260, 180, 140]) {
|
||||
if (width !== 260) {
|
||||
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
|
||||
const bounds = await sidebar.boundingBox()
|
||||
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(sidebar).toHaveCSS("width", `${width}px`)
|
||||
await testInfo.attach(`navigation-${count}-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const button = sidebar.getByRole("button", { name, exact: true })
|
||||
const label = button.getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await button.hover()
|
||||
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await expect
|
||||
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.getByRole("main").hover()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
@@ -254,23 +325,27 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(settings).toHaveText("Settings")
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(settings).toBeInViewport({ ratio: 1 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(settings).toHaveCSS("margin-top", "8px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
@@ -285,12 +360,12 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinned = await settings.boundingBox()
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await testInfo.attach(`vertical-tabs-settings-${width}`, {
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -304,17 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await settings.click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(settings).toHaveAttribute("aria-pressed", "true")
|
||||
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeHidden()
|
||||
await settings.focus()
|
||||
await settings.press("Enter")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,9 +431,9 @@ for (const profile of [
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await expect(hint).toBeHidden()
|
||||
await button.hover()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect(hint).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
@@ -373,7 +445,7 @@ for (const profile of [
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await expect(hint).toBeHidden()
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
@@ -381,10 +453,10 @@ for (const profile of [
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -560,7 +632,12 @@ async function mockServer(page: Page) {
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: sessionA.directory,
|
||||
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -67,12 +67,12 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectToken(
|
||||
@@ -92,12 +92,14 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
@@ -107,14 +109,14 @@ for (const theme of ["light", "dark"] as const) {
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
@@ -125,29 +127,29 @@ for (const theme of ["light", "dark"] as const) {
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
await expectBackground(stop, "icon-button-contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -199,10 +201,13 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
// Keep authoritative inventory independent of the raw project's empty sandboxes.
|
||||
await page.route(`**/api/worktree/${projectID}`, (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
},
|
||||
)
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
@@ -222,7 +227,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
)
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
|
||||
@@ -71,27 +71,23 @@ const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -55,6 +55,8 @@ function ComposerStory(props: {
|
||||
label?: string
|
||||
inspectRequest?: boolean
|
||||
continueOnStop?: boolean
|
||||
longLabels?: boolean
|
||||
alternate?: "queue" | "steer"
|
||||
}) {
|
||||
const [draft, setDraft] = createStore<ComposerPersistedState>({
|
||||
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
@@ -66,11 +68,15 @@ function ComposerStory(props: {
|
||||
activity: props.label ?? "Ready",
|
||||
variant: STORY_MODEL.variant,
|
||||
})
|
||||
const modelOption = createMemo(() => ({
|
||||
...selectedModel,
|
||||
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
|
||||
}))
|
||||
const modelSelection = {
|
||||
ready: Object.assign(() => true, { promise: undefined }),
|
||||
current: () => selectedModel,
|
||||
recent: () => [selectedModel],
|
||||
list: () => [selectedModel],
|
||||
current: () => modelOption(),
|
||||
recent: () => [modelOption()],
|
||||
list: () => [modelOption()],
|
||||
cycle() {},
|
||||
set() {},
|
||||
visible: () => true,
|
||||
@@ -126,7 +132,7 @@ function ComposerStory(props: {
|
||||
placeholder: () => "Ask anything, / for commands, @ for context...",
|
||||
agent: {
|
||||
options: () => [
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
|
||||
{ id: "review", label: "review" },
|
||||
],
|
||||
current: () => "build",
|
||||
@@ -135,7 +141,7 @@ function ComposerStory(props: {
|
||||
variant: {
|
||||
options: () => [
|
||||
{ id: "default", label: "default" },
|
||||
{ id: "balanced", label: "balanced" },
|
||||
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
current: () => story.variant,
|
||||
@@ -144,6 +150,17 @@ function ComposerStory(props: {
|
||||
submit: {
|
||||
stopping: () => !!props.stopping,
|
||||
working: () => !!props.working,
|
||||
queue: props.alternate
|
||||
? {
|
||||
count: () => 0,
|
||||
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
|
||||
alternate: () => props.alternate,
|
||||
editing: () => undefined,
|
||||
confirmEdit() {},
|
||||
cancelEdit() {},
|
||||
editFirst: () => false,
|
||||
}
|
||||
: undefined,
|
||||
onSubmit: () => {
|
||||
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
|
||||
const request = props.inspectRequest
|
||||
@@ -290,6 +307,21 @@ export const NarrowLayout = {
|
||||
),
|
||||
}
|
||||
|
||||
export const ToolbarOverflow = {
|
||||
args: { alternate: "queue" },
|
||||
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
|
||||
render: (args: { alternate: "none" | "queue" | "steer" }) => (
|
||||
<div class="w-[min(420px,calc(100vw-80px))]">
|
||||
<ComposerStory
|
||||
longLabels
|
||||
working
|
||||
prompt={text("Keep all controls reachable when the toolbar overflows")}
|
||||
alternate={args.alternate === "none" ? undefined : args.alternate}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DemoFirstClassSkillIDs = {
|
||||
name: "Demo: First-class skill IDs",
|
||||
render: () => (
|
||||
|
||||
@@ -2,12 +2,36 @@
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
|
||||
[data-slot="composer-controls"][data-overflow-start="true"] {
|
||||
--mask-start: 16px;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"][data-overflow-end="true"] {
|
||||
--mask-end: 16px;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"]:dir(rtl) {
|
||||
--mask-direction: to left;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
mask-image: linear-gradient(
|
||||
var(--mask-direction, to right),
|
||||
transparent,
|
||||
black var(--mask-start, 0px),
|
||||
black calc(100% - var(--mask-end, 0px)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
|
||||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let controlsViewport!: HTMLDivElement
|
||||
let controlsContent!: HTMLDivElement
|
||||
const [overflow, setOverflow] = createStore({ start: false, end: false })
|
||||
const updateOverflow = () => {
|
||||
const offset = Math.abs(controlsViewport.scrollLeft)
|
||||
setOverflow({
|
||||
start: offset > 1,
|
||||
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(updateOverflow)
|
||||
observer.observe(controlsViewport)
|
||||
observer.observe(controlsContent)
|
||||
updateOverflow()
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
@@ -226,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-1"
|
||||
class="flex shrink-0 items-center"
|
||||
aria-hidden={state.mode === "shell"}
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
@@ -245,64 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onContext={props.controller.openContext}
|
||||
onShell={props.controller.openShell}
|
||||
/>
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
</div>
|
||||
<div
|
||||
ref={controlsViewport}
|
||||
data-slot="composer-controls"
|
||||
data-overflow-start={overflow.start}
|
||||
data-overflow-end={overflow.end}
|
||||
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
|
||||
onScroll={updateOverflow}
|
||||
aria-hidden={state.mode === "shell"}
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
>
|
||||
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
<div data-slot="composer-actions" class="flex shrink-0 items-center">
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -808,11 +842,7 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
class="size-7 rounded-md p-[6px] disabled:opacity-50"
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -34,6 +34,14 @@ export function createHomeController() {
|
||||
const conn = list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
const project = home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const project = homeProjectForSession(session, home.project.list())
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const connKey = ServerConnection.key(conn)
|
||||
|
||||
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("labels a worktree session with its project before that project's inventory has loaded", () => {
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [{ ...opened, name: "Project A" }],
|
||||
})
|
||||
|
||||
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
|
||||
expect(records[0]?.projectName).toBe("Project A")
|
||||
})
|
||||
|
||||
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
|
||||
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [opened, nested],
|
||||
})
|
||||
|
||||
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const project = input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
|
||||
// the session's project ID still identifies its added project.
|
||||
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: readonly T[],
|
||||
) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
return (
|
||||
projects.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projects.find((item) => item.id === session.projectID)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
|
||||
/** Allows the prompt to extend 80px beyond each side of the 720px wordmark. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[880px] px-0"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
@@ -23,6 +22,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NewSessionView(props: {
|
||||
/>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<NewSessionWordmark />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
[data-component="new-session-wordmark"] {
|
||||
--wordmark-shimmer-opacity: 0.45;
|
||||
|
||||
.wordmark-shimmer {
|
||||
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
|
||||
color: white;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
|
||||
mask-size: 300% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
animation: new-session-wordmark-shimmer 800ms linear both;
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
|
||||
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
|
||||
--wordmark-shimmer-opacity: 0.0672;
|
||||
}
|
||||
|
||||
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
|
||||
@keyframes new-session-wordmark-shimmer {
|
||||
0% {
|
||||
mask-position: 100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
20%,
|
||||
80% {
|
||||
opacity: var(--wordmark-shimmer-opacity);
|
||||
}
|
||||
100% {
|
||||
mask-position: 0% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="new-session-wordmark"] {
|
||||
.wordmark-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
export default {
|
||||
title: "App/New Session/Wordmark",
|
||||
id: "app-new-session-wordmark",
|
||||
component: NewSessionWordmark,
|
||||
}
|
||||
|
||||
export const Reveal = {
|
||||
render: () => <NewSessionWordmark />,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import "./wordmark.css"
|
||||
|
||||
export function NewSessionWordmark() {
|
||||
return (
|
||||
<div
|
||||
data-component="new-session-wordmark"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
|
||||
>
|
||||
<div data-slot="wordmark-reveal" class="relative">
|
||||
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
|
||||
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { popularProviders } from "./order"
|
||||
|
||||
test("lists OpenCode Go before Zen and other popular providers", () => {
|
||||
expect(popularProviders.slice(0, 2)).toEqual(["opencode-go", "opencode"])
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
export const popularProviders = [
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"openai",
|
||||
|
||||
@@ -110,7 +110,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
active: undefined as string | undefined,
|
||||
connecting: undefined as string | undefined,
|
||||
})
|
||||
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||
const all = createMemo(() => {
|
||||
language.locale()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
|
||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
|
||||
@@ -1045,8 +1045,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "Project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.showProjectIcon.title": "Show project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "Place the title bar at the bottom of the screen on mobile",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
import { createRefCountMap } from "@/runtime/server/refcount"
|
||||
import { createRequestQueue } from "@/runtime/server/request-queue"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "./current"
|
||||
|
||||
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
const api = createApiForServer({ server: http, fetch: queue.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
|
||||
@@ -6,9 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
|
||||
type ProjectApi = ServerApi["project"]
|
||||
type WorktreeApi = ServerApi["worktree"]
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
@@ -23,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
@@ -50,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
|
||||
expect(store.project[0]?.worktrees).toEqual([
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
@@ -79,58 +88,39 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const projects = {
|
||||
list: async () => [
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
const worktrees = {
|
||||
list: async ({ projectID }: { projectID: string }) => {
|
||||
calls.push(projectID)
|
||||
return [
|
||||
{ directory: `/${projectID}` },
|
||||
{ directory: `/${projectID}/clone` },
|
||||
{ directory: `/${projectID}/copy`, strategy: "git" },
|
||||
]
|
||||
},
|
||||
} as unknown as WorktreeApi
|
||||
test("loads project metadata without enumerating any project's worktrees", async () => {
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
|
||||
return Response.json([
|
||||
...Array.from({ length: 300 }, (_, index) => ({
|
||||
id: `historical-${index.toString().padStart(3, "0")}`,
|
||||
canonical: `/history/${index}`,
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
|
||||
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
["/a/clone", "/a/copy"],
|
||||
["/b/clone", "/b/copy"],
|
||||
])
|
||||
expect(result.map((project) => project.worktrees)).toEqual([
|
||||
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
||||
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
||||
])
|
||||
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("keeps projects whose directory inventory cannot load", async () => {
|
||||
const projects = {
|
||||
list: async () => [
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
const worktrees = {
|
||||
list: async ({ projectID }: { projectID: string }) => {
|
||||
if (projectID === "b") throw new Error("unavailable")
|
||||
return [{ directory: "/a/copy", strategy: "git" as const }]
|
||||
},
|
||||
} as unknown as WorktreeApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
|
||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||
{ id: "a", sandboxes: ["/a/copy"] },
|
||||
{ id: "b", sandboxes: [] },
|
||||
expect(requests).toEqual(["/api/project"])
|
||||
expect(result).toHaveLength(302)
|
||||
expect(result.slice(0, 2)).toMatchObject([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
|
||||
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
|
||||
])
|
||||
expect(result.some((project) => project.id === "test")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -64,42 +63,21 @@ type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
projects.list().then(async (items) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(async (project) => {
|
||||
const directories = await worktrees
|
||||
.list({ projectID: project.id })
|
||||
.catch(() => [
|
||||
{ directory: project.canonical },
|
||||
...(project.sandboxes ?? [])
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory))
|
||||
.map((directory) => ({ directory })),
|
||||
])
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
||||
worktrees: directories,
|
||||
})
|
||||
}),
|
||||
)
|
||||
)
|
||||
projects.list().then((items) =>
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
}
|
||||
scope: ServerScope
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
|
||||
input.setGlobalStore(
|
||||
"project",
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
await runAll(slow)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue } from "./request-queue"
|
||||
|
||||
function setup(input?: { limit?: number; stallMs?: number }) {
|
||||
const pending: Array<{ url: string; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
stallMs: input?.stallMs,
|
||||
now: () => clock,
|
||||
log: (message, data) => logs.push({ message, data }),
|
||||
fetch: Object.assign(
|
||||
(resource: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
|
||||
}),
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
void input.queue.fetch("http://server/api/event")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
})
|
||||
|
||||
test("aborted requests leave the queue without being sent", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
void input.queue.fetch("http://server/api/first")
|
||||
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
|
||||
controller.abort()
|
||||
await input.settle()
|
||||
input.pending[0]!.resolve()
|
||||
await expect(aborted).rejects.toBeInstanceOf(DOMException)
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("a burst that drains promptly is not thrashing", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(10)
|
||||
// Drain two at a time before the stall threshold elapses.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
input.pending.splice(0).forEach((item) => item.resolve())
|
||||
await input.settle()
|
||||
}
|
||||
await Promise.all(responses)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("logs what is in flight and queued once per burst after requests stall", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
|
||||
input.tick(100)
|
||||
input.queue.fetch("http://server/api/health").catch(() => undefined)
|
||||
expect(input.logs).toEqual([])
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([
|
||||
{
|
||||
message: "server thrashing detected",
|
||||
data: {
|
||||
limit: 2,
|
||||
inflight: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
|
||||
],
|
||||
queued: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
|
||||
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
// Still stalled within the rate limit: no repeat.
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(1)
|
||||
input.tick(10_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
type Entry = { method: string; url: string; at: number }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
stallMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
const base = input.fetch
|
||||
const now = input.now ?? Date.now
|
||||
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
|
||||
const inflight = new Set<Entry>()
|
||||
const waiting: Array<{ entry: Entry; start: () => void }> = []
|
||||
let warned = -Infinity
|
||||
let watcher: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
|
||||
// Debug exports include the console, so list what the server is busy with while requests wait.
|
||||
const watch = () => {
|
||||
watcher = undefined
|
||||
const oldest = waiting[0]?.entry
|
||||
if (!oldest) return
|
||||
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
|
||||
warned = now()
|
||||
log("server thrashing detected", {
|
||||
limit,
|
||||
inflight: [...inflight].map(describe),
|
||||
queued: waiting.map((item) => describe(item.entry)),
|
||||
})
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
waiting.shift()?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (inflight.size < limit) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (new URL(request.url).pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now() }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
}
|
||||
return base(request).finally(() => release(entry))
|
||||
},
|
||||
// Bun's fetch type carries preconnect; the browser never calls it.
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
|
||||
return {
|
||||
fetch,
|
||||
inflight: () => inflight.size,
|
||||
queued: () => waiting.length,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const worktrees = createWorktreeInventory({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
),
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
@@ -197,17 +210,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
: project,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,7 +239,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
@@ -261,6 +283,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
worktrees,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -3,11 +3,9 @@ import { useLocation } from "@solidjs/router"
|
||||
import { ComposerEditor } from "@/composer/editor/editor"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerEditor } from "@/composer/editor/interaction"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { PendingSession } from "@/shell/tabs/tabs"
|
||||
|
||||
export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
let element: HTMLElement | undefined
|
||||
const editor = createComposerEditor({
|
||||
@@ -20,7 +18,6 @@ export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
},
|
||||
view: {
|
||||
draftOnly: true,
|
||||
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
|
||||
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
provider: { all: providers.all(), default: providers.default(), connected: [] },
|
||||
}}
|
||||
>
|
||||
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div data-component="session-preparing" data-workspace-session class="min-h-0 flex-1 overflow-y-auto">
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
|
||||
<SessionUserMessage
|
||||
|
||||
@@ -40,8 +40,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
if (!open) return
|
||||
const sdk = serverSDK
|
||||
void sdk.api.worktree
|
||||
.refresh({ projectID: props.project.id })
|
||||
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
|
||||
.list({ location: { directory: props.directory } })
|
||||
.then((items) =>
|
||||
setDirectories(
|
||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -61,6 +61,22 @@ export const SettingsExperimental: Component = () => {
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={settings.general.setShowProjectIcon}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.showProjectIcon.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -348,20 +348,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const SettingsProviders: Component<{
|
||||
(provider) =>
|
||||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
|
||||
)
|
||||
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
|
||||
@@ -65,7 +65,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
Promise.all(
|
||||
(await serverSDK.api.project.list()).map(async (project) => {
|
||||
const worktrees = await serverSDK.api.worktree
|
||||
.list({ projectID: project.id })
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
@@ -180,7 +180,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="-end-2"
|
||||
direction="horizontal"
|
||||
size={state.tabsWidth}
|
||||
min={130}
|
||||
min={140}
|
||||
max={520}
|
||||
onResize={(width) => setState("tabsWidth", width)}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function StatusPopover() {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const settings = useSettings()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
@@ -41,8 +42,9 @@ export function StatusPopover() {
|
||||
serverHealth: serverHealth(),
|
||||
attention: attention(),
|
||||
issue: issue(),
|
||||
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
|
||||
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
|
||||
sidebar: sidebar(),
|
||||
placement: sidebar() ? "top-start" : "bottom-end",
|
||||
shift: sidebar() ? 0 : -168,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
@@ -61,6 +63,7 @@ type StatusPopoverState = {
|
||||
serverHealth: boolean | undefined
|
||||
attention: boolean
|
||||
issue: boolean
|
||||
sidebar: boolean
|
||||
placement: "top-start" | "bottom-end"
|
||||
shift: number
|
||||
label: string
|
||||
@@ -93,21 +96,37 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={IconButton}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
triggerAs={props.state.sidebar ? "button" : IconButton}
|
||||
triggerProps={
|
||||
props.state.sidebar
|
||||
? {
|
||||
type: "button",
|
||||
class:
|
||||
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
|
||||
"data-state": props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
: {
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div class="relative size-4 shrink-0">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
data-slot="status-indicator"
|
||||
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.state.sidebar}>
|
||||
<span class="min-w-0 truncate">{props.state.label}</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
|
||||
@@ -38,9 +38,15 @@ export function createTitlebarRightSlot(): TitlebarRightSlot {
|
||||
}
|
||||
}
|
||||
|
||||
export function TitlebarRightMount() {
|
||||
export function TitlebarRightMount(props: { vertical?: boolean }) {
|
||||
const slot = useTitlebarRightSlot()
|
||||
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
||||
return (
|
||||
<div
|
||||
ref={slot.setMount}
|
||||
id="opencode-titlebar-right"
|
||||
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlebarRight(props: ParentProps) {
|
||||
|
||||
@@ -356,10 +356,10 @@ export function Titlebar(props: {
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" />
|
||||
<Icon name="grid-plus" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("home.title")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
|
||||
@@ -654,10 +654,10 @@ export function Titlebar(props: {
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
@@ -680,20 +680,8 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-settings"
|
||||
data-state={layout.route().type === "settings" ? "pressed" : undefined}
|
||||
class="mt-2 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]"
|
||||
onClick={openSettings}
|
||||
aria-label={language.t("sidebar.settings")}
|
||||
aria-pressed={layout.route().type === "settings"}
|
||||
>
|
||||
<Icon name="settings-gear" />
|
||||
{language.t("sidebar.settings")}
|
||||
</button>
|
||||
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
|
||||
<TitlebarRightMount />
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
|
||||
<TitlebarRightMount vertical />
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
@@ -7,30 +7,27 @@ import { createWorktree } from "./create"
|
||||
describe("worktree creation", () => {
|
||||
test.each(
|
||||
[
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
|
||||
{
|
||||
name: "clone subdirectory",
|
||||
directory: "/copies/repo/packages/app",
|
||||
root: "/copies/repo",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "linked worktree subdirectory",
|
||||
directory: "/linked/task/packages/app",
|
||||
root: "/linked/task",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "Windows clone",
|
||||
directory: "C:\\copies\\repo\\packages\\app",
|
||||
root: "C:\\copies\\repo",
|
||||
canonical: "C:\\copies\\repo",
|
||||
parent: "C:/copies/",
|
||||
},
|
||||
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
|
||||
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
|
||||
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
|
||||
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
|
||||
const requests: Request[] = []
|
||||
const api = OpenCode.make({
|
||||
@@ -66,10 +63,9 @@ describe("worktree creation", () => {
|
||||
strategy: "git",
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
directory: input.parent,
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
"http://localhost:3000/api/worktree/proj_clone",
|
||||
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
|
||||
)
|
||||
expect(
|
||||
requests
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
|
||||
export async function createWorktree(input: {
|
||||
api: Pick<OpenCodeClient, "location" | "worktree">
|
||||
@@ -11,11 +10,10 @@ export async function createWorktree(input: {
|
||||
}) {
|
||||
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
|
||||
const created = await input.api.worktree.create({
|
||||
projectID: project.id,
|
||||
location: { directory: input.directory },
|
||||
strategy: "git",
|
||||
from: project.canonical,
|
||||
branch: input.branch,
|
||||
directory: getDirectory(project.canonical),
|
||||
})
|
||||
// Populate the client cache before the destination session mounts.
|
||||
await input.data.location.syncInfo({ directory: created.directory })
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
const client = new QueryClient()
|
||||
const calls: string[] = []
|
||||
const updates: Array<[string, WorktreeDirectory[]]> = []
|
||||
const inventory = createWorktreeInventory({
|
||||
scope: ServerScope.local,
|
||||
queryClient: client,
|
||||
api: () => ({
|
||||
list: (input) => {
|
||||
const directory = input!.location!.directory!
|
||||
calls.push(directory)
|
||||
return list(directory)
|
||||
},
|
||||
}),
|
||||
updated: (directory, items) => updates.push([directory, items]),
|
||||
})
|
||||
return { client, calls, updates, inventory }
|
||||
}
|
||||
|
||||
describe("createWorktreeInventory", () => {
|
||||
test("loads once per project, shares in-flight work, and publishes the result", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (directory) => {
|
||||
await gate.promise
|
||||
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
|
||||
})
|
||||
const first = setupResult.inventory.load("/repo")
|
||||
const second = setupResult.inventory.load("/repo/")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
gate.resolve()
|
||||
expect(await first).toHaveLength(2)
|
||||
expect(await second).toHaveLength(2)
|
||||
await setupResult.inventory.load("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
expect(setupResult.updates).toEqual([
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
])
|
||||
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("refreshes only inventories a view already loaded", async () => {
|
||||
const setupResult = setup(async (directory) => [{ directory }])
|
||||
await setupResult.inventory.refresh("/never-opened")
|
||||
expect(setupResult.calls).toEqual([])
|
||||
await setupResult.inventory.load("/opened")
|
||||
await setupResult.inventory.refresh("/opened")
|
||||
expect(setupResult.calls).toEqual(["/opened", "/opened"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("a failed load is not cached and never rejects the caller", async () => {
|
||||
let fail = true
|
||||
const setupResult = setup(async (directory) => {
|
||||
if (fail) throw new Error("Location unavailable")
|
||||
return [{ directory }]
|
||||
})
|
||||
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
|
||||
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
|
||||
fail = false
|
||||
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
|
||||
expect(setupResult.calls).toEqual(["/repo", "/repo"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("keys are partitioned by server and normalized by path", () => {
|
||||
const remote = "https://remote.example" as typeof ServerScope.local
|
||||
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
|
||||
)
|
||||
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("withWorktreeInventory", () => {
|
||||
const metadata = {
|
||||
id: "project",
|
||||
canonical: "/repo",
|
||||
name: "Before",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
test("derives the workspace list from the inventory, excluding the project root", () => {
|
||||
const worktrees = [
|
||||
{ directory: "/repo/" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
{ directory: "/elsewhere" },
|
||||
]
|
||||
expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({
|
||||
worktree: "/repo",
|
||||
sandboxes: ["/repo/feature", "/elsewhere"],
|
||||
worktrees,
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves metadata untouched without an inventory and survives metadata updates", () => {
|
||||
const project = normalizeProjectInfo(metadata)
|
||||
expect(withWorktreeInventory(project, undefined)).toBe(project)
|
||||
const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]
|
||||
const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" })
|
||||
expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { pathKey } from "./path-key"
|
||||
import { sameDirectory } from "./paths"
|
||||
|
||||
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
|
||||
return [scope, "worktree", pathKey(directory)] as const
|
||||
}
|
||||
|
||||
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
|
||||
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
|
||||
if (!worktrees) return project
|
||||
return {
|
||||
...project,
|
||||
worktrees: [...worktrees],
|
||||
sandboxes: worktrees
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory)),
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
|
||||
// projects the user is looking at are loaded. Historical projects stay metadata-only.
|
||||
export function createWorktreeInventory(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
api: () => Pick<ServerApi["worktree"], "list">
|
||||
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
|
||||
}) {
|
||||
const options = (directory: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, directory),
|
||||
queryFn: () =>
|
||||
input
|
||||
.api()
|
||||
.list({ location: { directory } })
|
||||
.then((items) => {
|
||||
input.updated(directory, items)
|
||||
return items
|
||||
}),
|
||||
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
cached: (directory: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
|
||||
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
|
||||
// Only inventories some view already demanded are refreshed.
|
||||
refresh: (directory: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
@@ -15,6 +15,7 @@ const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
@@ -40,6 +41,14 @@ const context = createSimpleContext({
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
// Key it by the metadata root so the result merges into the same global project record.
|
||||
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void server.ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
} from "./shared"
|
||||
|
||||
const integrationPriority = new Map([
|
||||
["opencode", 0],
|
||||
["opencode-go", 1],
|
||||
["opencode-go", 0],
|
||||
["opencode", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
|
||||
@@ -18,7 +18,9 @@ export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (cl
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) => response.data),
|
||||
Effect.map((response) =>
|
||||
response.data.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -83,11 +84,15 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "auto"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return "notify"
|
||||
return policy
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,14 +6,14 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
@@ -23,6 +23,11 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("automatically installs every available release when enabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("skips when updates are disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export interface Interface {
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
@@ -78,7 +78,7 @@ const make = Effect.gen(function* () {
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
@@ -168,17 +168,20 @@ const make = Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
@@ -200,18 +203,19 @@ const make = Effect.gen(function* () {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: OPENCODE_VERSION,
|
||||
current,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
const next = action(current, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
|
||||
return { policy, version }
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
@@ -220,8 +224,10 @@ const make = Effect.gen(function* () {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
yield* Ref.set(installedVersion, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -229,9 +235,36 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (OPENCODE_LOCAL)
|
||||
return {
|
||||
type: "unavailable" as const,
|
||||
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
|
||||
}
|
||||
const version = yield* latest()
|
||||
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
if (action(current, version, "auto") === "none") {
|
||||
// An earlier check may have installed the update while this client is still running.
|
||||
return action(OPENCODE_VERSION, current, "auto") === "none"
|
||||
? undefined
|
||||
: { type: "installed" as const, version: current }
|
||||
}
|
||||
return { type: "available" as const, version }
|
||||
})
|
||||
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (!result) return undefined
|
||||
if (result.policy === "notify") return { type: "available" as const, version: result.version }
|
||||
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
return { type: "installed" as const, version: result.version }
|
||||
},
|
||||
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
|
||||
return Service.of({ run, check, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -68,6 +68,36 @@ describe("auth command", () => {
|
||||
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
|
||||
})
|
||||
|
||||
test("lists OpenCode Go before Zen and preserves the remaining integration order", async () => {
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located(
|
||||
[
|
||||
{ id: "opencode", name: "OpenCode Zen" },
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "opencode-go", name: "OpenCode Go" },
|
||||
].map((integration) => ({
|
||||
...integration,
|
||||
methods: [],
|
||||
connections: [{ type: "env", name: "TEST_API_KEY" }],
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "list", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(
|
||||
result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.split(/\s{2,}/)[0]),
|
||||
).toEqual(["OpenCode Go", "OpenCode Zen", "Anthropic"])
|
||||
})
|
||||
|
||||
test("runs command authentication without interactive input", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
|
||||
@@ -12,7 +12,8 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
|
||||
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1931,33 +1931,37 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type WorktreeListOutput = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy?: Worktree.StrategyID | undefined
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type WorktreeCreateOutput = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type WorktreeRemoveOutput = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
|
||||
|
||||
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type WorktreeRefreshOutput = void
|
||||
export type WorktreeRefreshOperation<E = never> = (
|
||||
input: WorktreeRefreshInput,
|
||||
input?: WorktreeRefreshInput,
|
||||
) => Effect.Effect<WorktreeRefreshOutput, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
|
||||
@@ -1451,21 +1451,21 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
|
||||
|
||||
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
|
||||
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
|
||||
preserveEffect<WorktreeListOutput>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
|
||||
preserveEffect<WorktreeCreateOutput>()(
|
||||
raw["worktree.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input?.["location"] },
|
||||
payload: {
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
@@ -1473,14 +1473,14 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
|
||||
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
|
||||
preserveEffect<WorktreeRemoveOutput>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
|
||||
preserveEffect<WorktreeRefreshOutput>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ export type WebSearchApi = Client["websearch"]
|
||||
export type SessionApi = Client["session"]
|
||||
export type SkillApi = Client["skill"]
|
||||
export type VcsApi = Client["vcs"]
|
||||
export type WorktreeApi = Client["worktree"]
|
||||
|
||||
export interface CatalogApi {
|
||||
readonly provider: ProviderApi
|
||||
|
||||
@@ -1968,28 +1968,30 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
body: {
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -2001,7 +2003,8 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/worktree`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -2009,11 +2012,12 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
|
||||
path: `/api/worktree/refresh`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -430,6 +430,8 @@ export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
|
||||
export type ConfigWorktree = { directory: string }
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
@@ -1892,7 +1894,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify"
|
||||
update?: "disable" | "notify" | "auto"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
@@ -1993,6 +1995,7 @@ export type ConfigEntry =
|
||||
}
|
||||
websearch?: false | { provider: "random" | (string & {}) }
|
||||
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
|
||||
worktree?: ConfigWorktree
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
@@ -2042,6 +2045,7 @@ export type ConfigEntry =
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
subagent_fork?: boolean
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
}
|
||||
@@ -6077,45 +6081,51 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly strategy: {
|
||||
readonly strategy: string
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy?: {
|
||||
readonly strategy?: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
readonly from?: {
|
||||
readonly strategy: string
|
||||
readonly strategy?: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly branch?: {
|
||||
readonly strategy: string
|
||||
readonly strategy?: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["branch"]
|
||||
readonly directory: {
|
||||
readonly strategy: string
|
||||
readonly directory?: {
|
||||
readonly strategy?: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy: string
|
||||
readonly strategy?: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["name"]
|
||||
}
|
||||
@@ -6123,14 +6133,20 @@ export type WorktreeCreateInput = {
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type WorktreeRemoveOutput = void
|
||||
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ export type CreateDataInput = {
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
const messagePageLimit = 20
|
||||
// Trailing window for event bursts that each ask for the same refetch.
|
||||
export const settleMs = 150
|
||||
|
||||
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
@@ -141,12 +143,18 @@ function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
}
|
||||
|
||||
function createSync() {
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean }
|
||||
// `started` is false while a reload waits for the load it replaces. Invalidations that land in
|
||||
// that window are already covered, since the reload has not read anything yet.
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean; started: boolean }
|
||||
const state = new Map<string, true | Pending>()
|
||||
const start = (key: string, load: () => Promise<void>, wait?: Promise<void>) => {
|
||||
const entry: Pending = { promise: Promise.resolve(), invalidated: false }
|
||||
const entry: Pending = { promise: Promise.resolve(), invalidated: false, started: !wait }
|
||||
state.set(key, entry)
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
|
||||
const run = () => {
|
||||
entry.started = true
|
||||
return load()
|
||||
}
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(run) : run())
|
||||
.then(() => {
|
||||
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
|
||||
})
|
||||
@@ -178,12 +186,12 @@ function createSync() {
|
||||
if (key) {
|
||||
const active = state.get(key)
|
||||
if (active === true) state.delete(key)
|
||||
if (active !== undefined && active !== true) active.invalidated = true
|
||||
if (active !== undefined && active !== true && active.started) active.invalidated = true
|
||||
return
|
||||
}
|
||||
state.forEach((active, current) => {
|
||||
if (active === true) state.delete(current)
|
||||
if (active !== true) active.invalidated = true
|
||||
if (active !== true && active.started) active.invalidated = true
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -203,6 +211,20 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
}
|
||||
|
||||
// Runs `load` once a burst of same-key events goes quiet, so N events cost one refetch.
|
||||
const settling = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
onCleanup(() => settling.forEach((timer) => clearTimeout(timer)))
|
||||
function settle(key: string, load: () => Promise<unknown>) {
|
||||
clearTimeout(settling.get(key))
|
||||
settling.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
settling.delete(key)
|
||||
refresh(load)
|
||||
}, settleMs),
|
||||
)
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<Store>({
|
||||
session: {
|
||||
info: {},
|
||||
@@ -1226,10 +1248,11 @@ export function createData(config: CreateDataInput) {
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||
// so the mcp list syncs here rather than off integration.updated.
|
||||
// so the mcp list syncs here rather than off integration.updated. The server emits one event
|
||||
// per MCP server as each settles, so a location booting nine servers emitted nine refetches.
|
||||
case "mcp.status.changed":
|
||||
result.location.mcp.server.invalidate(location)
|
||||
refresh(() => result.location.mcp.server.sync(location))
|
||||
settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location))
|
||||
break
|
||||
case "mcp.resources.changed":
|
||||
result.location.mcp.resource.invalidate(location)
|
||||
|
||||
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("worktree methods use the global project contract", async () => {
|
||||
test("all worktree operations use location-based routes without a project parameter", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
@@ -350,27 +350,25 @@ test("worktree methods use the global project contract", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
projectID: "proj_test",
|
||||
strategy: "git",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "proj_test",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
})
|
||||
await client.worktree.refresh({ projectID: "proj_test" })
|
||||
await client.worktree.refresh()
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["POST", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
|
||||
["GET", "http://localhost:3000/api/worktree"],
|
||||
["POST", "http://localhost:3000/api/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/worktree"],
|
||||
["POST", "http://localhost:3000/api/worktree/refresh"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
@@ -380,6 +378,39 @@ test("worktree methods use the global project contract", async () => {
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
})
|
||||
|
||||
test("worktree operations send the configuration location separately from their payload", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
|
||||
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
|
||||
return new Response(null, { status: 204 })
|
||||
return Response.json({ directory: "/configured/task" })
|
||||
},
|
||||
})
|
||||
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
|
||||
directory: "/configured/task",
|
||||
})
|
||||
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[0]?.json()).toEqual({ name: "task" })
|
||||
await client.worktree.remove({
|
||||
location: { directory: "/repo/nested" },
|
||||
directory: "/configured/task",
|
||||
force: true,
|
||||
})
|
||||
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
|
||||
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
|
||||
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
|
||||
{ directory: "/configured/task", strategy: "git" },
|
||||
])
|
||||
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
})
|
||||
|
||||
test("workspace.destroy returns the transition result", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { createData, settleMs, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("config reads and update refreshes are opt-in", async () => {
|
||||
@@ -165,3 +165,86 @@ test.each(["reconnecting", "disposed"] as const)("background reads respect %s ow
|
||||
expect(state.requests).toBe(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("a burst of mcp.status.changed events refetches the server list once it settles", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: string[] = []
|
||||
const location = { directory: "/project" }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(new URL(request.url).pathname)
|
||||
return Response.json({ location, data: [] })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
await setup.data.location.mcp.server.sync()
|
||||
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
||||
for (const server of ["a", "b", "c", "d", "e"]) {
|
||||
const event: OpenCodeEvent = { id: `evt_${server}`, created: 1, type: "mcp.status.changed", location, data: { server } }
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
}
|
||||
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs * 2))
|
||||
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(2)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("invalidations that land before a queued reload starts are absorbed by it", async () => {
|
||||
const pending: Array<PromiseWithResolvers<Response>> = []
|
||||
const location = { directory: "/project" }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: () => {
|
||||
const deferred = Promise.withResolvers<Response>()
|
||||
pending.push(deferred)
|
||||
return deferred.promise
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
const first = setup.data.location.agent.sync()
|
||||
expect(pending).toHaveLength(1)
|
||||
setup.data.location.agent.invalidate()
|
||||
const second = setup.data.location.agent.sync()
|
||||
setup.data.location.agent.invalidate()
|
||||
const third = setup.data.location.agent.sync()
|
||||
setup.data.location.agent.invalidate()
|
||||
expect(pending).toHaveLength(1)
|
||||
pending[0].resolve(Response.json({ location, data: [] }))
|
||||
await first
|
||||
await Promise.resolve()
|
||||
expect(pending).toHaveLength(2)
|
||||
pending[1].resolve(Response.json({ location, data: [{ name: "reviewer" }] }))
|
||||
await Promise.all([second, third])
|
||||
expect(pending).toHaveLength(2)
|
||||
await setup.data.location.agent.sync()
|
||||
expect(pending).toHaveLength(2)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
return false
|
||||
}),
|
||||
AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.4.1
|
||||
ARG BUN_VERSION=1.4.2
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -112,7 +111,6 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
|
||||
@@ -195,7 +195,9 @@ function mapBedrockSettings(
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
...(apiKey === undefined ? {} : { apiKey }),
|
||||
...(settings.auth === "bearer" || settings.auth === "sigv4" ? { auth: settings.auth } : {}),
|
||||
...(credentials === undefined ? {} : { credentials }),
|
||||
...(typeof settings.profile === "string" ? { profile: settings.profile } : {}),
|
||||
...(typeof settings.region === "string" ? { region: settings.region } : {}),
|
||||
...(typeof settings.topP === "number" ? { topP: settings.topP } : {}),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ ${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalo
|
||||
|
||||
## Search
|
||||
|
||||
Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
Call \`search(...)\` to discover exact paths and signatures for additional tools:
|
||||
|
||||
- ${searchSignature}` : ""}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export type Inventory = {
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
|
||||
@@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
@@ -211,6 +209,7 @@ export function normalize(input: unknown): Result {
|
||||
media: Info.fields.media,
|
||||
tool_output: Info.fields.tool_output,
|
||||
websearch: Info.fields.websearch,
|
||||
worktree: Info.fields.worktree,
|
||||
warming: Info.fields.warming,
|
||||
}
|
||||
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
||||
@@ -427,6 +426,15 @@ function normalizeExperimental(
|
||||
)
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
if (own(experimental, "subagent_fork")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_fork,
|
||||
experimental.subagent_fork,
|
||||
["experimental", "subagent_fork"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.subagent_fork = value
|
||||
}
|
||||
native.push(
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export * as ConfigWorktreePlugin from "./worktree.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { Config } from "../../config.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Worktree } from "../../worktree.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.worktree",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
|
||||
yield* worktrees.transform((editor) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.worktree) continue
|
||||
const directory = entry.info.worktree.directory
|
||||
editor.configure({
|
||||
directory: AbsolutePath.make(
|
||||
directory.startsWith("~/")
|
||||
? path.join(global.home, directory.slice(2))
|
||||
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { WorktreeRefresh } from "./worktree/refresh.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
@@ -71,7 +72,8 @@ const nodes = [
|
||||
PluginHooks.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
WorktreeRefresh.node,
|
||||
Worktree.node,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
|
||||
@@ -245,7 +245,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
|
||||
return yield* open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || error.code !== 404) return Effect.fail(error)
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
|
||||
return Effect.fail(error)
|
||||
// Some servers reject unknown query params. Retry once with the user's original URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Tool } from "../tool.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Vcs } from "../vcs.js"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { Generate } from "../generate.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
@@ -69,6 +70,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const sessions = yield* Session.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
@@ -88,6 +90,24 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
const atWorktree = <A, E>(
|
||||
ref: Location.Ref | undefined,
|
||||
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
|
||||
) => {
|
||||
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
|
||||
if (!ref || isCurrentLocation(ref)) return run(worktrees)
|
||||
return Effect.gen(function* () {
|
||||
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
|
||||
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
|
||||
const plugins = yield* Plugin.Service
|
||||
const target = yield* Worktree.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* run(target)
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
}
|
||||
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
|
||||
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
|
||||
|
||||
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
@@ -462,6 +482,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
})
|
||||
}),
|
||||
},
|
||||
worktree: {
|
||||
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
|
||||
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
|
||||
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
|
||||
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
|
||||
reload: worktrees.reload,
|
||||
transform: (callback) =>
|
||||
worktrees.transform((editor) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
editor.add({
|
||||
id: Worktree.StrategyID.make(definition.id),
|
||||
create: (input) => definition.create(input).pipe(Effect.flatMap(decodeWorktree)),
|
||||
remove: (input) => definition.remove(input),
|
||||
list: (directory) => definition.list(directory).pipe(Effect.flatMap(decodeWorktrees)),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||
create: (input) =>
|
||||
@@ -510,6 +549,7 @@ export const requirements = LayerNode.group([
|
||||
Tool.node,
|
||||
Vcs.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
@@ -135,6 +137,7 @@ const services = [
|
||||
ToolOutput.Service,
|
||||
Watcher.Service,
|
||||
WellKnown.Service,
|
||||
Worktree.Service,
|
||||
] as const
|
||||
|
||||
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
|
||||
@@ -183,6 +186,7 @@ export const requirements = LayerNode.group([
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
Worktree.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
@@ -232,6 +236,7 @@ const post = [
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
@@ -2,14 +2,60 @@ export * as PluginModule from "./module.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { createPluginSources } from "@opencode-ai/plugin/source"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Deferred, Effect, FiberSet, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import { PluginPromise } from "./promise.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
export const make = Effect.fn("PluginModule.make")(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const scope = yield* Effect.scope
|
||||
const runPromise = yield* FiberSet.makeRuntimePromise()
|
||||
const changes = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watch = Effect.fn("PluginModule.watch")(function* (file: string) {
|
||||
if (watched.has(file)) return
|
||||
watched.add(file)
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const target = yield* Effect.promise(() => watchTarget(file))
|
||||
const updates = yield* watcher.subscribe(target, Deferred.succeed(ready, undefined))
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
Effect.ensuring(Deferred.succeed(ready, undefined)),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(ready)
|
||||
})
|
||||
const sources = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createPluginSources((file) => runPromise(watch(file)))),
|
||||
(sources) => Effect.sync(() => sources.dispose()),
|
||||
)
|
||||
return {
|
||||
load: (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) => load(operation, sources, options),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
}
|
||||
})
|
||||
|
||||
// A missing dependency may have missing parents too. Watch the nearest existing
|
||||
// ancestor recursively so creating the rest of the path can trigger recovery.
|
||||
function watchTarget(file: string): Promise<Watcher.WatchInput> {
|
||||
return stat(file).then(
|
||||
(info) => ({ path: file, type: info.isDirectory() ? "directory" : "file" }),
|
||||
(cause) => {
|
||||
if (path.dirname(file) === file) throw cause
|
||||
return watchTarget(path.dirname(file))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const Module = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
@@ -31,8 +77,9 @@ export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.Loa
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
sources: ReturnType<typeof createPluginSources>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const local = path.isAbsolute(operation.target)
|
||||
@@ -51,12 +98,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const entrypoint = entrypoints.server
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
|
||||
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => Host.load(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint })
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
local
|
||||
? sources.read(entrypoint)
|
||||
: Host.load(entrypoint).then((module) => ({ module, version: installed?.revision })),
|
||||
)
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(loaded.module).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new LoadError({
|
||||
@@ -72,7 +120,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
...(entrypoints.tui ? { tui: true as const } : {}),
|
||||
...(entrypoints.rpc ? { rpc: true as const } : {}),
|
||||
},
|
||||
revision: JSON.stringify([operation, installed?.revision]),
|
||||
revision: JSON.stringify([operation, loaded.version]),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: fileURLToPath(entrypoint) }
|
||||
: {
|
||||
|
||||
@@ -1,123 +1,59 @@
|
||||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type MantleSDK = {
|
||||
chat: (modelID: string) => LanguageModelV3
|
||||
responses: (modelID: string) => LanguageModelV3
|
||||
}
|
||||
// Ambient inputs the AWS default credential chain can turn into credentials
|
||||
// without any key stored in opencode. Mirrors the presence checks the AWS CLI
|
||||
// and SDK use before consulting shared config.
|
||||
const CHAIN_ENV = [
|
||||
"AWS_PROFILE",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
||||
]
|
||||
|
||||
// Bedrock cross-region inference profiles require regional prefixes only for
|
||||
// specific model/region combinations. Keep the mapping narrow and avoid
|
||||
// double-prefixing model IDs that models.dev already marks as global/us/eu/etc.
|
||||
function resolveModelID(modelID: string, region: string | undefined) {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID
|
||||
|
||||
const resolvedRegion = region ?? "us-east-1"
|
||||
const regionPrefix = resolvedRegion.split("-")[0]
|
||||
if (regionPrefix === "us") {
|
||||
const requiresPrefix = ["nova-micro", "nova-lite", "nova-pro", "nova-premier", "nova-2", "claude", "deepseek"].some(
|
||||
(item) => modelID.includes(item),
|
||||
)
|
||||
if (requiresPrefix && !resolvedRegion.startsWith("us-gov")) return `${regionPrefix}.${modelID}`
|
||||
return modelID
|
||||
}
|
||||
if (regionPrefix === "eu") {
|
||||
const regionRequiresPrefix = [
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-north-1",
|
||||
"eu-central-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
].some((item) => resolvedRegion.includes(item))
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((item) =>
|
||||
modelID.includes(item),
|
||||
)
|
||||
return regionRequiresPrefix && modelRequiresPrefix ? `${regionPrefix}.${modelID}` : modelID
|
||||
}
|
||||
if (regionPrefix !== "ap") return modelID
|
||||
|
||||
const australia = ["ap-southeast-2", "ap-southeast-4"].includes(resolvedRegion)
|
||||
if (australia && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((item) => modelID.includes(item))) {
|
||||
return `au.${modelID}`
|
||||
}
|
||||
|
||||
const prefix = resolvedRegion === "ap-northeast-1" ? "jp" : "apac"
|
||||
return ["claude", "nova-lite", "nova-micro", "nova-pro"].some((item) => modelID.includes(item))
|
||||
? `${prefix}.${modelID}`
|
||||
: modelID
|
||||
}
|
||||
|
||||
function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
if (modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b")
|
||||
return sdk.chat(modelID)
|
||||
return sdk.responses(modelID)
|
||||
const isBedrock = (item: { readonly package: string }) => {
|
||||
const name = Provider.packageName(item.package)
|
||||
return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode-ai/ai/providers/amazon-bedrock")
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
// models.dev advertises AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
|
||||
// AWS_REGION alongside the bearer token. Only the bearer token is a key;
|
||||
// the rest feed the SigV4 credential chain and must not become one.
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.amazonBedrock,
|
||||
method: { type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
|
||||
if (!isBedrock(item.provider)) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (typeof provider.settings?.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.settings.baseURL = provider.settings.endpoint
|
||||
const settings = provider.settings ?? {}
|
||||
const chain = typeof settings.profile === "string" || CHAIN_ENV.some((name) => process.env[name])
|
||||
// SigV4 authenticates through the AWS default chain rather than a key
|
||||
// credential, so ambient AWS configuration is what makes Bedrock usable.
|
||||
if (chain && provider.activation === "auto") provider.activation = "enabled"
|
||||
// Same default the native package uses, made explicit here so catalog
|
||||
// `${AWS_REGION}` URLs resolve without any region configured.
|
||||
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
|
||||
provider.settings = {
|
||||
...settings,
|
||||
...(typeof settings.region !== "string" ? { region } : {}),
|
||||
// Users configure Bedrock private/VPC endpoints as `endpoint`; move it
|
||||
// into the catalog base URL once.
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
}
|
||||
delete provider.settings.endpoint
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
|
||||
const region = typeof options.region === "string" ? options.region : (process.env.AWS_REGION ?? "us-east-1")
|
||||
const bearerToken =
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK ??
|
||||
(typeof options.bearerToken === "string" ? options.bearerToken : undefined)
|
||||
if (bearerToken && !process.env.AWS_BEARER_TOKEN_BEDROCK) process.env.AWS_BEARER_TOKEN_BEDROCK = bearerToken
|
||||
options.region = region
|
||||
if (typeof options.endpoint === "string") options.baseURL = options.endpoint
|
||||
if (!bearerToken && options.credentialProvider === undefined) {
|
||||
// Do not gate SDK creation on explicit AWS env vars. The default chain
|
||||
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
|
||||
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
|
||||
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
|
||||
}
|
||||
|
||||
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock/mantle"))
|
||||
evt.sdk = mod.createBedrockMantle(options)
|
||||
return
|
||||
}
|
||||
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.amazonBedrock) return
|
||||
if (
|
||||
Provider.isAISDK(evt.model.package) &&
|
||||
Provider.packageName(evt.model.package) === "@ai-sdk/amazon-bedrock/mantle"
|
||||
) {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.modelID ?? evt.model.id)
|
||||
return
|
||||
}
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.modelID ?? evt.model.id, region))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -261,6 +261,7 @@ export const GithubCopilotPlugin = define({
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
evt.headers["X-Interaction-Id"] = evt.sessionID
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
|
||||
@@ -274,10 +274,12 @@ export const OpenAIPlugin = define({
|
||||
return
|
||||
}
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
|
||||
const major = Number(match?.[1])
|
||||
const minor = Number(match?.[2] ?? 0)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
|
||||
) {
|
||||
draft.enabled = false
|
||||
return
|
||||
|
||||
@@ -13,8 +13,10 @@ import { PluginInternal } from "./internal.js"
|
||||
import { PluginModule } from "./module.js"
|
||||
import { SdkPlugins } from "./sdk.js"
|
||||
import { PluginUpdate } from "./update.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
modules: Effect.Success<ReturnType<typeof PluginModule.make>>,
|
||||
pre: readonly Plugin.Generation[],
|
||||
post: readonly Plugin.Generation[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
@@ -53,7 +55,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = yield* PluginModule.load(operation, { install }).pipe(
|
||||
const plugin = yield* modules.load(operation, { install }).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
const error = Cause.squash(cause)
|
||||
@@ -117,6 +119,7 @@ export const layer = Layer.effectDiscard(
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const modules = yield* PluginModule.make()
|
||||
const bus = yield* Bus.Service
|
||||
const updates = yield* PluginUpdate.Service
|
||||
const internal = yield* PluginInternal.list()
|
||||
@@ -149,7 +152,7 @@ export const layer = Layer.effectDiscard(
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false, running)
|
||||
const immediate = yield* resolve(modules, pre, post, operations, false, running)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package"
|
||||
? {
|
||||
@@ -164,7 +167,9 @@ export const layer = Layer.effectDiscard(
|
||||
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
|
||||
)
|
||||
yield* apply(immediate)
|
||||
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
|
||||
const resolved = immediate.pending.length
|
||||
? yield* resolve(modules, pre, post, operations, true, running)
|
||||
: immediate
|
||||
if (resolved !== immediate) yield* apply(resolved)
|
||||
running = resolved.packages
|
||||
const targets = new Set(
|
||||
@@ -203,6 +208,7 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* watch(sources.changes())
|
||||
yield* watch(modules.changes())
|
||||
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
|
||||
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
|
||||
yield* watch(
|
||||
@@ -240,6 +246,7 @@ const nodeDeps = [
|
||||
PluginUpdate.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
Watcher.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
|
||||
|
||||
@@ -2,42 +2,50 @@ export * as SystemPromptPlugin from "./system-prompt.js"
|
||||
|
||||
import { SystemPart } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { SessionSystemPrompt } from "../session/system-prompt.js"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt-extension.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
|
||||
export const OpenAIPlugin = make("openai", (id) => (id.includes("gpt") ? PROMPT_GPT : undefined), {
|
||||
operation: "append",
|
||||
})
|
||||
export const OpenAIPlugin = make(
|
||||
"openai",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("gpt")) return
|
||||
|
||||
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(id) => {
|
||||
if (!id.includes("muse")) return
|
||||
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
|
||||
if (model.id.toLowerCase().includes("gpt-6")) return PROMPT_ASTRA
|
||||
|
||||
return PROMPT_GPT
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const KimiPlugin = make("kimi", (model) => (model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make(
|
||||
"arcee",
|
||||
(model) => (model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined),
|
||||
{ operation: "replace" },
|
||||
)
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("muse")) return
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
getPrompt: (modelID: string) => string | undefined,
|
||||
getPrompt: (model: Model.Info) => string | undefined,
|
||||
options: { operation: "replace" | "append" },
|
||||
) {
|
||||
return define({
|
||||
@@ -51,8 +59,9 @@ function make(
|
||||
const model = (yield* ctx.catalog.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
)
|
||||
const prompt = getPrompt(`${model?.modelID ?? event.model.id} ${model?.family ?? ""}`.toLowerCase())
|
||||
if (!prompt) return
|
||||
const template = getPrompt(model ?? Model.Info.default(event.model.providerID, event.model.id))
|
||||
if (!template) return
|
||||
const prompt = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
if (options.operation === "append") {
|
||||
event.system.splice(1, 0, SystemPart.make(prompt))
|
||||
return
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- ctrl+p to list available actions
|
||||
- To give feedback, users should report the issue at
|
||||
https://github.com/anomalyco/opencode
|
||||
|
||||
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/v2/docs/
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
|
||||
# Professional objectivity
|
||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
||||
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
|
||||
- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description.
|
||||
|
||||
- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response.
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls.
|
||||
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the subagent tool]
|
||||
</example>
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user