Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
746381008d | ||
|
|
1e7586a93f | ||
|
|
128d10d9e9 | ||
|
|
5ef72e1101 | ||
|
|
d8060dc9ad | ||
|
|
f71fb18d3d | ||
|
|
5654dd2aad | ||
|
|
64dde0cb15 | ||
|
|
a658e43eeb | ||
|
|
ca8a42c973 | ||
|
|
8f5f75db12 | ||
|
|
4ff0d07b1d | ||
|
|
2a571b3cee | ||
|
|
0fffcdfe46 | ||
|
|
2ba2ee52e8 | ||
|
|
56d818fc34 | ||
|
|
9c8da69196 | ||
|
|
a78018697c | ||
|
|
e45b6ef1de | ||
|
|
b616543ac2 | ||
|
|
2bd3d9a696 | ||
|
|
fa15dbc5ec | ||
|
|
312e5c7a7c | ||
|
|
049502fac6 | ||
|
|
cc2915be16 | ||
|
|
ce061bf661 | ||
|
|
3b8790e034 | ||
|
|
a4f3cedcdf | ||
|
|
1c9a2eb239 | ||
|
|
4fb417d3b5 | ||
|
|
11030c627b | ||
|
|
c104098a66 | ||
|
|
49ee3ba85a | ||
|
|
4fc538378d | ||
|
|
d28b5ad2f4 | ||
|
|
6589a66822 | ||
|
|
5cf9abe743 | ||
|
|
903d81819d | ||
|
|
472f9e64a6 | ||
|
|
c04fa9e253 | ||
|
|
3a78fb1f42 | ||
|
|
85ce6a5f95 | ||
|
|
5217e6c1af | ||
|
|
3a7f617098 | ||
|
|
d9150413cb | ||
|
|
bcbc1dba22 | ||
|
|
ce3235e115 | ||
|
|
a9a2a597d5 | ||
|
|
3753601f87 | ||
|
|
fb4bab8a66 | ||
|
|
b3526f6ce9 | ||
|
|
f220f02a2f | ||
|
|
235a86fb60 | ||
|
|
67b9c9c027 | ||
|
|
2f11c9f7ed | ||
|
|
e1c1193f3e | ||
|
|
29250a0efb | ||
|
|
c6e6bdf59f | ||
|
|
d80e1199ca | ||
|
|
10ea59066f | ||
|
|
79d6b10d7c | ||
|
|
6e78f36a0f | ||
|
|
16866e1180 | ||
|
|
6d130e5deb | ||
|
|
e30d8173c1 | ||
|
|
7a79f3a5ea | ||
|
|
b8ca71d309 |
@@ -9,6 +9,7 @@
|
|||||||
### General Principles
|
### General Principles
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- Keep things in one function unless composable or reusable
|
||||||
|
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||||
- Avoid `try`/`catch` where possible
|
- Avoid `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -412,6 +412,7 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
@@ -495,7 +496,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -533,7 +534,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -548,7 +549,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -583,7 +584,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -632,7 +633,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -677,6 +678,7 @@
|
|||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||||
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
@@ -2035,6 +2037,8 @@
|
|||||||
|
|
||||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||||
|
|
||||||
|
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
|
|
||||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
|
||||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
|
||||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
|
||||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||||
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function UsageSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||||
return u.outputTokens + (u.reasoningTokens ?? 0)
|
return u.outputTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
const goPrev = async () => {
|
const goPrev = async () => {
|
||||||
|
|||||||
@@ -889,10 +889,6 @@ export async function handler(
|
|||||||
|
|
||||||
const inputCost = modelCost.input * inputTokens * 100
|
const inputCost = modelCost.input * inputTokens * 100
|
||||||
const outputCost = modelCost.output * outputTokens * 100
|
const outputCost = modelCost.output * outputTokens * 100
|
||||||
const reasoningCost = (() => {
|
|
||||||
if (!reasoningTokens) return undefined
|
|
||||||
return modelCost.output * reasoningTokens * 100
|
|
||||||
})()
|
|
||||||
const cacheReadCost = (() => {
|
const cacheReadCost = (() => {
|
||||||
if (!cacheReadTokens) return undefined
|
if (!cacheReadTokens) return undefined
|
||||||
if (!modelCost.cacheRead) return undefined
|
if (!modelCost.cacheRead) return undefined
|
||||||
@@ -909,17 +905,11 @@ export async function handler(
|
|||||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||||
})()
|
})()
|
||||||
const totalCostInCent =
|
const totalCostInCent =
|
||||||
inputCost +
|
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
|
||||||
outputCost +
|
|
||||||
(reasoningCost ?? 0) +
|
|
||||||
(cacheReadCost ?? 0) +
|
|
||||||
(cacheWrite5mCost ?? 0) +
|
|
||||||
(cacheWrite1hCost ?? 0)
|
|
||||||
return {
|
return {
|
||||||
totalCostInCent,
|
totalCostInCent,
|
||||||
inputCost,
|
inputCost,
|
||||||
outputCost,
|
outputCost,
|
||||||
reasoningCost,
|
|
||||||
cacheReadCost,
|
cacheReadCost,
|
||||||
cacheWrite5mCost,
|
cacheWrite5mCost,
|
||||||
cacheWrite1hCost,
|
cacheWrite1hCost,
|
||||||
@@ -941,8 +931,7 @@ export async function handler(
|
|||||||
) {
|
) {
|
||||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||||
usageInfo
|
usageInfo
|
||||||
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
|
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
|
||||||
costInfo
|
|
||||||
|
|
||||||
logger.metric({
|
logger.metric({
|
||||||
"tokens.input": inputTokens,
|
"tokens.input": inputTokens,
|
||||||
@@ -953,14 +942,12 @@ export async function handler(
|
|||||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||||
"cost.input.microcents": centsToMicroCents(inputCost),
|
"cost.input.microcents": centsToMicroCents(inputCost),
|
||||||
"cost.output.microcents": centsToMicroCents(outputCost),
|
"cost.output.microcents": centsToMicroCents(outputCost),
|
||||||
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
|
|
||||||
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
||||||
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
||||||
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
||||||
// deprecated - remove after May 20, 2026
|
// deprecated - remove after May 20, 2026
|
||||||
"cost.input": Math.round(inputCost),
|
"cost.input": Math.round(inputCost),
|
||||||
"cost.output": Math.round(outputCost),
|
"cost.output": Math.round(outputCost),
|
||||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
|
||||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
|||||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||||
return {
|
return {
|
||||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
outputTokens,
|
||||||
reasoningTokens,
|
reasoningTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
cacheWrite5mTokens: undefined,
|
cacheWrite5mTokens: undefined,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Config } from "effect"
|
import { Config } from "effect"
|
||||||
|
import { InstallationChannel } from "../installation/version"
|
||||||
|
|
||||||
function truthy(key: string) {
|
function truthy(key: string) {
|
||||||
const value = process.env[key]?.toLowerCase()
|
const value = process.env[key]?.toLowerCase()
|
||||||
@@ -10,6 +11,13 @@ function falsy(key: string) {
|
|||||||
return value === "false" || value === "0"
|
return value === "false" || value === "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channels where new experiments default to ON (unstable / internal users).
|
||||||
|
// Stable channels (`prod`, `latest`) stay opt-in.
|
||||||
|
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
|
||||||
|
function unstableDefault(key: string) {
|
||||||
|
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
|
||||||
|
}
|
||||||
|
|
||||||
function number(key: string) {
|
function number(key: string) {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
@@ -48,6 +56,9 @@ export const Flag = {
|
|||||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||||
|
// Default-on for dev/beta/local; opt-in for stable. Set
|
||||||
|
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
|
||||||
|
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
|
||||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.14.44"
|
version = "1.14.46"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# @opencode-ai/http-recorder
|
||||||
|
|
||||||
|
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
|
||||||
|
exercise real request shapes against deterministic, version-controlled
|
||||||
|
cassettes — no manual mocks, no flakes from upstream drift.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Internal package; depended on as `@opencode-ai/http-recorder` from another
|
||||||
|
workspace package.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||||
|
By default the layer records on first run and replays on subsequent runs —
|
||||||
|
no env-var ternary at the call site, and `CI=true` forces strict replay so
|
||||||
|
missing cassettes fail loudly in CI rather than silently re-recording.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
|
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
|
||||||
|
return yield* response.json
|
||||||
|
})
|
||||||
|
|
||||||
|
// Records if the cassette is missing, replays if it exists.
|
||||||
|
// In CI (CI=true) always replays — fails loudly on missing fixtures.
|
||||||
|
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||||
|
|
||||||
|
// Force a refresh — always hits upstream and overwrites.
|
||||||
|
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
| ------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
|
||||||
|
| `replay` | Strict — match the request to a recorded interaction; error if none. |
|
||||||
|
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||||
|
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||||
|
|
||||||
|
## Cassette format
|
||||||
|
|
||||||
|
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
|
||||||
|
"response": { "status": 200, "headers": {...}, "body": "..." }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cassettes are normal source files — review them, diff them, commit them.
|
||||||
|
|
||||||
|
## Request matching
|
||||||
|
|
||||||
|
By default, requests match on canonicalized method, URL, headers, and JSON
|
||||||
|
body (object keys sorted). Two dispatch strategies are available:
|
||||||
|
|
||||||
|
- **`match`** (default) — find the first recorded interaction whose request
|
||||||
|
matches the incoming request. Same request twice returns the same response.
|
||||||
|
- **`sequential`** — return interactions in the order they were recorded,
|
||||||
|
validating each one matches as the cursor advances. Use for ordered flows
|
||||||
|
where the same URL is hit multiple times with meaningful state changes
|
||||||
|
(pagination, retries, polling).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
|
||||||
|
```
|
||||||
|
|
||||||
|
Supply your own matcher via `match: (incoming, recorded) => boolean` for
|
||||||
|
custom equivalence (e.g. ignoring a timestamp field in the body).
|
||||||
|
|
||||||
|
## Redaction & secret safety
|
||||||
|
|
||||||
|
Cassettes get checked in, so the recorder is aggressive about not letting
|
||||||
|
secrets escape. Redaction is configured by composing a `Redactor`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||||
|
|
||||||
|
HttpRecorder.cassetteLayer("anthropic/messages", {
|
||||||
|
redactor: Redactor.defaults({
|
||||||
|
requestHeaders: { allow: ["content-type", "anthropic-version"] },
|
||||||
|
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
|
||||||
|
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Redactor.defaults({ … })` composes the four built-in redactors with your
|
||||||
|
overrides. For full control, build the stack yourself:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const redactor = Redactor.compose(
|
||||||
|
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
|
||||||
|
Redactor.responseHeaders(),
|
||||||
|
Redactor.url({ query: ["session-id"] }),
|
||||||
|
Redactor.body((parsed) => /* … */),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
What each layer does:
|
||||||
|
|
||||||
|
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
|
||||||
|
allow-list (request default: `content-type`, `accept`, `openai-beta`;
|
||||||
|
response default: `content-type`). Sensitive headers within the
|
||||||
|
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
|
||||||
|
…) are replaced with `[REDACTED]`.
|
||||||
|
- **`url`** — query parameters matching common secret names (`api_key`,
|
||||||
|
`token`, `signature`, AWS signing params, …) are replaced with
|
||||||
|
`[REDACTED]`. URL user/password are replaced. `transform` runs after
|
||||||
|
built-in redaction for path-level scrubbing.
|
||||||
|
- **`body`** — receives the parsed JSON request body and returns a redacted
|
||||||
|
version. No-op for non-JSON bodies.
|
||||||
|
|
||||||
|
After assembling the cassette, the recorder scans every string for known
|
||||||
|
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
|
||||||
|
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
|
||||||
|
environment variable named like a credential. If anything is found, the
|
||||||
|
cassette is **not written** and the request fails with `UnsafeCassetteError`
|
||||||
|
listing what was detected.
|
||||||
|
|
||||||
|
## WebSocket recording
|
||||||
|
|
||||||
|
WebSocket support records the open frame plus client/server message
|
||||||
|
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
|
||||||
|
can live in the same cassette.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const cassette = yield* HttpRecorder.Cassette.Service
|
||||||
|
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||||
|
name: "ws/subscribe",
|
||||||
|
cassette,
|
||||||
|
live: liveExecutor,
|
||||||
|
})
|
||||||
|
// use executor.open(...)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inspecting cassettes programmatically
|
||||||
|
|
||||||
|
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
|
||||||
|
returns the recorded interactions for a name; the file format is hidden
|
||||||
|
behind the seam. Useful for CI checks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
|
const audit = Effect.gen(function* () {
|
||||||
|
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||||
|
const entries = yield* cassettes.list()
|
||||||
|
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||||
|
cassettes
|
||||||
|
.read(entry.name)
|
||||||
|
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||||
|
)
|
||||||
|
return issues.filter((i) => i.findings.length > 0)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`cassetteLayer` is the batteries-included entry point — it provides
|
||||||
|
`Cassette.fileSystem({ directory })` automatically. If you want to provide
|
||||||
|
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
|
||||||
|
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
|
||||||
|
`Cassette.memory` yourself.
|
||||||
|
|
||||||
|
## Options reference
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type RecordReplayOptions = {
|
||||||
|
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
|
||||||
|
directory?: string // default: <cwd>/test/fixtures/recordings
|
||||||
|
metadata?: Record<string, unknown> // merged into cassette.metadata
|
||||||
|
redactor?: Redactor // default: Redactor.defaults()
|
||||||
|
dispatch?: "match" | "sequential" // default: "match"
|
||||||
|
match?: (incoming, recorded) => boolean // custom matcher
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||||
|
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||||
|
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||||
|
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||||
|
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||||
|
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||||
|
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||||
|
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
|
||||||
|
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,54 +1,76 @@
|
|||||||
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
|
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||||
|
import * as fs from "node:fs"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
|
import { secretFindings, type SecretFinding } from "./redaction"
|
||||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||||
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
|
||||||
|
|
||||||
export interface Entry {
|
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||||
readonly name: string
|
|
||||||
readonly path: string
|
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
||||||
|
cassetteName: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Cassette "${this.cassetteName}" not found`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppendResult {
|
||||||
|
readonly findings: ReadonlyArray<SecretFinding>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly path: (name: string) => string
|
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||||
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
|
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||||
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
|
|
||||||
readonly append: (
|
|
||||||
name: string,
|
|
||||||
interaction: Interaction,
|
|
||||||
metadata: CassetteMetadata | undefined,
|
|
||||||
) => Effect.Effect<
|
|
||||||
{
|
|
||||||
readonly cassette: Cassette
|
|
||||||
readonly findings: ReadonlyArray<SecretFinding>
|
|
||||||
},
|
|
||||||
PlatformError.PlatformError
|
|
||||||
>
|
|
||||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
|
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||||
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
||||||
|
|
||||||
export const layer = (options: { readonly directory?: string } = {}) =>
|
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||||
|
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
|
||||||
|
|
||||||
|
const buildCassette = (
|
||||||
|
name: string,
|
||||||
|
interactions: ReadonlyArray<Interaction>,
|
||||||
|
metadata: CassetteMetadata | undefined,
|
||||||
|
): Cassette => ({
|
||||||
|
version: 1,
|
||||||
|
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||||
|
interactions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||||
|
|
||||||
|
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||||
|
|
||||||
|
export const fileSystem = (
|
||||||
|
options: { readonly directory?: string } = {},
|
||||||
|
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fileSystem = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||||
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
|
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||||
|
const directoriesEnsured = new Set<string>()
|
||||||
|
|
||||||
const pathFor = (name: string) => cassettePath(name, directory)
|
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
|
||||||
|
|
||||||
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
|
const ensureDirectory = (name: string) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const entries = yield* fileSystem
|
const dir = path.dirname(cassettePath(name))
|
||||||
.readDirectory(directory)
|
if (directoriesEnsured.has(dir)) return
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
directoriesEnsured.add(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||||
const full = path.join(directory, entry)
|
const full = path.join(current, entry)
|
||||||
return fileSystem.stat(full).pipe(
|
return fs.stat(full).pipe(
|
||||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||||
Effect.catch(() => Effect.succeed([] as string[])),
|
Effect.catch(() => Effect.succeed([] as string[])),
|
||||||
)
|
)
|
||||||
@@ -56,53 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
|||||||
return nested.flat()
|
return nested.flat()
|
||||||
})
|
})
|
||||||
|
|
||||||
const read = Effect.fn("Cassette.read")(function* (name: string) {
|
return Service.of({
|
||||||
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
|
read: (name) =>
|
||||||
|
fs.readFileString(cassettePath(name)).pipe(
|
||||||
|
Effect.map((raw) => parseCassette(raw).interactions),
|
||||||
|
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||||
|
),
|
||||||
|
append: (name, interaction, metadata) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||||
|
if (!recorded.has(name)) recorded.set(name, entry)
|
||||||
|
entry.interactions.push(interaction)
|
||||||
|
entry.findings.push(...secretFindings(interaction))
|
||||||
|
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||||
|
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||||
|
if (findings.length === 0) {
|
||||||
|
yield* ensureDirectory(name)
|
||||||
|
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
return { findings }
|
||||||
|
}),
|
||||||
|
exists: (name) =>
|
||||||
|
fs.access(cassettePath(name)).pipe(
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.catch(() => Effect.succeed(false)),
|
||||||
|
),
|
||||||
|
list: () =>
|
||||||
|
walk(directory).pipe(
|
||||||
|
Effect.map((files) =>
|
||||||
|
files
|
||||||
|
.filter((file) => file.endsWith(".json"))
|
||||||
|
.map((file) =>
|
||||||
|
path
|
||||||
|
.relative(directory, file)
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/\.json$/, ""),
|
||||||
|
)
|
||||||
|
.toSorted((a, b) => a.localeCompare(b)),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
|
|
||||||
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
|
|
||||||
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
|
|
||||||
})
|
|
||||||
|
|
||||||
const append = Effect.fn("Cassette.append")(function* (
|
|
||||||
name: string,
|
|
||||||
interaction: Interaction,
|
|
||||||
metadata: CassetteMetadata | undefined,
|
|
||||||
) {
|
|
||||||
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
|
|
||||||
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
|
|
||||||
)
|
|
||||||
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
|
|
||||||
const findings = cassetteSecretFindings(cassette)
|
|
||||||
if (findings.length === 0) yield* write(name, cassette)
|
|
||||||
return { cassette, findings }
|
|
||||||
})
|
|
||||||
|
|
||||||
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
|
|
||||||
return yield* fileSystem.access(pathFor(name)).pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catch(() => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Cassette.list")(function* () {
|
|
||||||
return (yield* walk(directory))
|
|
||||||
.filter((file) => file.endsWith(".json"))
|
|
||||||
.map((file) => ({
|
|
||||||
name: path
|
|
||||||
.relative(directory, file)
|
|
||||||
.replace(/\\/g, "/")
|
|
||||||
.replace(/\.json$/, ""),
|
|
||||||
path: file,
|
|
||||||
}))
|
|
||||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer()
|
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
||||||
|
Layer.sync(Service, () => {
|
||||||
|
const stored = new Map<string, Interaction[]>(
|
||||||
|
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
||||||
|
)
|
||||||
|
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||||
|
|
||||||
export * as Cassette from "./cassette"
|
return Service.of({
|
||||||
|
read: (name) =>
|
||||||
|
stored.has(name)
|
||||||
|
? Effect.succeed(stored.get(name) ?? [])
|
||||||
|
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||||
|
append: (name, interaction, metadata) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const existing = stored.get(name)
|
||||||
|
if (existing) existing.push(interaction)
|
||||||
|
else stored.set(name, [interaction])
|
||||||
|
const findings = accumulatedFindings.get(name)
|
||||||
|
if (findings) findings.push(...secretFindings(interaction))
|
||||||
|
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||||
|
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||||
|
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||||
|
}),
|
||||||
|
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||||
|
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Option } from "effect"
|
|
||||||
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
|
|
||||||
import { decodeJson } from "./matching"
|
|
||||||
import { REDACTED, redactUrl, secretFindings } from "./redaction"
|
|
||||||
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
|
|
||||||
|
|
||||||
const safeText = (value: unknown) => {
|
|
||||||
if (value === undefined) return "undefined"
|
|
||||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
|
||||||
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
|
|
||||||
if (!text) return String(value)
|
|
||||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
|
||||||
|
|
||||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
|
||||||
if (Object.is(expected, received)) return []
|
|
||||||
if (
|
|
||||||
expected &&
|
|
||||||
received &&
|
|
||||||
typeof expected === "object" &&
|
|
||||||
typeof received === "object" &&
|
|
||||||
!Array.isArray(expected) &&
|
|
||||||
!Array.isArray(received)
|
|
||||||
) {
|
|
||||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
|
||||||
.toSorted()
|
|
||||||
.flatMap((key) =>
|
|
||||||
valueDiffs(
|
|
||||||
(expected as Record<string, unknown>)[key],
|
|
||||||
(received as Record<string, unknown>)[key],
|
|
||||||
`${base}.${key}`,
|
|
||||||
limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
|
||||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
|
||||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
|
||||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
|
||||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
|
||||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
|
||||||
if (expected[key] === received[key]) return []
|
|
||||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
|
||||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
|
||||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
|
||||||
})
|
|
||||||
|
|
||||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
|
|
||||||
const lines = []
|
|
||||||
if (expected.method !== received.method) {
|
|
||||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
|
||||||
}
|
|
||||||
if (expected.url !== received.url) {
|
|
||||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
|
||||||
}
|
|
||||||
const headers = headerDiffs(expected.headers, received.headers)
|
|
||||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
|
||||||
const expectedBody = jsonBody(expected.body)
|
|
||||||
const receivedBody = jsonBody(received.body)
|
|
||||||
const body =
|
|
||||||
expectedBody !== undefined && receivedBody !== undefined
|
|
||||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
|
||||||
: expected.body === received.body
|
|
||||||
? []
|
|
||||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
|
||||||
if (body.length > 0) lines.push("body:", ...body)
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
|
|
||||||
const interactions = httpInteractions(cassette)
|
|
||||||
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
|
||||||
const ranked = interactions
|
|
||||||
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
|
||||||
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
|
||||||
const best = ranked[0]
|
|
||||||
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
HttpClientRequest.makeWith(
|
|
||||||
request.method,
|
|
||||||
redactUrl(request.url),
|
|
||||||
UrlParams.empty,
|
|
||||||
Option.none(),
|
|
||||||
Headers.empty,
|
|
||||||
HttpBody.empty,
|
|
||||||
)
|
|
||||||
@@ -1,62 +1,37 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, Layer, Option, Ref } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
|
Headers,
|
||||||
|
HttpBody,
|
||||||
HttpClient,
|
HttpClient,
|
||||||
HttpClientError,
|
HttpClientError,
|
||||||
HttpClientRequest,
|
HttpClientRequest,
|
||||||
HttpClientResponse,
|
HttpClientResponse,
|
||||||
|
UrlParams,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
|
|
||||||
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
|
|
||||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
|
||||||
import {
|
|
||||||
httpInteractions,
|
|
||||||
type Cassette,
|
|
||||||
type CassetteMetadata,
|
|
||||||
type HttpInteraction,
|
|
||||||
type ResponseSnapshot,
|
|
||||||
} from "./schema"
|
|
||||||
import * as CassetteService from "./cassette"
|
import * as CassetteService from "./cassette"
|
||||||
|
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
|
||||||
|
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||||
|
import { defaults, type Redactor } from "./redactor"
|
||||||
|
import { redactUrl } from "./redaction"
|
||||||
|
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||||
|
|
||||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
|
||||||
|
|
||||||
export type RecordReplayMode = "record" | "replay" | "passthrough"
|
|
||||||
|
|
||||||
export interface RecordReplayOptions {
|
export interface RecordReplayOptions {
|
||||||
readonly mode?: RecordReplayMode
|
readonly mode?: RecordReplayMode
|
||||||
readonly directory?: string
|
readonly directory?: string
|
||||||
readonly metadata?: CassetteMetadata
|
readonly metadata?: CassetteMetadata
|
||||||
readonly redact?: {
|
readonly redactor?: Redactor
|
||||||
readonly headers?: ReadonlyArray<string>
|
|
||||||
readonly query?: ReadonlyArray<string>
|
|
||||||
readonly url?: (url: string) => string
|
|
||||||
}
|
|
||||||
readonly requestHeaders?: ReadonlyArray<string>
|
|
||||||
readonly responseHeaders?: ReadonlyArray<string>
|
|
||||||
readonly redactBody?: (body: unknown) => unknown
|
|
||||||
readonly dispatch?: "match" | "sequential"
|
readonly dispatch?: "match" | "sequential"
|
||||||
readonly match?: RequestMatcher
|
readonly match?: RequestMatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseHeaders = (
|
|
||||||
response: HttpClientResponse.HttpClientResponse,
|
|
||||||
allow: ReadonlyArray<string>,
|
|
||||||
redact: ReadonlyArray<string> | undefined,
|
|
||||||
) => {
|
|
||||||
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
|
|
||||||
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
||||||
|
|
||||||
const isBinaryContentType = (contentType: string | undefined) => {
|
const isBinaryContentType = (contentType: string | undefined) =>
|
||||||
if (!contentType) return false
|
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
|
||||||
const lower = contentType.toLowerCase()
|
|
||||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
|
||||||
}
|
|
||||||
|
|
||||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||||
isBinaryContentType(contentType)
|
isBinaryContentType(contentType)
|
||||||
@@ -68,34 +43,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
|
|||||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||||
|
|
||||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
new HttpClientError.HttpClientError({
|
HttpClientRequest.makeWith(
|
||||||
reason: new HttpClientError.TransportError({
|
request.method,
|
||||||
request: redactedErrorRequest(request),
|
redactUrl(request.url),
|
||||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
UrlParams.empty,
|
||||||
}),
|
Option.none(),
|
||||||
})
|
Headers.empty,
|
||||||
|
HttpBody.empty,
|
||||||
|
)
|
||||||
|
|
||||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
|
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
||||||
new HttpClientError.HttpClientError({
|
new HttpClientError.HttpClientError({
|
||||||
reason: new HttpClientError.TransportError({
|
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||||
request: redactedErrorRequest(request),
|
|
||||||
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const unsafeCassette = (
|
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
|
||||||
name: string,
|
|
||||||
findings: ReadonlyArray<SecretFinding>,
|
|
||||||
) =>
|
|
||||||
new HttpClientError.HttpClientError({
|
|
||||||
reason: new HttpClientError.TransportError({
|
|
||||||
request: redactedErrorRequest(request),
|
|
||||||
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
|
|
||||||
.map((item) => `${item.path} (${item.reason})`)
|
|
||||||
.join(", ")}`,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const recordingLayer = (
|
export const recordingLayer = (
|
||||||
@@ -107,61 +67,22 @@ export const recordingLayer = (
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const upstream = yield* HttpClient.HttpClient
|
const upstream = yield* HttpClient.HttpClient
|
||||||
const cassetteService = yield* CassetteService.Service
|
const cassetteService = yield* CassetteService.Service
|
||||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
const redactor = options.redactor ?? defaults()
|
||||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
|
||||||
const match = options.match ?? defaultMatcher
|
const match = options.match ?? defaultMatcher
|
||||||
const mode = options.mode ?? "replay"
|
const requested = options.mode ?? "auto"
|
||||||
|
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||||
const sequential = options.dispatch === "sequential"
|
const sequential = options.dispatch === "sequential"
|
||||||
const replay = yield* Ref.make<Cassette | undefined>(undefined)
|
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||||
const cursor = yield* Ref.make(0)
|
|
||||||
|
|
||||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||||
const raw = yield* Effect.promise(() => web.text())
|
return redactor.request({
|
||||||
const body = options.redactBody
|
|
||||||
? Option.match(decodeJson(raw), {
|
|
||||||
onNone: () => raw,
|
|
||||||
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
|
|
||||||
})
|
|
||||||
: raw
|
|
||||||
return {
|
|
||||||
method: web.method,
|
method: web.method,
|
||||||
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
|
url: web.url,
|
||||||
headers: redactHeaders(
|
headers: Object.fromEntries(web.headers.entries()),
|
||||||
Object.fromEntries(web.headers.entries()),
|
body: yield* Effect.promise(() => web.text()),
|
||||||
requestHeadersAllow,
|
})
|
||||||
options.redact?.headers,
|
|
||||||
),
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const interactions = httpInteractions(cassette)
|
|
||||||
if (sequential) {
|
|
||||||
const index = yield* Ref.get(cursor)
|
|
||||||
const interaction = interactions[index]
|
|
||||||
if (!interaction)
|
|
||||||
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
|
||||||
if (!match(incoming, interaction.request)) {
|
|
||||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
|
||||||
}
|
|
||||||
yield* Ref.update(cursor, (n) => n + 1)
|
|
||||||
return { interaction, detail: "" }
|
|
||||||
}
|
|
||||||
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
|
||||||
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cached = yield* Ref.get(replay)
|
|
||||||
if (cached) return cached
|
|
||||||
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
|
|
||||||
yield* Ref.set(replay, cassette)
|
|
||||||
return cassette
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return HttpClient.make((request) => {
|
return HttpClient.make((request) => {
|
||||||
@@ -169,18 +90,21 @@ export const recordingLayer = (
|
|||||||
|
|
||||||
if (mode === "record") {
|
if (mode === "record") {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const currentRequest = yield* snapshotRequest(request)
|
const incoming = yield* snapshotRequest(request)
|
||||||
const response = yield* upstream.execute(request)
|
const response = yield* upstream.execute(request)
|
||||||
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
|
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
|
||||||
const interaction: HttpInteraction = {
|
const interaction: HttpInteraction = {
|
||||||
transport: "http",
|
transport: "http",
|
||||||
request: currentRequest,
|
request: incoming,
|
||||||
response: { status: response.status, headers, ...captured },
|
response: redactor.response({
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers as Record<string, string>,
|
||||||
|
...captured,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
|
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||||
const findings = result.findings
|
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||||
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
|
)
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||||
@@ -189,14 +113,23 @@ export const recordingLayer = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const cassette = yield* loadReplay(request)
|
|
||||||
const incoming = yield* snapshotRequest(request)
|
const incoming = yield* snapshotRequest(request)
|
||||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
const interactions = yield* replay.load.pipe(
|
||||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
Effect.mapError(() =>
|
||||||
|
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const result = sequential
|
||||||
|
? selectSequential(interactions, incoming, match, yield* replay.cursor)
|
||||||
|
: selectMatch(interactions, incoming, match)
|
||||||
|
if (!result.interaction)
|
||||||
|
return yield* Effect.fail(
|
||||||
|
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||||
|
)
|
||||||
|
if (sequential) yield* replay.advance
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -205,7 +138,7 @@ export const recordingLayer = (
|
|||||||
|
|
||||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||||
recordingLayer(name, options).pipe(
|
recordingLayer(name, options).pipe(
|
||||||
Layer.provide(CassetteService.layer({ directory: options.directory })),
|
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
export * from "./schema"
|
export type {
|
||||||
export * from "./redaction"
|
CassetteMetadata,
|
||||||
export * from "./matching"
|
HttpInteraction,
|
||||||
export * from "./diff"
|
Interaction,
|
||||||
export * from "./storage"
|
RequestSnapshot,
|
||||||
export * from "./websocket"
|
ResponseSnapshot,
|
||||||
export * from "./effect"
|
WebSocketFrame,
|
||||||
|
WebSocketInteraction,
|
||||||
|
} from "./schema"
|
||||||
|
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||||
|
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||||
|
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||||
|
export { UnsafeCassetteError } from "./recorder"
|
||||||
|
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||||
|
export {
|
||||||
|
makeWebSocketExecutor,
|
||||||
|
type WebSocketConnection,
|
||||||
|
type WebSocketExecutor,
|
||||||
|
type WebSocketRecordReplayOptions,
|
||||||
|
type WebSocketRequest,
|
||||||
|
} from "./websocket"
|
||||||
|
|
||||||
export * as Cassette from "./cassette"
|
export * as Cassette from "./cassette"
|
||||||
|
export * as Redactor from "./redactor"
|
||||||
|
|
||||||
export * as HttpRecorder from "."
|
export * as HttpRecorder from "."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Option, Schema } from "effect"
|
import { Option, Schema } from "effect"
|
||||||
import type { RequestSnapshot } from "./schema"
|
import { REDACTED, secretFindings } from "./redaction"
|
||||||
|
import type { HttpInteraction, RequestSnapshot } from "./schema"
|
||||||
|
|
||||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||||
@@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
|||||||
|
|
||||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||||
|
|
||||||
|
const safeText = (value: unknown) => {
|
||||||
|
if (value === undefined) return "undefined"
|
||||||
|
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||||
|
const text = JSON.stringify(value)
|
||||||
|
if (!text) return String(value)
|
||||||
|
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||||
|
|
||||||
|
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||||
|
if (Object.is(expected, received)) return []
|
||||||
|
if (isRecord(expected) && isRecord(received)) {
|
||||||
|
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||||
|
.toSorted()
|
||||||
|
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||||
|
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||||
|
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||||
|
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||||
|
if (expected[key] === received[key]) return []
|
||||||
|
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||||
|
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||||
|
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
|
||||||
|
const lines: string[] = []
|
||||||
|
if (expected.method !== received.method) {
|
||||||
|
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||||
|
}
|
||||||
|
if (expected.url !== received.url) {
|
||||||
|
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||||
|
}
|
||||||
|
const headers = headerDiffs(expected.headers, received.headers)
|
||||||
|
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||||
|
const expectedBody = jsonBody(expected.body)
|
||||||
|
const receivedBody = jsonBody(received.body)
|
||||||
|
const body =
|
||||||
|
expectedBody !== undefined && receivedBody !== undefined
|
||||||
|
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||||
|
: expected.body === received.body
|
||||||
|
? []
|
||||||
|
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||||
|
if (body.length > 0) lines.push("body:", ...body)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
|
||||||
|
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
||||||
|
const ranked = interactions
|
||||||
|
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
||||||
|
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
||||||
|
const best = ranked[0]
|
||||||
|
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectMatch = (
|
||||||
|
interactions: ReadonlyArray<HttpInteraction>,
|
||||||
|
incoming: RequestSnapshot,
|
||||||
|
match: RequestMatcher,
|
||||||
|
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||||
|
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||||
|
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectSequential = (
|
||||||
|
interactions: ReadonlyArray<HttpInteraction>,
|
||||||
|
incoming: RequestSnapshot,
|
||||||
|
match: RequestMatcher,
|
||||||
|
index: number,
|
||||||
|
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||||
|
const interaction = interactions[index]
|
||||||
|
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||||
|
if (!match(incoming, interaction.request))
|
||||||
|
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||||
|
return { interaction, detail: "" }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Effect, Ref, Schema, Scope } from "effect"
|
||||||
|
import type * as CassetteService from "./cassette"
|
||||||
|
import type { CassetteNotFoundError } from "./cassette"
|
||||||
|
import { SecretFindingSchema } from "./redaction"
|
||||||
|
import type { CassetteMetadata, Interaction } from "./schema"
|
||||||
|
|
||||||
|
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||||
|
cassetteName: Schema.String,
|
||||||
|
findings: Schema.Array(SecretFindingSchema),
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||||
|
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||||
|
.join(", ")}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||||
|
|
||||||
|
const isCI = () => {
|
||||||
|
const value = process.env.CI
|
||||||
|
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (isCI()) return "replay"
|
||||||
|
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||||
|
})
|
||||||
|
|
||||||
|
export const appendOrFail = (
|
||||||
|
cassette: CassetteService.Interface,
|
||||||
|
name: string,
|
||||||
|
interaction: Interaction,
|
||||||
|
metadata: CassetteMetadata | undefined,
|
||||||
|
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||||
|
cassette
|
||||||
|
.append(name, interaction, metadata)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap(({ findings }) =>
|
||||||
|
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ReplayState<T> {
|
||||||
|
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||||
|
readonly cursor: Effect.Effect<number>
|
||||||
|
readonly advance: Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeReplayState = <T>(
|
||||||
|
cassette: CassetteService.Interface,
|
||||||
|
name: string,
|
||||||
|
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||||
|
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||||
|
const position = yield* Ref.make(0)
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const used = yield* Ref.get(position)
|
||||||
|
if (used === 0) return
|
||||||
|
const interactions = yield* load.pipe(Effect.orDie)
|
||||||
|
if (used < interactions.length)
|
||||||
|
yield* Effect.die(
|
||||||
|
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) }
|
||||||
|
})
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Cassette } from "./schema"
|
|
||||||
|
|
||||||
export const REDACTED = "[REDACTED]"
|
export const REDACTED = "[REDACTED]"
|
||||||
|
|
||||||
const DEFAULT_REDACT_HEADERS = [
|
const DEFAULT_REDACT_HEADERS = [
|
||||||
@@ -97,10 +95,13 @@ export const redactHeaders = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SecretFinding = {
|
import { Schema } from "effect"
|
||||||
readonly path: string
|
|
||||||
readonly reason: string
|
export const SecretFindingSchema = Schema.Struct({
|
||||||
}
|
path: Schema.String,
|
||||||
|
reason: Schema.String,
|
||||||
|
})
|
||||||
|
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
||||||
|
|
||||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||||
stringEntries(value).flatMap((entry) => [
|
stringEntries(value).flatMap((entry) => [
|
||||||
@@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
|||||||
.filter((item) => entry.value.includes(item.value))
|
.filter((item) => entry.value.includes(item.value))
|
||||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Option } from "effect"
|
||||||
|
import { decodeJson } from "./matching"
|
||||||
|
import { redactHeaders, redactUrl } from "./redaction"
|
||||||
|
import type { RequestSnapshot, ResponseSnapshot } from "./schema"
|
||||||
|
|
||||||
|
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||||
|
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||||
|
|
||||||
|
const identity = <T>(value: T) => value
|
||||||
|
|
||||||
|
export interface Redactor {
|
||||||
|
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
||||||
|
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
||||||
|
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
|
||||||
|
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
|
||||||
|
return {
|
||||||
|
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
|
||||||
|
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderOptions {
|
||||||
|
readonly allow?: ReadonlyArray<string>
|
||||||
|
readonly redact?: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||||
|
request: (snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||||
|
response: (snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface UrlOptions {
|
||||||
|
readonly query?: ReadonlyArray<string>
|
||||||
|
readonly transform?: (url: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
||||||
|
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
|
||||||
|
request: (snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
body: Option.match(decodeJson(snapshot.body), {
|
||||||
|
onNone: () => snapshot.body,
|
||||||
|
onSome: (parsed) => JSON.stringify(transform(parsed)),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface DefaultRedactorOverrides {
|
||||||
|
readonly requestHeaders?: HeaderOptions
|
||||||
|
readonly responseHeaders?: HeaderOptions
|
||||||
|
readonly url?: UrlOptions
|
||||||
|
readonly body?: (parsed: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
|
||||||
|
compose(
|
||||||
|
requestHeaders(overrides.requestHeaders),
|
||||||
|
responseHeaders(overrides.responseHeaders),
|
||||||
|
url(overrides.url),
|
||||||
|
...(overrides.body ? [body(overrides.body)] : []),
|
||||||
|
)
|
||||||
@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
|
|||||||
|
|
||||||
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
||||||
|
|
||||||
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
|
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
||||||
|
|
||||||
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
|
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||||
|
interactions.filter(isWebSocketInteraction)
|
||||||
|
|
||||||
export const CassetteSchema = Schema.Struct({
|
export const CassetteSchema = Schema.Struct({
|
||||||
version: Schema.Literal(1),
|
version: Schema.Literal(1),
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Option } from "effect"
|
|
||||||
import * as fs from "node:fs"
|
|
||||||
import * as path from "node:path"
|
|
||||||
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
|
||||||
|
|
||||||
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
|
||||||
|
|
||||||
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
|
|
||||||
|
|
||||||
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
|
|
||||||
name,
|
|
||||||
recordedAt: new Date().toISOString(),
|
|
||||||
...(metadata ?? {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const cassetteFor = (
|
|
||||||
name: string,
|
|
||||||
interactions: ReadonlyArray<Interaction>,
|
|
||||||
metadata: CassetteMetadata | undefined,
|
|
||||||
): Cassette => ({
|
|
||||||
version: 1,
|
|
||||||
metadata: metadataFor(name, metadata),
|
|
||||||
interactions,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
|
||||||
|
|
||||||
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
|
||||||
|
|
||||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
|
|
||||||
const file = cassettePath(name, options.directory)
|
|
||||||
if (!fs.existsSync(file)) return false
|
|
||||||
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
|
|||||||
import type { Headers } from "effect/unstable/http"
|
import type { Headers } from "effect/unstable/http"
|
||||||
import * as CassetteService from "./cassette"
|
import * as CassetteService from "./cassette"
|
||||||
import { canonicalizeJson, decodeJson } from "./matching"
|
import { canonicalizeJson, decodeJson } from "./matching"
|
||||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
|
import type { RecordReplayMode } from "./effect"
|
||||||
|
import { defaults, type Redactor } from "./redactor"
|
||||||
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||||
|
|
||||||
export interface WebSocketRequest {
|
export interface WebSocketRequest {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -24,67 +24,36 @@ export interface WebSocketExecutor<E> {
|
|||||||
|
|
||||||
export interface WebSocketRecordReplayOptions<E> {
|
export interface WebSocketRecordReplayOptions<E> {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly mode?: "record" | "replay" | "passthrough"
|
readonly mode?: RecordReplayMode
|
||||||
readonly metadata?: CassetteMetadata
|
readonly metadata?: CassetteMetadata
|
||||||
readonly cassette: CassetteService.Interface
|
readonly cassette: CassetteService.Interface
|
||||||
readonly live: WebSocketExecutor<E>
|
readonly live: WebSocketExecutor<E>
|
||||||
readonly redact?: {
|
readonly redactor?: Redactor
|
||||||
readonly headers?: ReadonlyArray<string>
|
|
||||||
readonly query?: ReadonlyArray<string>
|
|
||||||
readonly url?: (url: string) => string
|
|
||||||
}
|
|
||||||
readonly requestHeaders?: ReadonlyArray<string>
|
|
||||||
readonly compareClientMessagesAsJson?: boolean
|
readonly compareClientMessagesAsJson?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const headersRecord = (headers: Headers.Headers) =>
|
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(headers as Record<string, unknown>)
|
Object.entries(headers as Record<string, unknown>).filter(
|
||||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const openSnapshot = (
|
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||||
request: WebSocketRequest,
|
|
||||||
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
|
|
||||||
) => ({
|
|
||||||
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
|
|
||||||
headers: redactHeaders(
|
|
||||||
headersRecord(request.headers),
|
|
||||||
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
|
|
||||||
options.redact?.headers,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
|
|
||||||
|
|
||||||
const frameText = (frame: WebSocketFrame) => {
|
|
||||||
if (frame.kind === "text") return frame.body
|
|
||||||
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameMessage = (frame: WebSocketFrame) =>
|
|
||||||
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
|
||||||
|
|
||||||
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
|
|
||||||
typeof message === "string"
|
typeof message === "string"
|
||||||
? textFrame(message)
|
? { kind: "text", body: message }
|
||||||
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||||
|
|
||||||
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
|
||||||
new Error(
|
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||||
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
|
|
||||||
.map((item) => `${item.path} (${item.reason})`)
|
|
||||||
.join(", ")}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const mismatch = (message: string, actual: unknown, expected: unknown) =>
|
const decodeFrameText = (frame: WebSocketFrame) =>
|
||||||
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||||
|
|
||||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||||
throw mismatch(message, actual, expected)
|
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||||
@@ -94,7 +63,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
|
|||||||
return Effect.sync(() => {
|
return Effect.sync(() => {
|
||||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
||||||
})
|
})
|
||||||
const expectedText = frameText(expected)
|
const expectedText = decodeFrameText(expected)
|
||||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||||
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
||||||
}
|
}
|
||||||
@@ -103,7 +72,18 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
options: WebSocketRecordReplayOptions<E>,
|
options: WebSocketRecordReplayOptions<E>,
|
||||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const mode = options.mode ?? "replay"
|
const requested = options.mode ?? "auto"
|
||||||
|
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
|
||||||
|
const redactor = options.redactor ?? defaults()
|
||||||
|
const openSnapshot = (request: WebSocketRequest) => {
|
||||||
|
const redacted = redactor.request({
|
||||||
|
method: "GET",
|
||||||
|
url: request.url,
|
||||||
|
headers: headersRecord(request.headers),
|
||||||
|
body: "",
|
||||||
|
})
|
||||||
|
return { url: redacted.url, headers: redacted.headers }
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "passthrough") return options.live
|
if (mode === "passthrough") return options.live
|
||||||
|
|
||||||
@@ -118,21 +98,21 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
const closeOnce = Effect.gen(function* () {
|
const closeOnce = Effect.gen(function* () {
|
||||||
if (yield* Ref.getAndSet(closed, true)) return
|
if (yield* Ref.getAndSet(closed, true)) return
|
||||||
yield* connection.close
|
yield* connection.close
|
||||||
const result = yield* options.cassette
|
yield* appendOrFail(
|
||||||
.append(
|
options.cassette,
|
||||||
options.name,
|
options.name,
|
||||||
{ transport: "websocket", open: openSnapshot(request, options), client, server },
|
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||||
options.metadata,
|
options.metadata,
|
||||||
)
|
).pipe(Effect.orDie)
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
|
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
sendText: (message: string) =>
|
sendText: (message) =>
|
||||||
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
|
connection
|
||||||
|
.sendText(message)
|
||||||
|
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||||
messages: connection.messages.pipe(
|
messages: connection.messages.pipe(
|
||||||
Stream.map((message) => {
|
Stream.map((message) => {
|
||||||
server.push(receivedFrame(message))
|
server.push(encodeFrame(message))
|
||||||
return message
|
return message
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -142,44 +122,20 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
|
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
const cursor = yield* Ref.make(0)
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const input = yield* Ref.get(replay)
|
|
||||||
if (!input) return
|
|
||||||
yield* assertEqual(
|
|
||||||
`Unused recorded WebSocket interactions in ${options.name}`,
|
|
||||||
yield* Ref.get(cursor),
|
|
||||||
input.interactions.length,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
|
|
||||||
const cached = yield* Ref.get(replay)
|
|
||||||
if (cached) return cached
|
|
||||||
const input = {
|
|
||||||
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
|
|
||||||
}
|
|
||||||
yield* Ref.set(replay, input)
|
|
||||||
return input
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open: (request) => {
|
open: (request) =>
|
||||||
return Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* loadReplay()
|
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
const index = yield* replay.cursor
|
||||||
const interaction = input.interactions[index]
|
const interaction = interactions[index]
|
||||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
|
yield* replay.advance
|
||||||
|
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||||
const messageIndex = yield* Ref.make(0)
|
const messageIndex = yield* Ref.make(0)
|
||||||
return {
|
return {
|
||||||
sendText: (message: string) =>
|
sendText: (message) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||||
yield* compareClientMessage(
|
yield* compareClientMessage(
|
||||||
@@ -189,7 +145,7 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
options.compareClientMessagesAsJson === true,
|
options.compareClientMessagesAsJson === true,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
|
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||||
close: Effect.gen(function* () {
|
close: Effect.gen(function* () {
|
||||||
yield* assertEqual(
|
yield* assertEqual(
|
||||||
`WebSocket client frame count for interaction ${index + 1}`,
|
`WebSocket client frame count for interaction ${index + 1}`,
|
||||||
@@ -198,7 +154,6 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/* This file is auto-generated by SST. Do not edit. */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
@@ -6,7 +6,16 @@ import * as fs from "node:fs"
|
|||||||
import * as os from "node:os"
|
import * as os from "node:os"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { HttpRecorder } from "../src"
|
import { HttpRecorder } from "../src"
|
||||||
import { redactedErrorRequest } from "../src/diff"
|
import { redactedErrorRequest } from "../src/effect"
|
||||||
|
import type { Interaction } from "../src/schema"
|
||||||
|
|
||||||
|
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cassette = yield* HttpRecorder.Cassette.Service
|
||||||
|
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
|
||||||
|
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
const post = (url: string, body: object) =>
|
const post = (url: string, body: object) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -33,7 +42,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
|
|||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||||
),
|
),
|
||||||
Effect.provide(NodeFileSystem.layer),
|
Effect.provide(NodeFileSystem.layer),
|
||||||
),
|
),
|
||||||
@@ -108,7 +117,7 @@ describe("http-recorder", () => {
|
|||||||
|
|
||||||
test("detects secret-looking values without returning the secret", () => {
|
test("detects secret-looking values without returning the secret", () => {
|
||||||
expect(
|
expect(
|
||||||
HttpRecorder.cassetteSecretFindings({
|
HttpRecorder.secretFindings({
|
||||||
version: 1,
|
version: 1,
|
||||||
interactions: [
|
interactions: [
|
||||||
{
|
{
|
||||||
@@ -136,7 +145,7 @@ describe("http-recorder", () => {
|
|||||||
|
|
||||||
test("detects secret-looking values inside metadata", () => {
|
test("detects secret-looking values inside metadata", () => {
|
||||||
expect(
|
expect(
|
||||||
HttpRecorder.cassetteSecretFindings({
|
HttpRecorder.secretFindings({
|
||||||
version: 1,
|
version: 1,
|
||||||
metadata: { token: "sk-123456789012345678901234" },
|
metadata: { token: "sk-123456789012345678901234" },
|
||||||
interactions: [],
|
interactions: [],
|
||||||
@@ -144,60 +153,42 @@ describe("http-recorder", () => {
|
|||||||
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats websocket cassettes with shared metadata", () => {
|
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
|
||||||
const cassette = HttpRecorder.cassetteFor(
|
await Effect.runPromise(
|
||||||
"websocket/basic",
|
Effect.scoped(
|
||||||
[
|
Effect.gen(function* () {
|
||||||
{
|
const cassette = yield* HttpRecorder.Cassette.Service
|
||||||
transport: "websocket",
|
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
name: "websocket/replay",
|
||||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
cassette,
|
||||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
compareClientMessagesAsJson: true,
|
||||||
},
|
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||||
],
|
})
|
||||||
{ provider: "openai" },
|
const connection = yield* executor.open({
|
||||||
)
|
url: "wss://example.test/realtime",
|
||||||
|
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||||
|
})
|
||||||
|
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||||
|
const messages: Array<string | Uint8Array> = []
|
||||||
|
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||||
|
yield* connection.close
|
||||||
|
|
||||||
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
|
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||||
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
|
}).pipe(
|
||||||
})
|
Effect.provide(
|
||||||
|
HttpRecorder.Cassette.memory({
|
||||||
test("replays websocket interactions from the shared cassette service", async () => {
|
"websocket/replay": [
|
||||||
await runRecorder(
|
{
|
||||||
Effect.gen(function* () {
|
transport: "websocket",
|
||||||
const cassette = yield* HttpRecorder.Cassette.Service
|
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||||
yield* cassette.write(
|
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||||
"websocket/replay",
|
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||||
HttpRecorder.cassetteFor(
|
},
|
||||||
"websocket/replay",
|
],
|
||||||
[
|
}),
|
||||||
{
|
|
||||||
transport: "websocket",
|
|
||||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
|
||||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
|
||||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
),
|
||||||
name: "websocket/replay",
|
|
||||||
cassette,
|
|
||||||
compareClientMessagesAsJson: true,
|
|
||||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
|
||||||
})
|
|
||||||
const connection = yield* executor.open({
|
|
||||||
url: "wss://example.test/realtime",
|
|
||||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
|
||||||
})
|
|
||||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
|
||||||
const messages: Array<string | Uint8Array> = []
|
|
||||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
|
||||||
yield* connection.close
|
|
||||||
|
|
||||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -227,17 +218,14 @@ describe("http-recorder", () => {
|
|||||||
yield* connection.messages.pipe(Stream.runDrain)
|
yield* connection.messages.pipe(Stream.runDrain)
|
||||||
yield* connection.close
|
yield* connection.close
|
||||||
|
|
||||||
expect(yield* cassette.read("websocket/record")).toMatchObject({
|
expect(yield* cassette.read("websocket/record")).toMatchObject([
|
||||||
metadata: { name: "websocket/record", provider: "test" },
|
{
|
||||||
interactions: [
|
transport: "websocket",
|
||||||
{
|
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||||
transport: "websocket",
|
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
},
|
||||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
])
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -300,6 +288,49 @@ describe("http-recorder", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("auto mode replays when the cassette exists", async () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
|
||||||
|
await seedCassetteDirectory(directory, "auto-replay", [
|
||||||
|
{
|
||||||
|
transport: "http",
|
||||||
|
request: {
|
||||||
|
method: "POST",
|
||||||
|
url: "https://example.test/echo",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ step: 1 }),
|
||||||
|
},
|
||||||
|
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await runWith(
|
||||||
|
"auto-replay",
|
||||||
|
{ directory, mode: "auto" },
|
||||||
|
post("https://example.test/echo", { step: 1 }),
|
||||||
|
)
|
||||||
|
expect(result).toBe('{"reply":"hi"}')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
|
||||||
|
const previous = process.env.CI
|
||||||
|
process.env.CI = "true"
|
||||||
|
try {
|
||||||
|
const exit = await Effect.runPromise(
|
||||||
|
Effect.exit(
|
||||||
|
post("https://example.test/echo", { step: 1 }).pipe(
|
||||||
|
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CI
|
||||||
|
else process.env.CI = previous
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("mismatch diagnostics show closest redacted request differences", async () => {
|
test("mismatch diagnostics show closest redacted request differences", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
|||||||
stream: {
|
stream: {
|
||||||
event: Schema.String,
|
event: Schema.String,
|
||||||
initial: () => undefined,
|
initial: () => undefined,
|
||||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
|
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||||
onHalt: () => [{ type: "request-finish", reason: "stop" }],
|
onHalt: () => [{ type: "request-finish", reason: "stop" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
|
|||||||
import { Framing } from "../route/framing"
|
import { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
@@ -415,14 +415,13 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
|||||||
? String((block.content as Record<string, unknown>).type)
|
? String((block.content as Record<string, unknown>).type)
|
||||||
: ""
|
: ""
|
||||||
const isError = errorPayload.endsWith("_tool_result_error")
|
const isError = errorPayload.endsWith("_tool_result_error")
|
||||||
return {
|
return LLMEvent.toolResult({
|
||||||
type: "tool-result",
|
|
||||||
id: block.tool_use_id ?? "",
|
id: block.tool_use_id ?? "",
|
||||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||||
@@ -453,18 +452,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text) {
|
||||||
return [state, [{ type: "text-delta", text: block.text }]]
|
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "thinking" && block.thinking) {
|
if (block.type === "thinking" && block.thinking) {
|
||||||
return [
|
return [
|
||||||
state,
|
state,
|
||||||
[
|
[
|
||||||
{
|
LLMEvent.reasoningDelta({
|
||||||
type: "reasoning-delta",
|
id: `reasoning-${event.index ?? 0}`,
|
||||||
text: block.thinking,
|
text: block.thinking,
|
||||||
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -480,17 +478,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
|||||||
const delta = event.delta
|
const delta = event.delta
|
||||||
|
|
||||||
if (delta?.type === "text_delta" && delta.text) {
|
if (delta?.type === "text_delta" && delta.text) {
|
||||||
return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
|
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||||
return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
|
return [
|
||||||
|
state,
|
||||||
|
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
|
||||||
|
] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delta?.type === "signature_delta" && delta.signature) {
|
if (delta?.type === "signature_delta" && delta.signature) {
|
||||||
return [
|
return [
|
||||||
state,
|
state,
|
||||||
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
|
[
|
||||||
|
LLMEvent.reasoningEnd({
|
||||||
|
id: `reasoning-${event.index ?? 0}`,
|
||||||
|
providerMetadata: anthropicMetadata({ signature: delta.signature }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
] satisfies StepResult
|
] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,21 +530,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
|||||||
return [
|
return [
|
||||||
{ ...state, usage },
|
{ ...state, usage },
|
||||||
[
|
[
|
||||||
{
|
LLMEvent.requestFinish({
|
||||||
type: "request-finish",
|
|
||||||
reason: mapFinishReason(event.delta?.stop_reason),
|
reason: mapFinishReason(event.delta?.stop_reason),
|
||||||
usage,
|
usage,
|
||||||
...(event.delta?.stop_sequence
|
providerMetadata: event.delta?.stop_sequence
|
||||||
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
|
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||||
: {}),
|
: undefined,
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||||
state,
|
state,
|
||||||
[{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
|
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
|
||||||
]
|
]
|
||||||
|
|
||||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
|
|||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
@@ -400,13 +400,26 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.contentBlockDelta?.delta?.text) {
|
if (event.contentBlockDelta?.delta?.text) {
|
||||||
return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
|
return [
|
||||||
|
state,
|
||||||
|
[
|
||||||
|
LLMEvent.textDelta({
|
||||||
|
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
|
||||||
|
text: event.contentBlockDelta.delta.text,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||||
return [
|
return [
|
||||||
state,
|
state,
|
||||||
[{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
|
[
|
||||||
|
LLMEvent.reasoningDelta({
|
||||||
|
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
|
||||||
|
text: event.contentBlockDelta.delta.reasoningContent.text,
|
||||||
|
}),
|
||||||
|
],
|
||||||
] as const
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,16 +462,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
event.modelStreamErrorException?.message ??
|
event.modelStreamErrorException?.message ??
|
||||||
event.serviceUnavailableException?.message ??
|
event.serviceUnavailableException?.message ??
|
||||||
"Bedrock Converse stream error"
|
"Bedrock Converse stream error"
|
||||||
return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
|
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.validationException || event.throttlingException) {
|
if (event.validationException || event.throttlingException) {
|
||||||
const message =
|
const message =
|
||||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||||
return [
|
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
|
||||||
state,
|
|
||||||
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
|
|
||||||
] as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [state, []] as const
|
return [state, []] as const
|
||||||
@@ -468,7 +478,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
|
|||||||
|
|
||||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||||
state.pendingFinish
|
state.pendingFinish
|
||||||
? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
|
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
|
|||||||
import { Framing } from "../route/framing"
|
import { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
@@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
|||||||
|
|
||||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||||
state.finishReason || state.usage
|
state.finishReason || state.usage
|
||||||
? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
|
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const step = (state: ParserState, event: GeminiEvent) => {
|
const step = (state: ParserState, event: GeminiEvent) => {
|
||||||
@@ -332,14 +332,18 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
|
|
||||||
for (const part of candidate.content.parts) {
|
for (const part of candidate.content.parts) {
|
||||||
if ("text" in part && part.text.length > 0) {
|
if ("text" in part && part.text.length > 0) {
|
||||||
events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
|
events.push(
|
||||||
|
part.thought
|
||||||
|
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
|
||||||
|
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
const id = `tool_${nextToolCallId++}`
|
||||||
events.push({ type: "tool-call", id, name: part.functionCall.name, input })
|
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
|
||||||
hasToolCalls = true
|
hasToolCalls = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
|||||||
import { HttpTransport } from "../route/transport"
|
import { HttpTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
@@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const toolDeltas = delta?.tool_calls ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
let tools = state.tools
|
||||||
|
|
||||||
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
|
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content }))
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
for (const tool of toolDeltas) {
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
@@ -348,10 +348,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const hasToolCalls = state.toolCallEvents.length > 0
|
||||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||||
return [
|
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
|
||||||
...state.toolCallEvents,
|
|
||||||
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
|||||||
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
@@ -348,22 +348,20 @@ const hostedToolEvents = (
|
|||||||
const tool = HOSTED_TOOLS[item.type]
|
const tool = HOSTED_TOOLS[item.type]
|
||||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||||
return [
|
return [
|
||||||
{
|
LLMEvent.toolCall({
|
||||||
type: "tool-call",
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input: tool.input(item),
|
input: tool.input(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
},
|
}),
|
||||||
{
|
LLMEvent.toolResult({
|
||||||
type: "tool-result",
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
result: hostedToolResult(item),
|
result: hostedToolResult(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
},
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,17 +377,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
|||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
return [
|
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
|
||||||
state,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: "text-delta",
|
|
||||||
id: event.item_id,
|
|
||||||
text: event.delta,
|
|
||||||
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
@@ -458,30 +446,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
|||||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||||
state,
|
state,
|
||||||
[
|
[
|
||||||
{
|
LLMEvent.requestFinish({
|
||||||
type: "request-finish",
|
|
||||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||||
usage: mapUsage(event.response?.usage),
|
usage: mapUsage(event.response?.usage),
|
||||||
...(event.response?.id || event.response?.service_tier
|
providerMetadata:
|
||||||
? {
|
event.response?.id || event.response?.service_tier
|
||||||
providerMetadata: openaiMetadata({
|
? openaiMetadata({
|
||||||
responseId: event.response.id,
|
responseId: event.response.id,
|
||||||
serviceTier: event.response.service_tier,
|
serviceTier: event.response.service_tier,
|
||||||
}),
|
})
|
||||||
}
|
: undefined,
|
||||||
: {}),
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||||
state,
|
state,
|
||||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
|
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
|
||||||
]
|
]
|
||||||
|
|
||||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||||
state,
|
state,
|
||||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
|
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
|
||||||
]
|
]
|
||||||
|
|
||||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
@@ -49,34 +49,24 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
|
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
|
||||||
type: "tool-input-delta",
|
LLMEvent.toolInputDelta({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
text,
|
text,
|
||||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||||
Effect.map(
|
Effect.map(
|
||||||
(input): ToolCall =>
|
(input): ToolCall =>
|
||||||
tool.providerExecuted
|
LLMEvent.toolCall({
|
||||||
? {
|
id: tool.id,
|
||||||
type: "tool-call",
|
name: tool.name,
|
||||||
id: tool.id,
|
input,
|
||||||
name: tool.name,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
input,
|
providerMetadata: tool.providerMetadata,
|
||||||
providerExecuted: true,
|
}),
|
||||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "tool-call",
|
|
||||||
id: tool.id,
|
|
||||||
name: tool.name,
|
|
||||||
input,
|
|
||||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
|
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
|
||||||
import { ModelRef } from "./options"
|
import { ModelRef } from "./options"
|
||||||
import { ToolResultValue } from "./messages"
|
import { ToolResultValue } from "./messages"
|
||||||
|
|
||||||
@@ -14,60 +14,87 @@ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
|
|||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const RequestStart = Schema.Struct({
|
export const RequestStart = Schema.Struct({
|
||||||
type: Schema.Literal("request-start"),
|
type: Schema.tag("request-start"),
|
||||||
id: Schema.String,
|
id: ResponseID,
|
||||||
model: ModelRef,
|
model: ModelRef,
|
||||||
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
||||||
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
||||||
|
|
||||||
export const StepStart = Schema.Struct({
|
export const StepStart = Schema.Struct({
|
||||||
type: Schema.Literal("step-start"),
|
type: Schema.tag("step-start"),
|
||||||
index: Schema.Number,
|
index: Schema.Number,
|
||||||
}).annotate({ identifier: "LLM.Event.StepStart" })
|
}).annotate({ identifier: "LLM.Event.StepStart" })
|
||||||
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||||
|
|
||||||
export const TextStart = Schema.Struct({
|
export const TextStart = Schema.Struct({
|
||||||
type: Schema.Literal("text-start"),
|
type: Schema.tag("text-start"),
|
||||||
id: Schema.String,
|
id: ContentBlockID,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||||
|
|
||||||
export const TextDelta = Schema.Struct({
|
export const TextDelta = Schema.Struct({
|
||||||
type: Schema.Literal("text-delta"),
|
type: Schema.tag("text-delta"),
|
||||||
id: Schema.optional(Schema.String),
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||||
|
|
||||||
export const TextEnd = Schema.Struct({
|
export const TextEnd = Schema.Struct({
|
||||||
type: Schema.Literal("text-end"),
|
type: Schema.tag("text-end"),
|
||||||
id: Schema.String,
|
id: ContentBlockID,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||||
|
|
||||||
export const ReasoningDelta = Schema.Struct({
|
export const ReasoningStart = Schema.Struct({
|
||||||
type: Schema.Literal("reasoning-delta"),
|
type: Schema.tag("reasoning-start"),
|
||||||
id: Schema.optional(Schema.String),
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||||
|
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||||
|
|
||||||
|
export const ReasoningDelta = Schema.Struct({
|
||||||
|
type: Schema.tag("reasoning-delta"),
|
||||||
|
id: ContentBlockID,
|
||||||
|
text: Schema.String,
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||||
|
|
||||||
|
export const ReasoningEnd = Schema.Struct({
|
||||||
|
type: Schema.tag("reasoning-end"),
|
||||||
|
id: ContentBlockID,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||||
|
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||||
|
|
||||||
|
export const ToolInputStart = Schema.Struct({
|
||||||
|
type: Schema.tag("tool-input-start"),
|
||||||
|
id: ToolCallID,
|
||||||
|
name: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||||
|
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||||
|
|
||||||
export const ToolInputDelta = Schema.Struct({
|
export const ToolInputDelta = Schema.Struct({
|
||||||
type: Schema.Literal("tool-input-delta"),
|
type: Schema.tag("tool-input-delta"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||||
|
|
||||||
|
export const ToolInputEnd = Schema.Struct({
|
||||||
|
type: Schema.tag("tool-input-end"),
|
||||||
|
id: ToolCallID,
|
||||||
|
name: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||||
|
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||||
|
|
||||||
export const ToolCall = Schema.Struct({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.Literal("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
input: Schema.Unknown,
|
input: Schema.Unknown,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -76,8 +103,8 @@ export const ToolCall = Schema.Struct({
|
|||||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||||
|
|
||||||
export const ToolResult = Schema.Struct({
|
export const ToolResult = Schema.Struct({
|
||||||
type: Schema.Literal("tool-result"),
|
type: Schema.tag("tool-result"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
result: ToolResultValue,
|
result: ToolResultValue,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -86,8 +113,8 @@ export const ToolResult = Schema.Struct({
|
|||||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||||
|
|
||||||
export const ToolError = Schema.Struct({
|
export const ToolError = Schema.Struct({
|
||||||
type: Schema.Literal("tool-error"),
|
type: Schema.tag("tool-error"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -95,7 +122,7 @@ export const ToolError = Schema.Struct({
|
|||||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||||
|
|
||||||
export const StepFinish = Schema.Struct({
|
export const StepFinish = Schema.Struct({
|
||||||
type: Schema.Literal("step-finish"),
|
type: Schema.tag("step-finish"),
|
||||||
index: Schema.Number,
|
index: Schema.Number,
|
||||||
reason: FinishReason,
|
reason: FinishReason,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
@@ -104,7 +131,7 @@ export const StepFinish = Schema.Struct({
|
|||||||
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||||
|
|
||||||
export const RequestFinish = Schema.Struct({
|
export const RequestFinish = Schema.Struct({
|
||||||
type: Schema.Literal("request-finish"),
|
type: Schema.tag("request-finish"),
|
||||||
reason: FinishReason,
|
reason: FinishReason,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -112,7 +139,7 @@ export const RequestFinish = Schema.Struct({
|
|||||||
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
||||||
|
|
||||||
export const ProviderErrorEvent = Schema.Struct({
|
export const ProviderErrorEvent = Schema.Struct({
|
||||||
type: Schema.Literal("provider-error"),
|
type: Schema.tag("provider-error"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
retryable: Schema.optional(Schema.Boolean),
|
retryable: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -125,8 +152,12 @@ const llmEventTagged = Schema.Union([
|
|||||||
TextStart,
|
TextStart,
|
||||||
TextDelta,
|
TextDelta,
|
||||||
TextEnd,
|
TextEnd,
|
||||||
|
ReasoningStart,
|
||||||
ReasoningDelta,
|
ReasoningDelta,
|
||||||
|
ReasoningEnd,
|
||||||
|
ToolInputStart,
|
||||||
ToolInputDelta,
|
ToolInputDelta,
|
||||||
|
ToolInputEnd,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
@@ -135,20 +166,52 @@ const llmEventTagged = Schema.Union([
|
|||||||
ProviderErrorEvent,
|
ProviderErrorEvent,
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
|
||||||
|
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||||
|
|
||||||
|
const responseID = (value: ResponseID | string) => ResponseID.make(value)
|
||||||
|
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
|
||||||
|
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
|
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
|
||||||
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
||||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||||
*/
|
*/
|
||||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||||
|
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
|
||||||
|
stepStart: StepStart.make,
|
||||||
|
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||||
|
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||||
|
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||||
|
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
|
||||||
|
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||||
|
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
|
||||||
|
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||||
|
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
|
||||||
|
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||||
|
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
|
||||||
|
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||||
|
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
stepFinish: StepFinish.make,
|
||||||
|
requestFinish: RequestFinish.make,
|
||||||
|
providerError: ProviderErrorEvent.make,
|
||||||
is: {
|
is: {
|
||||||
requestStart: llmEventTagged.guards["request-start"],
|
requestStart: llmEventTagged.guards["request-start"],
|
||||||
stepStart: llmEventTagged.guards["step-start"],
|
stepStart: llmEventTagged.guards["step-start"],
|
||||||
textStart: llmEventTagged.guards["text-start"],
|
textStart: llmEventTagged.guards["text-start"],
|
||||||
textDelta: llmEventTagged.guards["text-delta"],
|
textDelta: llmEventTagged.guards["text-delta"],
|
||||||
textEnd: llmEventTagged.guards["text-end"],
|
textEnd: llmEventTagged.guards["text-end"],
|
||||||
|
reasoningStart: llmEventTagged.guards["reasoning-start"],
|
||||||
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
||||||
|
reasoningEnd: llmEventTagged.guards["reasoning-end"],
|
||||||
|
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||||
|
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||||
toolCall: llmEventTagged.guards["tool-call"],
|
toolCall: llmEventTagged.guards["tool-call"],
|
||||||
toolResult: llmEventTagged.guards["tool-result"],
|
toolResult: llmEventTagged.guards["tool-result"],
|
||||||
toolError: llmEventTagged.guards["tool-error"],
|
toolError: llmEventTagged.guards["tool-error"],
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type
|
|||||||
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
|
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
|
||||||
export type ProviderID = typeof ProviderID.Type
|
export type ProviderID = typeof ProviderID.Type
|
||||||
|
|
||||||
|
export const ResponseID = Schema.String
|
||||||
|
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
|
||||||
|
|
||||||
|
export const ContentBlockID = Schema.String
|
||||||
|
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||||
|
|
||||||
|
export const ToolCallID = Schema.String
|
||||||
|
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
|
||||||
|
|
||||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type ContentPart,
|
type ContentPart,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMError,
|
type LLMError,
|
||||||
type LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
Message,
|
Message,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
@@ -115,11 +115,19 @@ interface StepState {
|
|||||||
|
|
||||||
const accumulate = (state: StepState, event: LLMEvent) => {
|
const accumulate = (state: StepState, event: LLMEvent) => {
|
||||||
if (event.type === "text-delta") {
|
if (event.type === "text-delta") {
|
||||||
appendStreamingText(state, "text", event.text, event.providerMetadata)
|
appendStreamingText(state, "text", event.text, undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "reasoning-delta") {
|
if (event.type === "reasoning-delta") {
|
||||||
appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
|
appendStreamingText(state, "reasoning", event.text, undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "reasoning-end") {
|
||||||
|
appendStreamingText(state, "reasoning", "", event.providerMetadata)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "text-end") {
|
||||||
|
appendStreamingText(state, "text", "", event.providerMetadata)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "tool-call") {
|
if (event.type === "tool-call") {
|
||||||
@@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
|
|||||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||||
result.type === "error"
|
result.type === "error"
|
||||||
? [
|
? [
|
||||||
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
|
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
|
||||||
{ type: "tool-result", id: call.id, name: call.name, result },
|
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
|
||||||
]
|
]
|
||||||
: [{ type: "tool-result", id: call.id, name: call.name, result }]
|
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
|
||||||
|
|
||||||
const followUpRequest = (
|
const followUpRequest = (
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/* This file is auto-generated by SST. Do not edit. */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
@@ -50,7 +50,9 @@ const request = LLM.request({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||||
event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
|
event.type === "finish"
|
||||||
|
? { type: "request-finish", reason: event.reason }
|
||||||
|
: { type: "text-delta", id: "text-0", text: event.text }
|
||||||
|
|
||||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||||
id: "fake",
|
id: "fake",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
|
|||||||
expect(
|
expect(
|
||||||
LLMResponse.text({
|
LLMResponse.text({
|
||||||
events: [
|
events: [
|
||||||
{ type: "text-delta", text: "hi" },
|
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||||
{ type: "request-finish", reason: "stop" },
|
{ type: "request-finish", reason: "stop" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Redactor } from "@opencode-ai/http-recorder"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMError } from "../../src"
|
import { LLM, LLMError } from "../../src"
|
||||||
@@ -30,7 +31,7 @@ const recorded = recordedTests({
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
protocol: "anthropic-messages",
|
protocol: "anthropic-messages",
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
requires: ["ANTHROPIC_API_KEY"],
|
||||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Anthropic Messages sad-path recorded", () => {
|
describe("Anthropic Messages sad-path recorded", () => {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
})
|
})
|
||||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
|
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||||
})
|
})
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ describe("Gemini route", () => {
|
|||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
})
|
})
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "reasoning-delta", text: "thinking" },
|
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||||
{ type: "text-delta", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Redactor } from "@opencode-ai/http-recorder"
|
||||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||||
import * as Gemini from "../../src/protocols/gemini"
|
import * as Gemini from "../../src/protocols/gemini"
|
||||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||||
@@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) =>
|
|||||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||||
|
|
||||||
const cloudflareOptions = {
|
const cloudflareOptions = {
|
||||||
redact: { url: redactCloudflareURL },
|
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
describeRecordedGoldenScenarios([
|
describeRecordedGoldenScenarios([
|
||||||
@@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([
|
|||||||
prefix: "anthropic-messages",
|
prefix: "anthropic-messages",
|
||||||
model: anthropicHaiku,
|
model: anthropicHaiku,
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
requires: ["ANTHROPIC_API_KEY"],
|
||||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||||
scenarios: ["text", "tool-call"],
|
scenarios: ["text", "tool-call"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([
|
|||||||
model: anthropicOpus,
|
model: anthropicOpus,
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
requires: ["ANTHROPIC_API_KEY"],
|
||||||
tags: ["flagship"],
|
tags: ["flagship"],
|
||||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||||
scenarios: [{ id: "tool-loop", temperature: false }],
|
scenarios: [{ id: "tool-loop", temperature: false }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -225,8 +225,8 @@ describe("OpenAI Chat route", () => {
|
|||||||
|
|
||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "text-delta", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
|
|||||||
@@ -336,8 +336,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
|
|
||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||||
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
@@ -394,14 +394,12 @@ describe("OpenAI Responses route", () => {
|
|||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
text: '{"query"',
|
text: '{"query"',
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-input-delta",
|
type: "tool-input-delta",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
text: ':"weather"}',
|
text: ':"weather"}',
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
...metadata,
|
...metadata,
|
||||||
}
|
}
|
||||||
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
|
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
|
||||||
const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
|
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
const requestExecutor = RequestExecutor.layer.pipe(
|
const requestExecutor = RequestExecutor.layer.pipe(
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
|
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { WebSocketExecutor } from "../src/route"
|
import { WebSocketExecutor } from "../src/route"
|
||||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||||
|
|
||||||
const liveWebSocket = WebSocketExecutor.open
|
const liveWebSocket = WebSocketExecutor.open
|
||||||
type Mode = "record" | "replay" | "passthrough"
|
|
||||||
|
|
||||||
export const webSocketCassetteLayer = (
|
export const webSocketCassetteLayer = (
|
||||||
cassette: string,
|
cassette: string,
|
||||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
|
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
|
||||||
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
|
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
WebSocketExecutor.Service,
|
WebSocketExecutor.Service,
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
|
||||||
|
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
|
||||||
|
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
|
||||||
|
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
|
||||||
|
|
||||||
# Module shape
|
# Module shape
|
||||||
|
|
||||||
Do not use `export namespace Foo { ... }` for module organization. It is not
|
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.44",
|
"version": "1.14.46",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -119,6 +119,7 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# OpenAPI Translation Cleanup Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
|
||||||
|
|
||||||
|
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
|
||||||
|
|
||||||
|
## Current Culprit
|
||||||
|
|
||||||
|
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
|
||||||
|
|
||||||
|
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
|
||||||
|
|
||||||
|
## Non-Negotiables
|
||||||
|
|
||||||
|
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
|
||||||
|
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
|
||||||
|
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
|
||||||
|
- Prefer endpoint or schema annotations over post-generation spec surgery.
|
||||||
|
- Remove one category of rewrite at a time, with focused compatibility checks.
|
||||||
|
|
||||||
|
## PR Checklist
|
||||||
|
|
||||||
|
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
|
||||||
|
|
||||||
|
Current combined PR scope:
|
||||||
|
|
||||||
|
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
|
||||||
|
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
|
||||||
|
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
|
||||||
|
|
||||||
|
### PR 1: Add OpenAPI/Runtime Query Drift Tests
|
||||||
|
|
||||||
|
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
|
||||||
|
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
|
||||||
|
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
|
||||||
|
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
|
||||||
|
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
|
||||||
|
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
|
||||||
|
- `[x]` Keep this part test-only.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||||
|
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 2: Delete Spec-Only Workspace Query Injection
|
||||||
|
|
||||||
|
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||||
|
- `[x]` Delete `InstanceQueryParameters`.
|
||||||
|
- `[x]` Delete the `isInstanceRoute` constant.
|
||||||
|
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
|
||||||
|
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
|
||||||
|
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
|
||||||
|
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
|
||||||
|
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
|
||||||
|
|
||||||
|
Expected code shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||||
|
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
|
||||||
|
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
|
||||||
|
|
||||||
|
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||||
|
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
|
||||||
|
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
|
||||||
|
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
|
||||||
|
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
|
||||||
|
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
|
||||||
|
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Focused HTTP tests for changed query fields.
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated SDK request param types before deleting each override.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 4: Move Path Parameter Patterns Into ID Schemas
|
||||||
|
|
||||||
|
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||||
|
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||||
|
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||||
|
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- `[x]` `sessionID`
|
||||||
|
- `[x]` `messageID`
|
||||||
|
- `[x]` `partID`
|
||||||
|
- `[x]` `permissionID`
|
||||||
|
- `[x]` `ptyID`
|
||||||
|
|
||||||
|
- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated path param types and patterns.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
|
||||||
|
|
||||||
|
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
|
||||||
|
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
|
||||||
|
- Update handlers to fail with the declared API errors at the boundary.
|
||||||
|
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
|
||||||
|
- Do this group by group, starting with one small route group.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- `groups/config.ts` `PATCH /config` bad request.
|
||||||
|
- `groups/session.ts` endpoints that already translate domain not-found errors.
|
||||||
|
- `groups/file.ts` if any handler currently relies on built-in error shape.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Focused HTTP tests asserting response body shape for changed error paths.
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect SDK error union diff.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
|
||||||
|
|
||||||
|
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
|
||||||
|
- Decide whether SDK should expose auth in generated operation metadata.
|
||||||
|
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
|
||||||
|
- If removing it, update SDK generation expectations and docs in the same PR.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated client call signatures and error unions.
|
||||||
|
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
|
||||||
|
|
||||||
|
### PR 7: Tackle Component Shape Rewrites One At A Time
|
||||||
|
|
||||||
|
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
|
||||||
|
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
|
||||||
|
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
|
||||||
|
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
|
||||||
|
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated SDK type-name and optionality diffs.
|
||||||
|
|
||||||
|
## Upstream Middleware Query Support
|
||||||
|
|
||||||
|
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
|
||||||
|
|
||||||
|
Target in `effect-smol`:
|
||||||
|
|
||||||
|
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
|
||||||
|
- Make runtime request decoding include middleware query schemas.
|
||||||
|
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
|
||||||
|
|
||||||
|
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
|
||||||
|
|
||||||
|
## Suggested PR Order
|
||||||
|
|
||||||
|
1. Add drift detection tests only.
|
||||||
|
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
|
||||||
|
3. Convert query type overrides into route/schema-level helpers where possible.
|
||||||
|
4. Convert path parameter overrides into schema annotations or upstream fixes.
|
||||||
|
5. Replace built-in error response rewrites with explicit declared API errors by route group.
|
||||||
|
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
|
||||||
|
|
||||||
|
## Verification Checklist Per PR
|
||||||
|
|
||||||
|
- Focused HTTP tests for changed routes.
|
||||||
|
- OpenAPI drift tests.
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated SDK diff for public API churn.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
@@ -26,9 +26,7 @@ import * as Option from "effect/Option"
|
|||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { zod } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
|
||||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
@@ -303,69 +301,70 @@ export const layer = Layer.effect(
|
|||||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencePath(value: string) {
|
function referencePrompt(reference: Reference.Resolved) {
|
||||||
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
|
|
||||||
return path.isAbsolute(value)
|
|
||||||
? value
|
|
||||||
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReference(reference: ReferenceEntry): ResolvedReference {
|
|
||||||
if (typeof reference === "string") {
|
|
||||||
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
|
|
||||||
return { kind: "local", path: referencePath(reference) }
|
|
||||||
}
|
|
||||||
return { kind: "git", repository: reference }
|
|
||||||
}
|
|
||||||
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
|
|
||||||
return { kind: "git", repository: reference.repository, branch: reference.branch }
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencePrompt(name: string, reference: ResolvedReference) {
|
|
||||||
if (reference.kind === "local") {
|
if (reference.kind === "local") {
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||||
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
|
|
||||||
`Local directory: ${reference.path}`,
|
`Local directory: ${reference.path}`,
|
||||||
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
||||||
|
`Return exact absolute file paths for findings whenever possible.`,
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.kind === "invalid") {
|
||||||
|
return [
|
||||||
|
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
|
||||||
|
`Configured repository: ${reference.repository}`,
|
||||||
|
`Problem: ${reference.message}`,
|
||||||
|
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||||
`You are Scout reference @${name}. This reference points to a git repository.`,
|
|
||||||
`Repository: ${reference.repository}`,
|
`Repository: ${reference.repository}`,
|
||||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||||
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
|
`Cached directory: ${reference.path}`,
|
||||||
|
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
|
||||||
|
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
|
||||||
|
`Return exact absolute file paths for findings whenever possible.`,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function referenceDescription(reference: Reference.Resolved) {
|
||||||
|
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
|
||||||
|
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
|
||||||
|
return `Invalid Scout reference for repository ${reference.repository}`
|
||||||
|
}
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
||||||
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
|
const resolvedReferences = Reference.resolveAll({
|
||||||
if (agents[name]) continue
|
references: cfg.reference ?? {},
|
||||||
const resolved = resolveReference(reference)
|
directory: ctx.directory,
|
||||||
const localPath = resolved.kind === "local" ? resolved.path : undefined
|
worktree: ctx.worktree,
|
||||||
agents[name] = {
|
})
|
||||||
name,
|
for (const resolved of resolvedReferences) {
|
||||||
description:
|
if (agents[resolved.name]) continue
|
||||||
resolved.kind === "local"
|
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
|
||||||
? `Scout reference for local directory ${resolved.path}`
|
agents[resolved.name] = {
|
||||||
: `Scout reference for repository ${resolved.repository}`,
|
name: resolved.name,
|
||||||
|
description: referenceDescription(resolved),
|
||||||
permission: Permission.merge(
|
permission: Permission.merge(
|
||||||
agents.scout.permission,
|
agents.scout.permission,
|
||||||
Permission.fromConfig(
|
Permission.fromConfig({
|
||||||
localPath
|
repo_clone: "deny",
|
||||||
|
...(localPath
|
||||||
? {
|
? {
|
||||||
external_directory: {
|
external_directory: {
|
||||||
[localPath]: "allow",
|
[localPath]: "allow",
|
||||||
[path.join(localPath, "*")]: "allow",
|
[path.join(localPath, "*")]: "allow",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {},
|
: {}),
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
prompt: referencePrompt(name, resolved),
|
prompt: referencePrompt(resolved),
|
||||||
options: { reference },
|
options: { reference: cfg.reference?.[resolved.name], resolved },
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Permission } from "../permission"
|
||||||
|
import type { Agent } from "./agent"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||||
|
* via the task tool. Combines:
|
||||||
|
*
|
||||||
|
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||||
|
* restrictions live on the agent ruleset, not on the session, so a
|
||||||
|
* subagent that only inherited the parent SESSION's permission would
|
||||||
|
* silently bypass them. (#26514)
|
||||||
|
* 2. The parent **session's** deny rules and external_directory rules —
|
||||||
|
* same forwarding the original code already did.
|
||||||
|
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||||
|
* doesn't already permit them.
|
||||||
|
*/
|
||||||
|
export function deriveSubagentSessionPermission(input: {
|
||||||
|
parentSessionPermission: Permission.Ruleset
|
||||||
|
parentAgent: Agent.Info | undefined
|
||||||
|
subagent: Agent.Info
|
||||||
|
}): Permission.Ruleset {
|
||||||
|
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||||
|
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||||
|
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||||
|
return [
|
||||||
|
...parentAgentDenies,
|
||||||
|
...input.parentSessionPermission.filter(
|
||||||
|
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||||
|
),
|
||||||
|
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||||
|
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -2,3 +2,8 @@ declare module "*.wav" {
|
|||||||
const file: string
|
const file: string
|
||||||
export default file
|
export default file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "*.wasm" {
|
||||||
|
const file: string
|
||||||
|
export default file
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ const appBindingCommands = [
|
|||||||
"theme.mode.lock",
|
"theme.mode.lock",
|
||||||
"help.show",
|
"help.show",
|
||||||
"docs.open",
|
"docs.open",
|
||||||
"app.exit",
|
|
||||||
"app.debug",
|
"app.debug",
|
||||||
"app.console",
|
"app.console",
|
||||||
"app.heap_snapshot",
|
"app.heap_snapshot",
|
||||||
@@ -648,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
title: "Exit the app",
|
title: "Exit the app",
|
||||||
slashName: "exit",
|
slashName: "exit",
|
||||||
slashAliases: ["quit", "q"],
|
slashAliases: ["quit", "q"],
|
||||||
enabled: () => {
|
|
||||||
const current = promptRef.current
|
|
||||||
if (!current?.focused) return true
|
|
||||||
return current.current.input === ""
|
|
||||||
},
|
|
||||||
run: () => exit(),
|
run: () => exit(),
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
@@ -785,6 +779,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
useBindings(() => ({
|
||||||
|
enabled: () => {
|
||||||
|
const ok = command.matcher.get()
|
||||||
|
if (!ok) return false
|
||||||
|
const current = promptRef.current
|
||||||
|
if (!current?.focused) return true
|
||||||
|
return current.current.input === ""
|
||||||
|
},
|
||||||
|
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
||||||
|
}))
|
||||||
|
|
||||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||||
command.run(evt.properties.command)
|
command.run(evt.properties.command)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
...input.traits,
|
...input.traits,
|
||||||
...computePromptTraits({
|
...computePromptTraits({
|
||||||
mode: store.mode,
|
mode: store.mode,
|
||||||
disabled: !!props.disabled,
|
|
||||||
autocompleteVisible: !!auto()?.visible,
|
autocompleteVisible: !!auto()?.visible,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell"
|
|||||||
|
|
||||||
export interface PromptTraitsInput {
|
export interface PromptTraitsInput {
|
||||||
mode: PromptMode
|
mode: PromptMode
|
||||||
disabled: boolean
|
|
||||||
autocompleteVisible: boolean
|
autocompleteVisible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & {
|
|||||||
/**
|
/**
|
||||||
* Compute the textarea editor traits for the prompt.
|
* Compute the textarea editor traits for the prompt.
|
||||||
*
|
*
|
||||||
* `traits.suspend` gates the textarea's keybinding actions (backspace,
|
* The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits
|
||||||
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
|
* only expose capture/status metadata so focus changes cannot unsuspend the
|
||||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
* keymap-managed editor mappings.
|
||||||
* users can type in shell mode but cannot delete or move the cursor.
|
|
||||||
*/
|
*/
|
||||||
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||||
const capture =
|
const capture =
|
||||||
@@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
|||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
capture,
|
capture,
|
||||||
suspend: input.disabled,
|
|
||||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||||
owner: "opencode",
|
owner: "opencode",
|
||||||
role: "prompt",
|
role: "prompt",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
|
const context = createContext<{
|
||||||
|
path: () => string
|
||||||
|
format: (input?: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
|
||||||
|
return (
|
||||||
|
<context.Provider
|
||||||
|
value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePathFormatter() {
|
||||||
|
const value = useContext(context)
|
||||||
|
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPath(input: string | undefined, base: string | undefined) {
|
||||||
|
if (!input) return ""
|
||||||
|
|
||||||
|
const root = base || process.cwd()
|
||||||
|
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
|
||||||
|
const relative = path.relative(root, absolute)
|
||||||
|
|
||||||
|
if (!relative) return "."
|
||||||
|
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
|
||||||
|
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
|
||||||
|
return absolute.replace(Global.Path.home, "~")
|
||||||
|
}
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
KeymapProvider,
|
KeymapProvider,
|
||||||
reactiveMatcherFromSignal,
|
reactiveMatcherFromSignal,
|
||||||
useBindings,
|
|
||||||
useKeymap,
|
useKeymap,
|
||||||
useKeymapSelector,
|
useKeymapSelector,
|
||||||
|
useBindings,
|
||||||
} from "@opentui/keymap/solid"
|
} from "@opentui/keymap/solid"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { TuiConfig } from "./config/tui"
|
import type { TuiConfig } from "./config/tui"
|
||||||
@@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
|
|||||||
|
|
||||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||||
|
|
||||||
|
const KEY_ALIASES = {
|
||||||
|
enter: "return",
|
||||||
|
esc: "escape",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function expandKeyAliases(input: string) {
|
||||||
|
const result = Object.entries(KEY_ALIASES).reduce(
|
||||||
|
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
if (result === input) return
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerKeyAliases(keymap: OpenTuiKeymap) {
|
||||||
|
return keymap.appendBindingExpander((ctx) => {
|
||||||
|
const key = expandKeyAliases(ctx.input)
|
||||||
|
if (!key) return
|
||||||
|
return [{ key, displays: ctx.displays }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const inputCommands = [
|
const inputCommands = [
|
||||||
"input.move.left",
|
"input.move.left",
|
||||||
"input.move.right",
|
"input.move.right",
|
||||||
@@ -98,8 +120,13 @@ export function formatKeyBindings(
|
|||||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
|
export function registerOpencodeKeymap(
|
||||||
|
keymap: OpenTuiKeymap,
|
||||||
|
renderer: CliRenderer,
|
||||||
|
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
|
||||||
|
) {
|
||||||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||||
|
const offAliasExpander = registerKeyAliases(keymap)
|
||||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||||
const offLeader = addons.registerTimedLeader(keymap, {
|
const offLeader = addons.registerTimedLeader(keymap, {
|
||||||
trigger: config.keybinds.get(LEADER_TOKEN),
|
trigger: config.keybinds.get(LEADER_TOKEN),
|
||||||
@@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
|
|||||||
})
|
})
|
||||||
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
||||||
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
||||||
const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
|
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
|
||||||
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
|
|
||||||
const offInputBindings = keymap.registerLayer({
|
|
||||||
enabled: () => renderer.currentFocusedEditor !== null,
|
enabled: () => renderer.currentFocusedEditor !== null,
|
||||||
bindings: config.keybinds.gather("input", inputCommands),
|
bindings: config.keybinds.gather("input", inputCommands),
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
offInputBindings()
|
offInputBindings()
|
||||||
offInputSuspension()
|
|
||||||
offInputCommands()
|
|
||||||
offBackspace()
|
offBackspace()
|
||||||
offEscape()
|
offEscape()
|
||||||
offLeader()
|
offLeader()
|
||||||
|
offAliasExpander()
|
||||||
offBaseLayout()
|
offBaseLayout()
|
||||||
offCommaBindings()
|
offCommaBindings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi"
|
|||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { PermissionPrompt } from "./permission"
|
import { PermissionPrompt } from "./permission"
|
||||||
import { QuestionPrompt } from "./question"
|
import { QuestionPrompt } from "./question"
|
||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
@@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry"
|
|||||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
import { useCommandPalette } from "../../context/command-palette"
|
||||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||||
|
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -1078,199 +1078,201 @@ export function Session() {
|
|||||||
createEffect(on(() => route.sessionID, toBottom))
|
createEffect(on(() => route.sessionID, toBottom))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<context.Provider
|
<PathFormatterProvider path={session()?.directory}>
|
||||||
value={{
|
<context.Provider
|
||||||
get width() {
|
value={{
|
||||||
return contentWidth()
|
get width() {
|
||||||
},
|
return contentWidth()
|
||||||
sessionID: route.sessionID,
|
},
|
||||||
conceal,
|
sessionID: route.sessionID,
|
||||||
showThinking,
|
conceal,
|
||||||
showTimestamps,
|
showThinking,
|
||||||
showDetails,
|
showTimestamps,
|
||||||
showGenericToolOutput,
|
showDetails,
|
||||||
diffWrapMode,
|
showGenericToolOutput,
|
||||||
providers,
|
diffWrapMode,
|
||||||
sync,
|
providers,
|
||||||
tui: tuiConfig,
|
sync,
|
||||||
}}
|
tui: tuiConfig,
|
||||||
>
|
}}
|
||||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
>
|
||||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||||
<Show when={session()}>
|
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<scrollbox
|
<Show when={session()}>
|
||||||
ref={(r) => (scroll = r)}
|
<scrollbox
|
||||||
viewportOptions={{
|
ref={(r) => (scroll = r)}
|
||||||
paddingRight: showScrollbar() ? 1 : 0,
|
viewportOptions={{
|
||||||
}}
|
paddingRight: showScrollbar() ? 1 : 0,
|
||||||
verticalScrollbarOptions={{
|
}}
|
||||||
paddingLeft: 1,
|
verticalScrollbarOptions={{
|
||||||
visible: showScrollbar(),
|
paddingLeft: 1,
|
||||||
trackOptions: {
|
visible: showScrollbar(),
|
||||||
backgroundColor: theme.backgroundElement,
|
trackOptions: {
|
||||||
foregroundColor: theme.border,
|
backgroundColor: theme.backgroundElement,
|
||||||
},
|
foregroundColor: theme.border,
|
||||||
}}
|
},
|
||||||
stickyScroll={true}
|
}}
|
||||||
stickyStart="bottom"
|
stickyScroll={true}
|
||||||
flexGrow={1}
|
stickyStart="bottom"
|
||||||
scrollAcceleration={scrollAcceleration()}
|
flexGrow={1}
|
||||||
>
|
scrollAcceleration={scrollAcceleration()}
|
||||||
<box height={1} />
|
|
||||||
<For each={messages()}>
|
|
||||||
{(message, index) => (
|
|
||||||
<Switch>
|
|
||||||
<Match when={message.id === revert()?.messageID}>
|
|
||||||
{(function () {
|
|
||||||
const command = useCommandPalette()
|
|
||||||
const redoShortcut = useCommandShortcut("session.redo")
|
|
||||||
const [hover, setHover] = createSignal(false)
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
const handleUnrevert = async () => {
|
|
||||||
const confirmed = await DialogConfirm.show(
|
|
||||||
dialog,
|
|
||||||
"Confirm Redo",
|
|
||||||
"Are you sure you want to restore the reverted messages?",
|
|
||||||
)
|
|
||||||
if (confirmed) {
|
|
||||||
command.run("session.redo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
onMouseOver={() => setHover(true)}
|
|
||||||
onMouseOut={() => setHover(false)}
|
|
||||||
onMouseUp={handleUnrevert}
|
|
||||||
marginTop={1}
|
|
||||||
flexShrink={0}
|
|
||||||
border={["left"]}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
borderColor={theme.backgroundPanel}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
|
||||||
>
|
|
||||||
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
|
||||||
</text>
|
|
||||||
<Show when={revert()!.diffFiles?.length}>
|
|
||||||
<box marginTop={1}>
|
|
||||||
<For each={revert()!.diffFiles}>
|
|
||||||
{(file) => (
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{file.filename}
|
|
||||||
<Show when={file.additions > 0}>
|
|
||||||
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={file.deletions > 0}>
|
|
||||||
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Match>
|
|
||||||
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
|
||||||
<></>
|
|
||||||
</Match>
|
|
||||||
<Match when={message.role === "user"}>
|
|
||||||
<UserMessage
|
|
||||||
index={index()}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
dialog.replace(() => (
|
|
||||||
<DialogMessage
|
|
||||||
messageID={message.id}
|
|
||||||
sessionID={route.sessionID}
|
|
||||||
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}}
|
|
||||||
message={message as UserMessage}
|
|
||||||
parts={sync.data.part[message.id] ?? []}
|
|
||||||
pending={pending()}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={message.role === "assistant"}>
|
|
||||||
<AssistantMessage
|
|
||||||
last={lastAssistant()?.id === message.id}
|
|
||||||
message={message as AssistantMessage}
|
|
||||||
parts={sync.data.part[message.id] ?? []}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
<box flexShrink={0}>
|
|
||||||
<Show when={permissions().length > 0}>
|
|
||||||
<PermissionPrompt request={permissions()[0]} />
|
|
||||||
</Show>
|
|
||||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
|
||||||
<QuestionPrompt request={questions()[0]} />
|
|
||||||
</Show>
|
|
||||||
<Show when={session()?.parentID}>
|
|
||||||
<SubagentFooter />
|
|
||||||
</Show>
|
|
||||||
<Show when={visible()}>
|
|
||||||
<TuiPluginRuntime.Slot
|
|
||||||
name="session_prompt"
|
|
||||||
mode="replace"
|
|
||||||
session_id={route.sessionID}
|
|
||||||
visible={visible()}
|
|
||||||
disabled={disabled()}
|
|
||||||
on_submit={toBottom}
|
|
||||||
ref={bind}
|
|
||||||
>
|
|
||||||
<Prompt
|
|
||||||
visible={visible()}
|
|
||||||
ref={bind}
|
|
||||||
disabled={disabled()}
|
|
||||||
onSubmit={() => {
|
|
||||||
toBottom()
|
|
||||||
}}
|
|
||||||
sessionID={route.sessionID}
|
|
||||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
|
||||||
/>
|
|
||||||
</TuiPluginRuntime.Slot>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<Toast />
|
|
||||||
</box>
|
|
||||||
<Show when={sidebarVisible()}>
|
|
||||||
<Switch>
|
|
||||||
<Match when={wide()}>
|
|
||||||
<Sidebar sessionID={route.sessionID} />
|
|
||||||
</Match>
|
|
||||||
<Match when={!wide()}>
|
|
||||||
<box
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
bottom={0}
|
|
||||||
alignItems="flex-end"
|
|
||||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
|
||||||
>
|
>
|
||||||
<Sidebar sessionID={route.sessionID} />
|
<box height={1} />
|
||||||
|
<For each={messages()}>
|
||||||
|
{(message, index) => (
|
||||||
|
<Switch>
|
||||||
|
<Match when={message.id === revert()?.messageID}>
|
||||||
|
{(function () {
|
||||||
|
const command = useCommandPalette()
|
||||||
|
const redoShortcut = useCommandShortcut("session.redo")
|
||||||
|
const [hover, setHover] = createSignal(false)
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const handleUnrevert = async () => {
|
||||||
|
const confirmed = await DialogConfirm.show(
|
||||||
|
dialog,
|
||||||
|
"Confirm Redo",
|
||||||
|
"Are you sure you want to restore the reverted messages?",
|
||||||
|
)
|
||||||
|
if (confirmed) {
|
||||||
|
command.run("session.redo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
onMouseOver={() => setHover(true)}
|
||||||
|
onMouseOut={() => setHover(false)}
|
||||||
|
onMouseUp={handleUnrevert}
|
||||||
|
marginTop={1}
|
||||||
|
flexShrink={0}
|
||||||
|
border={["left"]}
|
||||||
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
|
borderColor={theme.backgroundPanel}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
|
>
|
||||||
|
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
||||||
|
</text>
|
||||||
|
<Show when={revert()!.diffFiles?.length}>
|
||||||
|
<box marginTop={1}>
|
||||||
|
<For each={revert()!.diffFiles}>
|
||||||
|
{(file) => (
|
||||||
|
<text fg={theme.text}>
|
||||||
|
{file.filename}
|
||||||
|
<Show when={file.additions > 0}>
|
||||||
|
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={file.deletions > 0}>
|
||||||
|
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
||||||
|
<></>
|
||||||
|
</Match>
|
||||||
|
<Match when={message.role === "user"}>
|
||||||
|
<UserMessage
|
||||||
|
index={index()}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogMessage
|
||||||
|
messageID={message.id}
|
||||||
|
sessionID={route.sessionID}
|
||||||
|
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
message={message as UserMessage}
|
||||||
|
parts={sync.data.part[message.id] ?? []}
|
||||||
|
pending={pending()}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={message.role === "assistant"}>
|
||||||
|
<AssistantMessage
|
||||||
|
last={lastAssistant()?.id === message.id}
|
||||||
|
message={message as AssistantMessage}
|
||||||
|
parts={sync.data.part[message.id] ?? []}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
<box flexShrink={0}>
|
||||||
|
<Show when={permissions().length > 0}>
|
||||||
|
<PermissionPrompt request={permissions()[0]} />
|
||||||
|
</Show>
|
||||||
|
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||||
|
<QuestionPrompt request={questions()[0]} />
|
||||||
|
</Show>
|
||||||
|
<Show when={session()?.parentID}>
|
||||||
|
<SubagentFooter />
|
||||||
|
</Show>
|
||||||
|
<Show when={visible()}>
|
||||||
|
<TuiPluginRuntime.Slot
|
||||||
|
name="session_prompt"
|
||||||
|
mode="replace"
|
||||||
|
session_id={route.sessionID}
|
||||||
|
visible={visible()}
|
||||||
|
disabled={disabled()}
|
||||||
|
on_submit={toBottom}
|
||||||
|
ref={bind}
|
||||||
|
>
|
||||||
|
<Prompt
|
||||||
|
visible={visible()}
|
||||||
|
ref={bind}
|
||||||
|
disabled={disabled()}
|
||||||
|
onSubmit={() => {
|
||||||
|
toBottom()
|
||||||
|
}}
|
||||||
|
sessionID={route.sessionID}
|
||||||
|
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||||
|
/>
|
||||||
|
</TuiPluginRuntime.Slot>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Show>
|
||||||
</Switch>
|
<Toast />
|
||||||
</Show>
|
</box>
|
||||||
</box>
|
<Show when={sidebarVisible()}>
|
||||||
</context.Provider>
|
<Switch>
|
||||||
|
<Match when={wide()}>
|
||||||
|
<Sidebar sessionID={route.sessionID} />
|
||||||
|
</Match>
|
||||||
|
<Match when={!wide()}>
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
alignItems="flex-end"
|
||||||
|
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||||
|
>
|
||||||
|
<Sidebar sessionID={route.sessionID} />
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</context.Provider>
|
||||||
|
</PathFormatterProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1827,7 +1829,7 @@ function BlockTool(props: {
|
|||||||
|
|
||||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const sync = useSync()
|
const pathFormatter = usePathFormatter()
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
@@ -1841,18 +1843,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
|||||||
const workdirDisplay = createMemo(() => {
|
const workdirDisplay = createMemo(() => {
|
||||||
const workdir = props.input.workdir
|
const workdir = props.input.workdir
|
||||||
if (!workdir || workdir === ".") return undefined
|
if (!workdir || workdir === ".") return undefined
|
||||||
|
return pathFormatter.format(workdir)
|
||||||
const base = sync.path.directory
|
|
||||||
if (!base) return undefined
|
|
||||||
|
|
||||||
const absolute = path.resolve(base, workdir)
|
|
||||||
if (absolute === base) return undefined
|
|
||||||
|
|
||||||
const home = Global.Path.home
|
|
||||||
if (!home) return absolute
|
|
||||||
|
|
||||||
const match = absolute === home || absolute.startsWith(home + path.sep)
|
|
||||||
return match ? absolute.replace(home, "~") : absolute
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
@@ -1894,6 +1885,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
|||||||
|
|
||||||
function Write(props: ToolProps<typeof WriteTool>) {
|
function Write(props: ToolProps<typeof WriteTool>) {
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
if (!props.input.content) return ""
|
if (!props.input.content) return ""
|
||||||
return props.input.content
|
return props.input.content
|
||||||
@@ -1902,7 +1894,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diagnostics !== undefined}>
|
<Match when={props.metadata.diagnostics !== undefined}>
|
||||||
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
|
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||||
<code
|
<code
|
||||||
conceal={false}
|
conceal={false}
|
||||||
@@ -1917,7 +1909,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||||
Write {normalizePath(props.input.filePath!)}
|
Write {pathFormatter.format(props.input.filePath)}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -1925,9 +1917,10 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Glob(props: ToolProps<typeof GlobTool>) {
|
function Glob(props: ToolProps<typeof GlobTool>) {
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
||||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
|
||||||
<Show when={props.metadata.count}>
|
<Show when={props.metadata.count}>
|
||||||
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
|||||||
|
|
||||||
function Read(props: ToolProps<typeof ReadTool>) {
|
function Read(props: ToolProps<typeof ReadTool>) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
const loaded = createMemo(() => {
|
const loaded = createMemo(() => {
|
||||||
if (props.part.state.status !== "completed") return []
|
if (props.part.state.status !== "completed") return []
|
||||||
@@ -1954,13 +1948,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
|||||||
spinner={isRunning()}
|
spinner={isRunning()}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
>
|
>
|
||||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
<For each={loaded()}>
|
<For each={loaded()}>
|
||||||
{(filepath) => (
|
{(filepath) => (
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
<text paddingLeft={3} fg={theme.textMuted}>
|
<text paddingLeft={3} fg={theme.textMuted}>
|
||||||
↳ Loaded {normalizePath(filepath)}
|
↳ Loaded {pathFormatter.format(filepath)}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Grep(props: ToolProps<typeof GrepTool>) {
|
function Grep(props: ToolProps<typeof GrepTool>) {
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
||||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
|
||||||
<Show when={props.metadata.matches}>
|
<Show when={props.metadata.matches}>
|
||||||
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
||||||
</Show>
|
</Show>
|
||||||
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
function Edit(props: ToolProps<typeof EditTool>) {
|
function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = ctx.tui.diff_style
|
const diffStyle = ctx.tui.diff_style
|
||||||
@@ -2086,7 +2082,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diff !== undefined}>
|
<Match when={props.metadata.diff !== undefined}>
|
||||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<diff
|
<diff
|
||||||
diff={diffContent()}
|
diff={diffContent()}
|
||||||
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const files = createMemo(() => props.metadata.files ?? [])
|
const files = createMemo(() => props.metadata.files ?? [])
|
||||||
|
|
||||||
@@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
|||||||
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
||||||
if (file.type === "delete") return "# Deleted " + file.relativePath
|
if (file.type === "delete") return "# Deleted " + file.relativePath
|
||||||
if (file.type === "add") return "# Created " + file.relativePath
|
if (file.type === "add") return "# Created " + file.relativePath
|
||||||
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
|
||||||
return "← Patched " + file.relativePath
|
return "← Patched " + file.relativePath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePath(input?: string) {
|
|
||||||
if (!input) return ""
|
|
||||||
|
|
||||||
const cwd = process.cwd()
|
|
||||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
|
||||||
const relative = path.relative(cwd, absolute)
|
|
||||||
|
|
||||||
if (!relative) return "."
|
|
||||||
if (!relative.startsWith("..")) return relative
|
|
||||||
|
|
||||||
// outside cwd - use absolute
|
|
||||||
return absolute
|
|
||||||
}
|
|
||||||
|
|
||||||
function input(input: Record<string, any>, omit?: string[]): string {
|
function input(input: Record<string, any>, omit?: string[]): string {
|
||||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||||
if (omit?.includes(key)) return false
|
if (omit?.includes(key)) return false
|
||||||
|
|||||||
@@ -11,34 +11,16 @@ import { useProject } from "../../context/project"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { ShellID } from "@/tool/shell/id"
|
import { ShellID } from "@/tool/shell/id"
|
||||||
import { webSearchProviderLabel } from "@/tool/websearch"
|
import { webSearchProviderLabel } from "@/tool/websearch"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||||
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
|
|
||||||
function normalizePath(input?: string) {
|
|
||||||
if (!input) return ""
|
|
||||||
|
|
||||||
const cwd = process.cwd()
|
|
||||||
const home = Global.Path.home
|
|
||||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
|
||||||
const relative = path.relative(cwd, absolute)
|
|
||||||
|
|
||||||
if (!relative) return "."
|
|
||||||
if (!relative.startsWith("..")) return relative
|
|
||||||
|
|
||||||
// outside cwd - use ~ or absolute
|
|
||||||
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
|
|
||||||
return absolute.replace(home, "~")
|
|
||||||
}
|
|
||||||
return absolute
|
|
||||||
}
|
|
||||||
|
|
||||||
function filetype(input?: string) {
|
function filetype(input?: string) {
|
||||||
if (!input) return "none"
|
if (!input) return "none"
|
||||||
const ext = path.extname(input)
|
const ext = path.extname(input)
|
||||||
@@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
stage: "permission" as PermissionStage,
|
stage: "permission" as PermissionStage,
|
||||||
})
|
})
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
|
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
|
||||||
|
|
||||||
@@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const filepath = typeof raw === "string" ? raw : ""
|
const filepath = typeof raw === "string" ? raw : ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Edit ${normalizePath(filepath)}`,
|
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||||
body: <EditBody request={props.request} />,
|
body: <EditBody request={props.request} />,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const filePath = typeof raw === "string" ? raw : ""
|
const filePath = typeof raw === "string" ? raw : ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Read ${normalizePath(filePath)}`,
|
title: `Read ${pathFormatter.format(filePath)}`,
|
||||||
body: (
|
body: (
|
||||||
<Show when={filePath}>
|
<Show when={filePath}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
|
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
),
|
),
|
||||||
@@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const dir = typeof raw === "string" ? raw : ""
|
const dir = typeof raw === "string" ? raw : ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `List ${normalizePath(dir)}`,
|
title: `List ${pathFormatter.format(dir)}`,
|
||||||
body: (
|
body: (
|
||||||
<Show when={dir}>
|
<Show when={dir}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
|
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
),
|
),
|
||||||
@@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||||
|
|
||||||
const raw = parent ?? filepath ?? derived
|
const raw = parent ?? filepath ?? derived
|
||||||
const dir = normalizePath(raw)
|
const dir = pathFormatter.format(raw)
|
||||||
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
|
||||||
|
|
||||||
export async function validateSession(input: {
|
export async function validateSession(input: {
|
||||||
url: string
|
url: string
|
||||||
@@ -10,9 +13,11 @@ export async function validateSession(input: {
|
|||||||
}) {
|
}) {
|
||||||
if (!input.sessionID) return
|
if (!input.sessionID) return
|
||||||
|
|
||||||
const result = SessionID.zod.safeParse(input.sessionID)
|
let sessionID: SessionID
|
||||||
if (!result.success) {
|
try {
|
||||||
throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
|
sessionID = decodeSessionID(input.sessionID)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
|
||||||
}
|
}
|
||||||
|
|
||||||
await createOpencodeClient({
|
await createOpencodeClient({
|
||||||
@@ -20,5 +25,5 @@ export async function validateSession(input: {
|
|||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
fetch: input.fetch,
|
fetch: input.fetch,
|
||||||
headers: input.headers,
|
headers: input.headers,
|
||||||
}).session.get({ sessionID: result.data }, { throwOnError: true })
|
}).session.get({ sessionID }, { throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export * as ConfigAttachment from "./attachment"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
|
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
|
export const Image = Schema.Struct({
|
||||||
|
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
|
||||||
|
}),
|
||||||
|
max_width: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
|
||||||
|
}),
|
||||||
|
max_height: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||||
|
}),
|
||||||
|
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "ImageAttachmentConfig" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Image = Schema.Schema.Type<typeof Image>
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "AttachmentConfig" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
@@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context"
|
|||||||
import { zod } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
|
import { ConfigAttachment } from "./attachment"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
import { ConfigFormatter } from "./formatter"
|
import { ConfigFormatter } from "./formatter"
|
||||||
import { ConfigLayout } from "./layout"
|
import { ConfigLayout } from "./layout"
|
||||||
@@ -241,6 +242,9 @@ export const Info = Schema.Struct({
|
|||||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||||
permission: Schema.optional(ConfigPermission.Info),
|
permission: Schema.optional(ConfigPermission.Info),
|
||||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||||
|
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||||
|
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||||
|
}),
|
||||||
enterprise: Schema.optional(
|
enterprise: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
|
||||||
import { withStatics } from "@opencode-ai/core/schema"
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
|
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
|
||||||
Schema.brand("WorkspaceID"),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type WorkspaceID = typeof workspaceIdSchema.Type
|
export type WorkspaceID = typeof workspaceIdSchema.Type
|
||||||
|
|
||||||
export const WorkspaceID = workspaceIdSchema.pipe(
|
export const WorkspaceID = workspaceIdSchema.pipe(
|
||||||
withStatics((schema: typeof workspaceIdSchema) => ({
|
withStatics((schema: typeof workspaceIdSchema) => ({
|
||||||
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
|
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
|
||||||
zod: zod(schema),
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { Format } from "@/format"
|
|||||||
import { InstanceLayer } from "@/project/instance-layer"
|
import { InstanceLayer } from "@/project/instance-layer"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
@@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
Workspace.defaultLayer,
|
||||||
Worktree.appLayer,
|
Worktree.appLayer,
|
||||||
Pty.defaultLayer,
|
Pty.defaultLayer,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import z from "zod"
|
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
|
|
||||||
const prefixes = {
|
const prefixes = {
|
||||||
@@ -7,19 +6,12 @@ const prefixes = {
|
|||||||
message: "msg",
|
message: "msg",
|
||||||
permission: "per",
|
permission: "per",
|
||||||
question: "que",
|
question: "que",
|
||||||
user: "usr",
|
|
||||||
part: "prt",
|
part: "prt",
|
||||||
pty: "pty",
|
pty: "pty",
|
||||||
tool: "tool",
|
tool: "tool",
|
||||||
workspace: "wrk",
|
workspace: "wrk",
|
||||||
entry: "ent",
|
|
||||||
account: "act",
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function schema(prefix: keyof typeof prefixes) {
|
|
||||||
return z.string().startsWith(prefixes[prefix])
|
|
||||||
}
|
|
||||||
|
|
||||||
const LENGTH = 26
|
const LENGTH = 26
|
||||||
|
|
||||||
// State for monotonic ID generation
|
// State for monotonic ID generation
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Config } from "@/config/config"
|
||||||
|
import type { MessageV2 } from "@/session/message-v2"
|
||||||
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
|
|
||||||
|
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
|
||||||
|
const MAX_WIDTH = 2000
|
||||||
|
const MAX_HEIGHT = 2000
|
||||||
|
const AUTO_RESIZE = true
|
||||||
|
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||||
|
const log = Log.create({ service: "image" })
|
||||||
|
|
||||||
|
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
|
||||||
|
"ImagePhotonUnavailableError",
|
||||||
|
{},
|
||||||
|
) {
|
||||||
|
override get message() {
|
||||||
|
return "Photon image processor is unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
|
||||||
|
url: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return "Image URL must be a base64 data URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "Image could not be decoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
|
||||||
|
bytes: Schema.Number,
|
||||||
|
max: Schema.Number,
|
||||||
|
width: Schema.Number,
|
||||||
|
height: Schema.Number,
|
||||||
|
max_width: Schema.Number,
|
||||||
|
max_height: Schema.Number,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const loadPhoton = yield* Effect.cached(
|
||||||
|
Effect.promise(async () => {
|
||||||
|
try {
|
||||||
|
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
|
||||||
|
.default
|
||||||
|
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
|
||||||
|
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||||
|
photonWasm
|
||||||
|
return await import("@silvia-odwyer/photon-node")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
|
||||||
|
const image = (yield* config.get()).attachment?.image
|
||||||
|
const info = {
|
||||||
|
autoResize: image?.auto_resize ?? AUTO_RESIZE,
|
||||||
|
maxWidth: image?.max_width ?? MAX_WIDTH,
|
||||||
|
maxHeight: image?.max_height ?? MAX_HEIGHT,
|
||||||
|
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
|
||||||
|
}
|
||||||
|
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
|
||||||
|
return yield* new InvalidDataUrlError({ url: input.url })
|
||||||
|
|
||||||
|
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
|
||||||
|
const photon = yield* loadPhoton
|
||||||
|
if (!photon) return yield* new PhotonUnavailableError()
|
||||||
|
|
||||||
|
const decoded = yield* Effect.sync(() => {
|
||||||
|
try {
|
||||||
|
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!decoded) return yield* new DecodeError()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalWidth = decoded.get_width()
|
||||||
|
const originalHeight = decoded.get_height()
|
||||||
|
if (
|
||||||
|
originalWidth <= info.maxWidth &&
|
||||||
|
originalHeight <= info.maxHeight &&
|
||||||
|
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
|
||||||
|
)
|
||||||
|
return input
|
||||||
|
if (!info.autoResize)
|
||||||
|
return yield* new SizeError({
|
||||||
|
bytes: Buffer.byteLength(base64, "utf8"),
|
||||||
|
max: info.maxBase64Bytes,
|
||||||
|
width: originalWidth,
|
||||||
|
height: originalHeight,
|
||||||
|
max_width: info.maxWidth,
|
||||||
|
max_height: info.maxHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
|
||||||
|
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||||
|
const previous = acc.at(-1) ?? {
|
||||||
|
width: Math.max(1, Math.round(originalWidth * scale)),
|
||||||
|
height: Math.max(1, Math.round(originalHeight * scale)),
|
||||||
|
}
|
||||||
|
const next =
|
||||||
|
acc.length === 0
|
||||||
|
? previous
|
||||||
|
: {
|
||||||
|
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||||
|
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||||
|
}
|
||||||
|
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
|
||||||
|
}, [])) {
|
||||||
|
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||||
|
const candidate = [
|
||||||
|
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
|
||||||
|
...JPEG_QUALITIES.map((quality) => ({
|
||||||
|
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
|
||||||
|
mime: "image/jpeg",
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
|
||||||
|
.find((item) => item.bytes <= info.maxBase64Bytes)
|
||||||
|
resized.free()
|
||||||
|
|
||||||
|
if (candidate) {
|
||||||
|
log.info("using resized image", {
|
||||||
|
from_mime: input.mime,
|
||||||
|
to_mime: candidate.mime,
|
||||||
|
from: `${originalWidth}x${originalHeight}`,
|
||||||
|
to: `${size.width}x${size.height}`,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
mime: candidate.mime,
|
||||||
|
url: `data:${candidate.mime};base64,${candidate.data}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return yield* new SizeError({
|
||||||
|
bytes: Buffer.byteLength(base64, "utf8"),
|
||||||
|
max: info.maxBase64Bytes,
|
||||||
|
width: originalWidth,
|
||||||
|
height: originalHeight,
|
||||||
|
max_width: info.maxWidth,
|
||||||
|
max_height: info.maxHeight,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
decoded.free()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ normalize })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||||
|
|
||||||
|
export * as Image from "./image"
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.md" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|||||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
|
ToolSchema,
|
||||||
type Tool as MCPToolDef,
|
type Tool as MCPToolDef,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
@@ -36,6 +37,15 @@ import { withStatics } from "@opencode-ai/core/schema"
|
|||||||
const log = Log.create({ service: "mcp" })
|
const log = Log.create({ service: "mcp" })
|
||||||
const DEFAULT_TIMEOUT = 30_000
|
const DEFAULT_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
const TolerantToolSchema = ToolSchema.extend({
|
||||||
|
outputSchema: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const TolerantListToolsResultSchema = z.looseObject({
|
||||||
|
tools: z.array(TolerantToolSchema),
|
||||||
|
nextCursor: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const Resource = Schema.Struct({
|
export const Resource = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
uri: Schema.String,
|
uri: Schema.String,
|
||||||
@@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) {
|
|||||||
log.warn("invalid remote mcp url", { key })
|
log.warn("invalid remote mcp url", { key })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOutputSchemaValidationError(error: Error) {
|
||||||
|
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
|
||||||
|
error.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTools(key: string, client: MCPClient, timeout: number) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => client.listTools(undefined, { timeout }),
|
||||||
|
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) => result.tools),
|
||||||
|
Effect.catch((error) => {
|
||||||
|
if (!isOutputSchemaValidationError(error)) return Effect.fail(error)
|
||||||
|
|
||||||
|
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
|
||||||
|
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Convert MCP tool definition to AI SDK Tool type
|
// Convert MCP tool definition to AI SDK Tool type
|
||||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
||||||
const inputSchema = mcpTool.inputSchema
|
const inputSchema = mcpTool.inputSchema
|
||||||
@@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
function defs(key: string, client: MCPClient, timeout?: number) {
|
function defs(key: string, client: MCPClient, timeout?: number) {
|
||||||
return Effect.tryPromise({
|
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
|
||||||
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
|
|
||||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((result) => result.tools),
|
|
||||||
Effect.catch((err) => {
|
Effect.catch((err) => {
|
||||||
log.error("failed to get tools from client", { key, error: err })
|
log.error("failed to get tools from client", { key, error: err })
|
||||||
return Effect.succeed(undefined)
|
return Effect.succeed(undefined)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { Newtype } from "@opencode-ai/core/schema"
|
import { Newtype } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
export class PermissionID extends Newtype<PermissionID>()(
|
export class PermissionID extends Newtype<PermissionID>()(
|
||||||
"PermissionID",
|
"PermissionID",
|
||||||
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
Schema.String.check(Schema.isStartsWith("per")),
|
||||||
) {
|
) {
|
||||||
static ascending(id?: string): PermissionID {
|
static ascending(id?: string): PermissionID {
|
||||||
return this.make(Identifier.ascending("permission", id))
|
return this.make(Identifier.ascending("permission", id))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next"
|
|||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Service } from "./bootstrap-service"
|
import { Service } from "./bootstrap-service"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
export { Service } from "./bootstrap-service"
|
export { Service } from "./bootstrap-service"
|
||||||
export type { Interface } from "./bootstrap-service"
|
export type { Interface } from "./bootstrap-service"
|
||||||
@@ -29,6 +30,7 @@ export const layer = Layer.effect(
|
|||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const project = yield* Project.Service
|
const project = yield* Project.Service
|
||||||
|
const reference = yield* Reference.Service
|
||||||
const shareNext = yield* ShareNext.Service
|
const shareNext = yield* ShareNext.Service
|
||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
const vcs = yield* Vcs.Service
|
const vcs = yield* Vcs.Service
|
||||||
@@ -43,7 +45,7 @@ export const layer = Layer.effect(
|
|||||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||||
// its per-instance state scope. We just await materialization here.
|
// its per-instance state scope. We just await materialization here.
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||||
{ concurrency: "unbounded", discard: true },
|
{ concurrency: "unbounded", discard: true },
|
||||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||||
@@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({
|
|||||||
// populates patch, but loosening matches the sibling schema so a
|
// populates patch, but loosening matches the sibling schema so a
|
||||||
// future code path that omits it can't crash /instance/vcs/diff.
|
// future code path that omits it can't crash /instance/vcs/diff.
|
||||||
patch: Schema.optional(Schema.String),
|
patch: Schema.optional(Schema.String),
|
||||||
additions: NonNegativeInt,
|
additions: Schema.Finite,
|
||||||
deletions: NonNegativeInt,
|
deletions: Schema.Finite,
|
||||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "VcsFileDiff" })
|
.annotate({ identifier: "VcsFileDiff" })
|
||||||
@@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type<typeof FileDiff>
|
|||||||
|
|
||||||
export const FileStatus = Schema.Struct({
|
export const FileStatus = Schema.Struct({
|
||||||
file: Schema.String,
|
file: Schema.String,
|
||||||
additions: NonNegativeInt,
|
additions: Schema.Finite,
|
||||||
deletions: NonNegativeInt,
|
deletions: Schema.Finite,
|
||||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "VcsFileStatus" })
|
.annotate({ identifier: "VcsFileStatus" })
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics } from "@opencode-ai/core/schema"
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
|
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
|
||||||
|
|
||||||
export type PtyID = typeof ptyIdSchema.Type
|
export type PtyID = typeof ptyIdSchema.Type
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { Newtype } from "@opencode-ai/core/schema"
|
import { Newtype } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
export class QuestionID extends Newtype<QuestionID>()(
|
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
|
||||||
"QuestionID",
|
|
||||||
Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }),
|
|
||||||
) {
|
|
||||||
static ascending(id?: string): QuestionID {
|
static ascending(id?: string): QuestionID {
|
||||||
return this.make(Identifier.ascending("question", id))
|
return this.make(Identifier.ascending("question", id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Effect, Context, Layer, Scope } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
|
||||||
|
import { RepositoryCache } from "./repository-cache"
|
||||||
|
|
||||||
|
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||||
|
|
||||||
|
export type Resolved =
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
kind: "local"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
kind: "git"
|
||||||
|
repository: string
|
||||||
|
reference: RepositoryReference
|
||||||
|
path: string
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
kind: "invalid"
|
||||||
|
repository: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
references: Resolved[]
|
||||||
|
materializeAll: Effect.Effect<void>
|
||||||
|
materializeByPath: { path: string; run: Effect.Effect<void> }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Resolved[]>
|
||||||
|
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||||
|
readonly ensure: (target?: string) => Effect.Effect<void>
|
||||||
|
readonly contains: (target?: string) => Effect.Effect<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
|
||||||
|
|
||||||
|
export function referencePath(input: { directory: string; worktree: string; value: string }) {
|
||||||
|
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
|
||||||
|
return path.isAbsolute(input.value)
|
||||||
|
? input.value
|
||||||
|
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGit(
|
||||||
|
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
|
||||||
|
): Resolved {
|
||||||
|
const parsed = parseRepositoryReference(input.repository)
|
||||||
|
if (!parsed || parsed.protocol === "file:") {
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
kind: "invalid",
|
||||||
|
repository: input.repository,
|
||||||
|
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
kind: "git",
|
||||||
|
repository: input.repository,
|
||||||
|
reference: parsed,
|
||||||
|
path: repositoryCachePath(parsed),
|
||||||
|
...("branch" in input ? { branch: input.branch } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchLabel(branch: string | undefined) {
|
||||||
|
return branch ?? "default branch"
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedTarget(target?: string) {
|
||||||
|
if (!target) return
|
||||||
|
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsReferencePath(referencePath: string, target: string) {
|
||||||
|
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolve(input: {
|
||||||
|
name: string
|
||||||
|
reference: ReferenceEntry
|
||||||
|
directory: string
|
||||||
|
worktree: string
|
||||||
|
}): Resolved {
|
||||||
|
if (typeof input.reference === "string") {
|
||||||
|
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
|
||||||
|
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
|
||||||
|
}
|
||||||
|
return resolveGit({ name: input.name, repository: input.reference })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("path" in input.reference) {
|
||||||
|
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAll(input: {
|
||||||
|
references: NonNullable<Config.Info["reference"]>
|
||||||
|
directory: string
|
||||||
|
worktree: string
|
||||||
|
}) {
|
||||||
|
const seen = new Map<string, { name: string; branch?: string }>()
|
||||||
|
return Object.entries(input.references).map(([name, reference]) => {
|
||||||
|
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
||||||
|
if (resolved.kind !== "git") return resolved
|
||||||
|
|
||||||
|
const existing = seen.get(resolved.path)
|
||||||
|
if (!existing) {
|
||||||
|
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if (existing.branch === resolved.branch) return resolved
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind: "invalid" as const,
|
||||||
|
repository: resolved.repository,
|
||||||
|
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Reference.state")(function* (ctx) {
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const references = resolveAll({
|
||||||
|
references: cfg.reference ?? {},
|
||||||
|
directory: ctx.directory,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
})
|
||||||
|
const seenPath = new Set<string>()
|
||||||
|
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||||
|
if (reference.kind !== "git") return false
|
||||||
|
if (seenPath.has(reference.path)) return false
|
||||||
|
seenPath.add(reference.path)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const materializeByPath = yield* Effect.forEach(
|
||||||
|
gitReferences,
|
||||||
|
Effect.fnUntraced(function* (reference) {
|
||||||
|
const run = yield* Effect.cached(
|
||||||
|
RepositoryCache.ensure(
|
||||||
|
{ reference: reference.reference, branch: reference.branch, refresh: true },
|
||||||
|
{ fs, git },
|
||||||
|
).pipe(
|
||||||
|
Effect.asVoid,
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return { path: reference.path, run }
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const materializeAll = yield* Effect.cached(
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||||
|
? Effect.gen(function* () {
|
||||||
|
yield* Effect.forEach(
|
||||||
|
materializeByPath,
|
||||||
|
Effect.fnUntraced(function* (item) {
|
||||||
|
yield* item.run
|
||||||
|
}),
|
||||||
|
{ concurrency: 4, discard: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: Effect.void,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { references, materializeAll, materializeByPath }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
init: Effect.fn("Reference.init")(function* () {
|
||||||
|
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||||
|
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||||
|
}),
|
||||||
|
list: Effect.fn("Reference.list")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.references)
|
||||||
|
}),
|
||||||
|
get: Effect.fn("Reference.get")(function* (name: string) {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
|
||||||
|
}),
|
||||||
|
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
||||||
|
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||||
|
const full = normalizedTarget(target)
|
||||||
|
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||||
|
return yield* InstanceState.useEffect(
|
||||||
|
state,
|
||||||
|
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||||
|
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false
|
||||||
|
const full = normalizedTarget(target)
|
||||||
|
if (!full) return false
|
||||||
|
return yield* InstanceState.use(state, (s) =>
|
||||||
|
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Reference from "./reference"
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Flock } from "@opencode-ai/core/util/flock"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import {
|
||||||
|
repositoryCachePath,
|
||||||
|
sameRepositoryReference,
|
||||||
|
parseRepositoryReference,
|
||||||
|
validateRepositoryBranch,
|
||||||
|
type Reference as RepositoryReference,
|
||||||
|
} from "@/util/repository"
|
||||||
|
|
||||||
|
export type Result = {
|
||||||
|
repository: string
|
||||||
|
host: string
|
||||||
|
remote: string
|
||||||
|
localPath: string
|
||||||
|
status: "cached" | "cloned" | "refreshed"
|
||||||
|
head?: string
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||||
|
if (!input.reuse) return "cloned" as const
|
||||||
|
if (input.branchMatches === false) return "refreshed" as const
|
||||||
|
if (input.refresh) return "refreshed" as const
|
||||||
|
return "cached" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTarget(input: {
|
||||||
|
requestedBranch?: string
|
||||||
|
remoteHead: { code: number; stdout: string }
|
||||||
|
branch: { code: number; stdout: string }
|
||||||
|
}) {
|
||||||
|
if (input.requestedBranch) return `origin/${input.requestedBranch}`
|
||||||
|
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||||
|
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||||
|
}
|
||||||
|
if (input.branch.code === 0 && input.branch.stdout) {
|
||||||
|
return `origin/${input.branch.stdout}`
|
||||||
|
}
|
||||||
|
return "HEAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
||||||
|
input: {
|
||||||
|
reference: RepositoryReference
|
||||||
|
refresh?: boolean
|
||||||
|
branch?: string
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
fs: AppFileSystem.Interface
|
||||||
|
git: Git.Interface
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (input.branch) validateRepositoryBranch(input.branch)
|
||||||
|
|
||||||
|
const repository = input.reference.label
|
||||||
|
const remote = input.reference.remote
|
||||||
|
const localPath = repositoryCachePath(input.reference)
|
||||||
|
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
||||||
|
|
||||||
|
return yield* Effect.acquireUseRelease(
|
||||||
|
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const exists = yield* services.fs.existsSafe(localPath)
|
||||||
|
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
||||||
|
const origin = hasGitDir
|
||||||
|
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||||
|
: undefined
|
||||||
|
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||||
|
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||||
|
if (exists && !reuse) {
|
||||||
|
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
|
||||||
|
const status = statusForRepository({
|
||||||
|
reuse,
|
||||||
|
refresh: input.refresh,
|
||||||
|
branchMatches: input.branch ? currentBranch === input.branch : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (status === "cloned") {
|
||||||
|
const clone = yield* services.git.run(
|
||||||
|
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
|
||||||
|
{ cwd: path.dirname(localPath) },
|
||||||
|
)
|
||||||
|
if (clone.exitCode !== 0) {
|
||||||
|
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "refreshed") {
|
||||||
|
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||||
|
if (fetch.exitCode !== 0) {
|
||||||
|
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.branch) {
|
||||||
|
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
|
||||||
|
cwd: localPath,
|
||||||
|
})
|
||||||
|
if (checkout.exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||||
|
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||||
|
const target = resetTarget({
|
||||||
|
requestedBranch: input.branch,
|
||||||
|
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||||
|
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
||||||
|
if (reset.exitCode !== 0) {
|
||||||
|
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||||
|
const branch = yield* services.git.branch(localPath)
|
||||||
|
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
host: input.reference.host,
|
||||||
|
remote,
|
||||||
|
localPath,
|
||||||
|
status,
|
||||||
|
head: headText,
|
||||||
|
branch,
|
||||||
|
} satisfies Result
|
||||||
|
}),
|
||||||
|
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as RepositoryCache from "./repository-cache"
|
||||||
@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
|
|||||||
import { WorkspaceApi } from "./groups/workspace"
|
import { WorkspaceApi } from "./groups/workspace"
|
||||||
import { V2Api } from "./groups/v2"
|
import { V2Api } from "./groups/v2"
|
||||||
import { Authorization } from "./middleware/authorization"
|
import { Authorization } from "./middleware/authorization"
|
||||||
|
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||||
|
|
||||||
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
||||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||||
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
|
|||||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||||
.addHttpApi(ControlApi)
|
.addHttpApi(ControlApi)
|
||||||
.addHttpApi(GlobalApi)
|
.addHttpApi(GlobalApi)
|
||||||
|
.middleware(SchemaErrorMiddleware)
|
||||||
.middleware(Authorization)
|
.middleware(Authorization)
|
||||||
|
|
||||||
export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||||
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
|||||||
.addHttpApi(V2Api)
|
.addHttpApi(V2Api)
|
||||||
.addHttpApi(TuiApi)
|
.addHttpApi(TuiApi)
|
||||||
.addHttpApi(WorkspaceApi)
|
.addHttpApi(WorkspaceApi)
|
||||||
|
.middleware(SchemaErrorMiddleware)
|
||||||
|
|
||||||
export const OpenCodeHttpApi = HttpApi.make("opencode")
|
export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||||
.addHttpApi(RootHttpApi)
|
.addHttpApi(RootHttpApi)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
|
|||||||
import { HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import * as Sse from "effect/unstable/encoding/Sse"
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
|
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add(
|
|||||||
HttpApiGroup.make("event")
|
HttpApiGroup.make("event")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("subscribe", EventPaths.event, {
|
HttpApiEndpoint.get("subscribe", EventPaths.event, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/config"
|
const root = "/config"
|
||||||
@@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
HttpApiGroup.make("config")
|
HttpApiGroup.make("config")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("get", root, {
|
HttpApiEndpoint.get("get", root, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Config.Info, "Get config info"),
|
success: described(Config.Info, "Get config info"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.patch("update", root, {
|
HttpApiEndpoint.patch("update", root, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Config.Info,
|
payload: Config.Info,
|
||||||
success: described(Config.Info, "Successfully updated config"),
|
success: described(Config.Info, "Successfully updated config"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Provider.ConfigProvidersResult, "List of providers"),
|
success: described(Provider.ConfigProvidersResult, "List of providers"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { ProviderID, ModelID } from "@/provider/schema"
|
|||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { Schema, SchemaGetter } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
import {
|
||||||
|
WorkspaceRoutingMiddleware,
|
||||||
|
WorkspaceRoutingQuery,
|
||||||
|
WorkspaceRoutingQueryFields,
|
||||||
|
} from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
import { QueryBoolean } from "./query"
|
||||||
|
|
||||||
const ConsoleStateResponse = Schema.Struct({
|
const ConsoleStateResponse = Schema.Struct({
|
||||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||||
@@ -48,12 +53,6 @@ export const ToolListQuery = Schema.Struct({
|
|||||||
model: ModelID,
|
model: ModelID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
|
||||||
Schema.decodeTo(Schema.Boolean, {
|
|
||||||
decode: SchemaGetter.transform((value) => value === "true"),
|
|
||||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const WorktreeList = Schema.Array(Schema.String)
|
const WorktreeList = Schema.Array(Schema.String)
|
||||||
export const SessionListQuery = Schema.Struct({
|
export const SessionListQuery = Schema.Struct({
|
||||||
...WorkspaceRoutingQueryFields,
|
...WorkspaceRoutingQueryFields,
|
||||||
@@ -82,6 +81,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
HttpApiGroup.make("experimental")
|
HttpApiGroup.make("experimental")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||||
error: HttpApiError.InternalServerError,
|
error: HttpApiError.InternalServerError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -92,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||||
error: HttpApiError.InternalServerError,
|
error: HttpApiError.InternalServerError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -102,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ConsoleSwitchPayload,
|
payload: ConsoleSwitchPayload,
|
||||||
success: described(Schema.Boolean, "Switch success"),
|
success: described(Schema.Boolean, "Switch success"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -125,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(ToolIDs, "Tool IDs"),
|
success: described(ToolIDs, "Tool IDs"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -136,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(WorktreeList, "List of worktree directories"),
|
success: described(WorktreeList, "List of worktree directories"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -145,6 +149,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Schema.optional(Worktree.CreateInput),
|
payload: Schema.optional(Worktree.CreateInput),
|
||||||
success: described(Worktree.Info, "Worktree created"),
|
success: described(Worktree.Info, "Worktree created"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -156,6 +161,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Worktree.RemoveInput,
|
payload: Worktree.RemoveInput,
|
||||||
success: described(Schema.Boolean, "Worktree removed"),
|
success: described(Schema.Boolean, "Worktree removed"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -167,6 +173,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Worktree.ResetInput,
|
payload: Worktree.ResetInput,
|
||||||
success: described(Schema.Boolean, "Worktree reset"),
|
success: described(Schema.Boolean, "Worktree reset"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -189,6 +196,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
|
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
import {
|
||||||
|
WorkspaceRoutingMiddleware,
|
||||||
|
WorkspaceRoutingQuery,
|
||||||
|
WorkspaceRoutingQueryFields,
|
||||||
|
} from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
export const FileQuery = Schema.Struct({
|
export const FileQuery = Schema.Struct({
|
||||||
@@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(File.Info), "File status"),
|
success: described(Schema.Array(File.Info), "File status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
import {
|
||||||
|
WorkspaceRoutingMiddleware,
|
||||||
|
WorkspaceRoutingQuery,
|
||||||
|
WorkspaceRoutingQueryFields,
|
||||||
|
} from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const PathInfo = Schema.Struct({
|
const PathInfo = Schema.Struct({
|
||||||
@@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
HttpApiGroup.make("instance")
|
HttpApiGroup.make("instance")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Instance disposed"),
|
success: described(Schema.Boolean, "Instance disposed"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("path", InstancePaths.path, {
|
HttpApiEndpoint.get("path", InstancePaths.path, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: PathInfo,
|
success: PathInfo,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Vcs.Info, "VCS info"),
|
success: described(Vcs.Info, "VCS info"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
|
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
|
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
|
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(
|
success: described(
|
||||||
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
|
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
|
||||||
"Raw VCS diff",
|
"Raw VCS diff",
|
||||||
@@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
|
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Vcs.ApplyInput,
|
payload: Vcs.ApplyInput,
|
||||||
success: described(Vcs.ApplyResult, "VCS patch applied"),
|
success: described(Vcs.ApplyResult, "VCS patch applied"),
|
||||||
error: ApiVcsApplyError,
|
error: ApiVcsApplyError,
|
||||||
@@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Command.Info), "List of commands"),
|
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Agent.Info), "List of agents"),
|
success: described(Schema.Array(Agent.Info), "List of agents"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Skill.Info), "List of skills"),
|
success: described(Schema.Array(Skill.Info), "List of skills"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(LSP.Status), "LSP server status"),
|
success: described(Schema.Array(LSP.Status), "LSP server status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Format.Status), "Formatter status"),
|
success: described(Schema.Array(Format.Status), "Formatter status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
export const AddPayload = Schema.Struct({
|
export const AddPayload = Schema.Struct({
|
||||||
@@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
HttpApiGroup.make("mcp")
|
HttpApiGroup.make("mcp")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("status", McpPaths.status, {
|
HttpApiEndpoint.get("status", McpPaths.status, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
|
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("add", McpPaths.status, {
|
HttpApiEndpoint.post("add", McpPaths.status, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: AddPayload,
|
payload: AddPayload,
|
||||||
success: described(StatusMap, "MCP server added successfully"),
|
success: described(StatusMap, "MCP server added successfully"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(AuthStartResponse, "OAuth flow started"),
|
success: described(AuthStartResponse, "OAuth flow started"),
|
||||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: AuthCallbackPayload,
|
payload: AuthCallbackPayload,
|
||||||
success: described(MCP.Status, "OAuth authentication completed"),
|
success: described(MCP.Status, "OAuth authentication completed"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(MCP.Status, "OAuth authentication completed"),
|
success: described(MCP.Status, "OAuth authentication completed"),
|
||||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||||
error: HttpApiError.NotFound,
|
error: HttpApiError.NotFound,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "MCP server connected successfully"),
|
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/permission"
|
const root = "/permission"
|
||||||
@@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission")
|
|||||||
HttpApiGroup.make("permission")
|
HttpApiGroup.make("permission")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
|
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||||
params: { requestID: PermissionID },
|
params: { requestID: PermissionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/project"
|
const root = "/project"
|
||||||
@@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
HttpApiGroup.make("project")
|
HttpApiGroup.make("project")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Project.Info), "List of projects"),
|
success: described(Schema.Array(Project.Info), "List of projects"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("current", `${root}/current`, {
|
HttpApiEndpoint.get("current", `${root}/current`, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Project.Info, "Current project information"),
|
success: described(Project.Info, "Current project information"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Project.Info, "Project information after git initialization"),
|
success: described(Project.Info, "Project information after git initialization"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
||||||
params: { projectID: ProjectID },
|
params: { projectID: ProjectID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: UpdatePayload,
|
payload: UpdatePayload,
|
||||||
success: described(Project.Info, "Updated project information"),
|
success: described(Project.Info, "Updated project information"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/provider"
|
const root = "/provider"
|
||||||
@@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider")
|
|||||||
HttpApiGroup.make("provider")
|
HttpApiGroup.make("provider")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Provider.ListResult, "List of providers"),
|
success: described(Provider.ListResult, "List of providers"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("auth", `${root}/auth`, {
|
HttpApiEndpoint.get("auth", `${root}/auth`, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(ProviderAuth.Methods, "Provider auth methods"),
|
success: described(ProviderAuth.Methods, "Provider auth methods"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||||
params: { providerID: ProviderID },
|
params: { providerID: ProviderID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ProviderAuth.AuthorizeInput,
|
payload: ProviderAuth.AuthorizeInput,
|
||||||
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
|
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
|
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
|
||||||
params: { providerID: ProviderID },
|
params: { providerID: ProviderID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ProviderAuth.CallbackInput,
|
payload: ProviderAuth.CallbackInput,
|
||||||
success: described(Schema.Boolean, "OAuth callback processed successfully"),
|
success: described(Schema.Boolean, "OAuth callback processed successfully"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
import {
|
||||||
|
WorkspaceRoutingMiddleware,
|
||||||
|
WorkspaceRoutingQuery,
|
||||||
|
WorkspaceRoutingQueryFields,
|
||||||
|
} from "../middleware/workspace-routing"
|
||||||
import { ApiNotFoundError } from "../errors"
|
import { ApiNotFoundError } from "../errors"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
HttpApiGroup.make("pty")
|
HttpApiGroup.make("pty")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("shells", PtyPaths.shells, {
|
HttpApiEndpoint.get("shells", PtyPaths.shells, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(ShellItem), "List of shells"),
|
success: described(Schema.Array(ShellItem), "List of shells"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -46,6 +51,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("list", PtyPaths.list, {
|
HttpApiEndpoint.get("list", PtyPaths.list, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Pty.Info), "List of sessions"),
|
success: described(Schema.Array(Pty.Info), "List of sessions"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -55,6 +61,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Pty.CreateInput,
|
payload: Pty.CreateInput,
|
||||||
success: described(Pty.Info, "Created session"),
|
success: described(Pty.Info, "Created session"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -67,6 +74,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("get", PtyPaths.get, {
|
HttpApiEndpoint.get("get", PtyPaths.get, {
|
||||||
params: { ptyID: PtyID },
|
params: { ptyID: PtyID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Pty.Info, "Session info"),
|
success: described(Pty.Info, "Session info"),
|
||||||
error: ApiNotFoundError,
|
error: ApiNotFoundError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -78,6 +86,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.put("update", PtyPaths.update, {
|
HttpApiEndpoint.put("update", PtyPaths.update, {
|
||||||
params: { ptyID: PtyID },
|
params: { ptyID: PtyID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: Pty.UpdateInput,
|
payload: Pty.UpdateInput,
|
||||||
success: described(Pty.Info, "Updated session"),
|
success: described(Pty.Info, "Updated session"),
|
||||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||||
@@ -90,6 +99,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
||||||
params: { ptyID: PtyID },
|
params: { ptyID: PtyID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Session removed"),
|
success: described(Schema.Boolean, "Session removed"),
|
||||||
error: ApiNotFoundError,
|
error: ApiNotFoundError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -101,6 +111,7 @@ export const PtyApi = HttpApi.make("pty")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
|
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
|
||||||
params: { ptyID: PtyID },
|
params: { ptyID: PtyID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
||||||
error: [HttpApiError.Forbidden, ApiNotFoundError],
|
error: [HttpApiError.Forbidden, ApiNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -129,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||||
params: Params,
|
params: Params,
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Connected session"),
|
success: described(Schema.Boolean, "Connected session"),
|
||||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Schema, SchemaGetter } from "effect"
|
||||||
|
|
||||||
|
export const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||||
|
Schema.decodeTo(Schema.Boolean, {
|
||||||
|
decode: SchemaGetter.transform((value) => value === "true"),
|
||||||
|
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const QueryBooleanOpenApi = {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/question"
|
const root = "/question"
|
||||||
@@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
HttpApiGroup.make("question")
|
HttpApiGroup.make("question")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Question.Request), "List of pending questions"),
|
success: described(Schema.Array(Question.Request), "List of pending questions"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||||
params: { requestID: QuestionID },
|
params: { requestID: QuestionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
success: described(Schema.Boolean, "Question answered successfully"),
|
success: described(Schema.Boolean, "Question answered successfully"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
||||||
params: { requestID: QuestionID },
|
params: { requestID: QuestionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Question rejected successfully"),
|
success: described(Schema.Boolean, "Question rejected successfully"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
|
|||||||
@@ -10,21 +10,20 @@ import { SessionSummary } from "@/session/summary"
|
|||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Schema, SchemaGetter, Struct } from "effect"
|
import { Schema, Struct } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
import {
|
||||||
|
WorkspaceRoutingMiddleware,
|
||||||
|
WorkspaceRoutingQuery,
|
||||||
|
WorkspaceRoutingQueryFields,
|
||||||
|
} from "../middleware/workspace-routing"
|
||||||
import { ApiNotFoundError } from "../errors"
|
import { ApiNotFoundError } from "../errors"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
import { QueryBoolean } from "./query"
|
||||||
|
|
||||||
const root = "/session"
|
const root = "/session"
|
||||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
|
||||||
Schema.decodeTo(Schema.Boolean, {
|
|
||||||
decode: SchemaGetter.transform((value) => value === "true"),
|
|
||||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
export const ListQuery = Schema.Struct({
|
export const ListQuery = Schema.Struct({
|
||||||
...WorkspaceRoutingQueryFields,
|
...WorkspaceRoutingQueryFields,
|
||||||
scope: Schema.optional(Schema.Literals(["project"])),
|
scope: Schema.optional(Schema.Literals(["project"])),
|
||||||
@@ -116,6 +115,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(StatusMap, "Get session status"),
|
success: described(StatusMap, "Get session status"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -127,6 +127,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Session.Info, "Get session"),
|
success: described(Session.Info, "Get session"),
|
||||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -138,6 +139,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Session.Info), "List of children"),
|
success: described(Schema.Array(Session.Info), "List of children"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -149,6 +151,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(Todo.Info), "Todo list"),
|
success: described(Schema.Array(Todo.Info), "Todo list"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -183,6 +186,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("message", SessionPaths.message, {
|
HttpApiEndpoint.get("message", SessionPaths.message, {
|
||||||
params: { sessionID: SessionID, messageID: MessageID },
|
params: { sessionID: SessionID, messageID: MessageID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(MessageV2.WithParts, "Message"),
|
success: described(MessageV2.WithParts, "Message"),
|
||||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -193,6 +197,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("create", SessionPaths.create, {
|
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: [HttpApiSchema.NoContent, Session.CreateInput],
|
payload: [HttpApiSchema.NoContent, Session.CreateInput],
|
||||||
success: described(Session.Info, "Successfully created session"),
|
success: described(Session.Info, "Successfully created session"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -205,6 +210,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Successfully deleted session"),
|
success: described(Schema.Boolean, "Successfully deleted session"),
|
||||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -216,6 +222,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: UpdatePayload,
|
payload: UpdatePayload,
|
||||||
success: described(Session.Info, "Successfully updated session"),
|
success: described(Session.Info, "Successfully updated session"),
|
||||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||||
@@ -228,6 +235,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ForkPayload,
|
payload: ForkPayload,
|
||||||
success: described(Session.Info, "200"),
|
success: described(Session.Info, "200"),
|
||||||
error: ApiNotFoundError,
|
error: ApiNotFoundError,
|
||||||
@@ -240,6 +248,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Aborted session"),
|
success: described(Schema.Boolean, "Aborted session"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -251,6 +260,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("init", SessionPaths.init, {
|
HttpApiEndpoint.post("init", SessionPaths.init, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: InitPayload,
|
payload: InitPayload,
|
||||||
success: described(Schema.Boolean, "200"),
|
success: described(Schema.Boolean, "200"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -264,6 +274,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Session.Info, "Successfully shared session"),
|
success: described(Session.Info, "Successfully shared session"),
|
||||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -275,6 +286,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Session.Info, "Successfully unshared session"),
|
success: described(Session.Info, "Successfully unshared session"),
|
||||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -286,6 +298,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: SummarizePayload,
|
payload: SummarizePayload,
|
||||||
success: described(Schema.Boolean, "Summarized session"),
|
success: described(Schema.Boolean, "Summarized session"),
|
||||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||||
@@ -298,6 +311,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: PromptPayload,
|
payload: PromptPayload,
|
||||||
success: described(MessageV2.WithParts, "Created message"),
|
success: described(MessageV2.WithParts, "Created message"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -310,6 +324,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: PromptPayload,
|
payload: PromptPayload,
|
||||||
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
|
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -323,6 +338,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("command", SessionPaths.command, {
|
HttpApiEndpoint.post("command", SessionPaths.command, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: CommandPayload,
|
payload: CommandPayload,
|
||||||
success: described(MessageV2.WithParts, "Created message"),
|
success: described(MessageV2.WithParts, "Created message"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -335,6 +351,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ShellPayload,
|
payload: ShellPayload,
|
||||||
success: described(MessageV2.WithParts, "Created message"),
|
success: described(MessageV2.WithParts, "Created message"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -347,6 +364,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: RevertPayload,
|
payload: RevertPayload,
|
||||||
success: described(Session.Info, "Updated session"),
|
success: described(Session.Info, "Updated session"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -360,6 +378,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Session.Info, "Updated session"),
|
success: described(Session.Info, "Updated session"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -371,6 +390,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
||||||
params: { sessionID: SessionID, permissionID: PermissionID },
|
params: { sessionID: SessionID, permissionID: PermissionID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: PermissionResponsePayload,
|
payload: PermissionResponsePayload,
|
||||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
@@ -384,6 +404,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
||||||
params: { sessionID: SessionID, messageID: MessageID },
|
params: { sessionID: SessionID, messageID: MessageID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Successfully deleted message"),
|
success: described(Schema.Boolean, "Successfully deleted message"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -396,6 +417,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
||||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Successfully deleted part"),
|
success: described(Schema.Boolean, "Successfully deleted part"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -406,6 +428,7 @@ export const SessionApi = HttpApi.make("session")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
||||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: MessageV2.Part,
|
payload: MessageV2.Part,
|
||||||
success: described(MessageV2.Part, "Successfully updated part"),
|
success: described(MessageV2.Part, "Successfully updated part"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/sync"
|
const root = "/sync"
|
||||||
@@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync")
|
|||||||
HttpApiGroup.make("sync")
|
HttpApiGroup.make("sync")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("start", SyncPaths.start, {
|
HttpApiEndpoint.post("start", SyncPaths.start, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "Workspace sync started"),
|
success: described(Schema.Boolean, "Workspace sync started"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: ReplayPayload,
|
payload: ReplayPayload,
|
||||||
success: described(ReplayResponse, "Replayed sync events"),
|
success: described(ReplayResponse, "Replayed sync events"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("steal", SyncPaths.steal, {
|
HttpApiEndpoint.post("steal", SyncPaths.steal, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: SessionPayload,
|
payload: SessionPayload,
|
||||||
success: described(SessionPayload, "Session stolen into workspace"),
|
success: described(SessionPayload, "Session stolen into workspace"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
@@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||||
|
query: WorkspaceRoutingQuery,
|
||||||
payload: HistoryPayload,
|
payload: HistoryPayload,
|
||||||
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user