Compare commits

..

58 Commits

Author SHA1 Message Date
Aiden Cline 5d929f78c3 refactor(opencode): simplify tool output disable config 2026-05-26 15:33:08 -05:00
Aiden Cline d4f83ba2b7 fix(opencode): normalize merged tool output config 2026-05-26 14:49:22 -05:00
Aiden Cline 93cc5e8dff refactor(opencode): model truncation limits as Option
Replace the `{ enabled: boolean; maxLines: number; maxBytes: number }`
shape with `Option<{ maxLines: number; maxBytes: number }>` so the absence
of limits is represented by the type system instead of a boolean flag with
dead numeric fields.

- Truncate.limits() now returns Effect<Option<Limits>>.
- Truncate.output() short-circuits on None instead of checking .enabled.
- shell tool gates rolling buffer, disk spill, and tail truncation on
  Option.isSome(limits); behavior unchanged when truncation is enabled.
- ShellPrompt.render and helpers accept Option<Limits>; the truncation
  guidance line is omitted when None.
- Tests updated to assert Option.isSome / Option.isNone.
2026-05-24 23:06:06 -05:00
Aiden Cline 3fcdf94873 merge origin/dev into issue-13770-tool-output-docs
Resolve conflict in packages/opencode/src/tool/shell/prompt.ts to combine
truncationGuidance helper (which omits the truncation note when truncation
is disabled) with the configurable defaultTimeoutMs from origin/dev.

Fix typecheck in tool/truncate.ts by narrowing the tool_output union before
reading max_lines and max_bytes.
2026-05-23 21:23:36 -05:00
Aiden Cline d16bfe850a chore(opencode): bump amazon bedrock provider (#29052) 2026-05-23 21:06:00 -05:00
opencode-agent[bot] 728eae9c25 chore: generate 2026-05-24 01:08:00 +00:00
Aiden Cline eb84f461b8 fix(llm): split OpenAI reasoning summary blocks (#29000) 2026-05-23 20:06:45 -05:00
Kit Langton 0b3a1c2fdf test(opencode): simplify config effect tests (#29019) 2026-05-23 23:48:08 +00:00
opencode-agent[bot] 1ccd14b0e1 chore: update nix node_modules hashes 2026-05-23 20:51:28 +00:00
Aiden Cline 61e7cdfbff fix(opencode): bump google vertex provider for thought signatures (#29010)
Co-authored-by: Andrew Sweet <andrew.sweet@cantab.net>
2026-05-23 15:29:43 -05:00
opencode-agent[bot] 7fe7b9f258 chore: update nix node_modules hashes 2026-05-23 16:42:22 +00:00
Aiden Cline a5ba1d075d chore(opencode): bump venice ai sdk provider (#29003)
Co-authored-by: dpuyosa <dpuyosa@users.noreply.github.com>
2026-05-23 11:28:04 -05:00
Aiden Cline ba437069e6 fix(opencode): advertise configured shell timeout (#28998)
Co-authored-by: Nabs <nabil@instafork.com>
2026-05-23 10:43:39 -05:00
Frank 387c5a02c8 sync
deploy / deploy (push) Has been cancelled
2026-05-23 10:27:07 -04:00
Jérôme Benoit 4b1b722e98 fix(nix): re-inherit patches in desktop, ad-hoc sign .app on darwin (#27869) 2026-05-23 09:20:21 -05:00
Frank 6b03be5468 sync 2026-05-23 03:57:22 -04:00
opencode-agent[bot] 7d2c1ce6c6 chore: generate
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
stats / stats (push) Has been cancelled
close-prs / close (push) Has been cancelled
close-issues / close (push) Has been cancelled
2026-05-23 04:49:36 +00:00
Dax a9ef5a0fae feat(project): resolve remote-backed project identity (#28914) 2026-05-23 04:48:09 +00:00
Luke Parker 0cf9a5d20b fix(desktop): avoid white flash when restoring window (#28247) 2026-05-23 14:42:40 +10:00
opencode 2c4ad9f405 sync release versions for v1.15.10 2026-05-23 01:04:22 +00:00
Luke Parker 3bf054c1d9 fix(app): restore desktop prod legacy flows (#28919) 2026-05-22 20:40:47 -04:00
Aiden Cline 7cfcb644ab fix(core): enforce tool output truncation variants 2026-05-22 17:37:43 -05:00
Aiden Cline 7a58560161 fix(core): nest tool output truncation toggle 2026-05-22 17:31:22 -05:00
Aiden Cline 1edba25dfb feat(core): allow disabling tool output truncation 2026-05-22 17:20:58 -05:00
Aiden Cline 14c511e380 fix(llm): stabilize anthropic tool result typecheck (#28909) 2026-05-22 17:14:40 -05:00
Aiden Cline 6d3c4c916b docs: document tool output truncation settings 2026-05-22 16:55:00 -05:00
opencode-agent[bot] 5ee0238c4a chore: update nix node_modules hashes 2026-05-22 21:33:19 +00:00
opencode 871d38cbd7 sync release versions for v1.15.9 2026-05-22 21:18:49 +00:00
opencode-agent[bot] 1a329e4e67 chore: generate 2026-05-22 19:31:59 +00:00
James Long bfb2d8dc76 fix(tui): when diff viewer closes always return to last route (#28903) 2026-05-22 15:30:31 -04:00
James Long 8f7a6c4a00 fix(tui): refine diff view keyboard shortcuts (#28896) 2026-05-22 14:40:14 -04:00
opencode-agent[bot] 1857c73565 chore: generate 2026-05-22 18:25:01 +00:00
Shoubhit Dash 5f42351159 fix(provider): type default model failures (#28881) 2026-05-22 23:53:23 +05:30
Shoubhit Dash 968aaa3cfe fix(pty): expose missing session errors (#28884) 2026-05-22 23:53:03 +05:30
opencode-agent[bot] b8266e5819 chore: generate 2026-05-22 18:22:48 +00:00
James Long ba746e36d8 fix(tui): empty states, context, and minor improvements to diff viewer (#28878) 2026-05-22 14:21:22 -04:00
opencode-agent[bot] d5068ba28e chore: generate 2026-05-22 17:50:23 +00:00
Shoubhit Dash 7265c46af6 fix(skill): type expected skill failures (#28885) 2026-05-22 23:18:52 +05:30
Shoubhit Dash 536ee857c6 fix(installation): type upgrade failures (#28883) 2026-05-22 23:18:17 +05:30
opencode-agent[bot] dda69d77e8 chore: generate 2026-05-22 17:47:18 +00:00
Shoubhit Dash 0e14404e5f fix(sync): map workspace warp not found (#28882) 2026-05-22 23:15:53 +05:30
opencode-agent[bot] 05f51bfe45 chore: generate 2026-05-22 17:44:34 +00:00
Shoubhit Dash aee552c043 fix(repository): type expected reference failures (#28880) 2026-05-22 23:13:14 +05:30
opencode-agent[bot] 4f6eaf859b chore: generate 2026-05-22 16:39:58 +00:00
Kit Langton d0cb58782f fix(llm): surface code, type, and nested fields on provider stream errors (#28757) 2026-05-22 12:37:55 -04:00
opencode-agent[bot] a3430db73a chore: generate 2026-05-22 16:37:46 +00:00
Shoubhit Dash 3e1972fd92 fix(httpapi): return project not found errors (#28856) 2026-05-22 22:06:20 +05:30
opencode-agent[bot] b368e5adbe chore: generate 2026-05-22 16:31:30 +00:00
James Long 69e4f52272 fix(tui): interaction improvements to diff viewer (#28851) 2026-05-22 12:30:10 -04:00
opencode-agent[bot] 8a55920531 chore: generate 2026-05-22 16:25:15 +00:00
Kit Langton 9db90a0b76 fix(llm): emit structured image blocks for tool-result media in Anthropic Messages (#28755) 2026-05-22 12:23:41 -04:00
Kit Langton 700d012025 fix(llm): emit structured input_image content for tool-result media in OpenAI Responses (#28754) 2026-05-22 12:23:23 -04:00
Shoubhit Dash 59e486a917 fix(tui): restore question prompt key handling (#28835) 2026-05-22 21:47:13 +05:30
Aiden Cline 8596967415 ci: "fix: exempt team members from compliance cleanup" (#28865) 2026-05-22 11:14:52 -05:00
opencode-agent[bot] 00038027c8 chore: generate 2026-05-22 15:31:46 +00:00
Shoubhit Dash 5cf597d583 fix(httpapi): return pty error bodies (#28838) 2026-05-22 21:00:20 +05:30
opencode-agent[bot] d92b8d8009 chore: generate 2026-05-22 13:52:40 +00:00
Dax Raad 854c535536 fix(tui): enable diff viewer by default 2026-05-22 09:50:30 -04:00
208 changed files with 4701 additions and 2572 deletions
-14
View File
@@ -34,25 +34,11 @@ jobs:
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000;
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
if (teamAssociations.includes(item.author_association)) {
core.info(`Skipping ${kind} #${item.number}: author association is ${item.author_association}`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
+2 -2
View File
@@ -6,7 +6,7 @@ on:
jobs:
check-duplicates:
if: github.event.action == 'opened' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
if: github.event.action == 'opened'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -118,7 +118,7 @@ jobs:
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
recheck-compliance:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
+6 -9
View File
@@ -11,25 +11,22 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 1
- name: Check team membership
id: team-check
run: |
LOGIN="${{ github.event.pull_request.user.login }}"
ASSOCIATION="${{ github.event.pull_request.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
echo "Skipping: $LOGIN is a team member or bot"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.team-check.outputs.is_team != 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.base.sha }}
- name: Setup Bun
if: steps.team-check.outputs.is_team != 'true'
uses: ./.github/actions/setup-bun
+18 -6
View File
@@ -28,9 +28,15 @@ jobs:
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (teamAssociations.includes(pr.author_association)) {
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
@@ -169,9 +175,15 @@ jobs:
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (teamAssociations.includes(pr.author_association)) {
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
-3
View File
@@ -2,9 +2,6 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
},
"mcp": {},
"tools": {
"github-triage": false,
+68 -36
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -169,7 +169,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -193,21 +193,21 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.7",
"version": "1.15.10",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -239,7 +239,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"venice-ai-sdk-provider": "2.0.1",
"venice-ai-sdk-provider": "2.0.2",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
@@ -254,7 +254,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -309,7 +309,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -323,7 +323,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -353,7 +353,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -369,7 +369,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -382,7 +382,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -400,7 +400,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.7",
"version": "1.15.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -409,15 +409,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -494,7 +494,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.1",
"venice-ai-sdk-provider": "2.0.2",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
@@ -538,7 +538,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -576,7 +576,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -591,7 +591,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -626,7 +626,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -675,7 +675,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -810,7 +810,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -832,9 +832,9 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -4912,7 +4912,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/provider": "^3.0.8", "@ai-sdk/provider-utils": "^4.0.21" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-6SxA8a4MoA6Q/c+D3q7My0Hfog76enN3n0MXhwosM+tso66rXBEGeBRD/0lravRDVzL2Q1w5QJPc86rAVJtfXg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
@@ -5090,9 +5090,13 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
@@ -5108,11 +5112,17 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5956,7 +5966,11 @@
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@@ -6002,6 +6016,12 @@
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6012,6 +6032,14 @@
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6658,6 +6686,10 @@
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+10 -1
View File
@@ -3,6 +3,7 @@
stdenv,
bun,
nodejs,
darwin,
electron_41,
makeWrapper,
writableTmpDirAsHomeHook,
@@ -14,7 +15,12 @@ let
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
inherit (opencode) version src node_modules;
inherit (opencode)
version
src
node_modules
patches
;
nativeBuildInputs = [
bun
@@ -23,6 +29,9 @@ stdenv.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-1RiaZQHzIhdtcOJUMsLagpP+nBBL/Qu6zQgrAXMHDCI=",
"aarch64-linux": "sha256-5QZhtkWuNpY/qUxlKRHcGbILOAVnuyzu+h8VDIuMQcU=",
"aarch64-darwin": "sha256-9w3QA22XNc1itGLyhikYU90xGH/iLUUM+SSGXla7lhw=",
"x86_64-darwin": "sha256-cSYiyhhSqIYiTeK1uWHDkHbYstQYD5jEB7JaYjWjgi4="
"x86_64-linux": "sha256-aHOQ9TMdmfbSIuKee1uflHiuAQ3+69NH7RUMv6LztVY=",
"aarch64-linux": "sha256-DBBT/nLkLOtHlKL3ea/Q7ysTG5asnWUx0zQ90+D6cuI=",
"aarch64-darwin": "sha256-VIiz1RRTu/qdWd63tXv5kxVTFYjqZbnbaKaR7V++7lI=",
"x86_64-darwin": "sha256-TUtJaedvRpZcM7Fs5/P6fkI/aT+rIo3Wsp9kZiN45Rg="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.7",
"version": "1.15.10",
"description": "",
"type": "module",
"exports": {
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory } from "./bootstrap"
import type { State, VcsCache } from "./types"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
await bootstrapDirectory({
directory: "/project",
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
})
})
@@ -220,6 +220,7 @@ export async function bootstrapDirectory(input: {
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false }))
}
if (loading) input.setStore("status", "partial")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
@@ -326,5 +327,7 @@ export async function bootstrapDirectory(input: {
description: formatServerError(slowErrs[0], input.translate),
})
}
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
@@ -1,10 +1,63 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner } from "solid-js"
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import { createChildStoreManager } from "./child-store"
import type { QueryOptionsApi } from "../global-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
path: (directory: string | null) => ({
queryKey: [directory, "path"],
queryFn: async () => ({
state: "",
config: "",
worktree: "",
directory: directory ?? "",
home: "",
}),
}),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi
function createOwner(callback: (owner: Owner) => void) {
return createRoot((dispose) => {
const owner = getOwner()
if (!owner) throw new Error("owner required")
callback(owner)
return dispose
})
}
beforeAll(async () => {
mock.module("@/utils/persist", () => ({
Persist: {
workspace: (...parts: string[]) => parts.join(":"),
},
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: () => [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
],
}))
createChildStoreManager = (await import("./child-store")).createChildStoreManager
})
describe("createChildStoreManager", () => {
test("does not evict the active directory during mark", () => {
@@ -22,8 +75,8 @@ describe("createChildStoreManager", () => {
onBootstrap() {},
onDispose() {},
translate: (key) => key,
queryOptions: {} as any,
global: { provider: null! },
queryOptions: queryOptionsApi,
global: { provider },
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -37,4 +90,35 @@ describe("createChildStoreManager", () => {
expect(manager.children[directory]).toBeDefined()
})
test("starts new child stores as loading and bootstraps them on first access", () => {
const bootstraps: string[] = []
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project")
expect(store.status).toBe("loading")
expect(bootstraps).toEqual(["/project"])
} finally {
dispose()
}
})
})
@@ -202,7 +202,7 @@ export function createChildStoreManager(input: {
return { state: "", config: "", worktree: "", directory: "", home: "" }
return pathQuery.data
},
status: "complete" as const,
status: "loading" as const,
agent: [],
command: [],
session: [],
+57 -181
View File
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -18,7 +18,6 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@@ -467,11 +466,6 @@ function LegacyHome() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const [promptText, setPromptText] = createSignal("")
const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
@@ -480,8 +474,6 @@ function LegacyHome() {
.slice(0, 5)
})
const currentProject = createMemo(() => recent()[0]?.worktree)
const serverDotClass = createMemo(() => {
const healthy = server.healthy()
if (healthy === true) return "bg-icon-success-base"
@@ -520,185 +512,69 @@ function LegacyHome() {
}
}
function handleModelSelect() {
dialog.show(() => <DialogSelectModel />)
}
function toggleAgent() {
const agents = ["frontend-specialist", "build", "general"]
const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
setSelectedAgent(agents[nextIndex])
}
function handleSubmit() {
const projectToOpen = currentProject()
if (projectToOpen) {
openProject(projectToOpen)
} else {
chooseProject()
}
}
const activeModelName = createMemo(() => {
const model = sync.data.config.model
if (!model) return "GPT-5.7 Pro"
const parts = model.split("/")
return parts[parts.length - 1]
})
return (
<div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center">
<div class="flex flex-col items-center gap-3 mb-10">
<div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200">
<Logo class="w-48 opacity-15" />
</div>
<Button
size="normal"
variant="ghost"
class="text-12-regular text-text-weak px-3"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-1.5 rounded-full mr-2": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
</div>
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<Switch>
<Match when={recent().length > 0}>
<div class="w-full flex flex-col items-center gap-6">
<div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative">
<textarea
class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none"
placeholder="Ask anything, / for commands, @ for context..."
value={promptText()}
onInput={(e) => setPromptText(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}}
/>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={toggleAgent}
>
<Icon name="sliders" size="small" class="shrink-0" />
<span>Agent: {selectedAgent()}</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
<div class="relative">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
</Button>
<Show when={showProjectsDropdown()}>
<div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
<div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
{language.t("home.recentProjects")}
</div>
<For each={recent()}>
{(project) => (
<button
class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
onClick={() => {
openProject(project.worktree)
setShowProjectsDropdown(false)
}}
>
<span class="truncate">{getFilename(project.worktree)}</span>
<span class="text-10-regular text-text-weak shrink-0 pl-2">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</span>
</button>
)}
</For>
<div class="border-t border-border-weak-base my-1" />
<button
class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
onClick={() => {
setShowProjectsDropdown(false)
chooseProject()
}}
>
<Icon name="folder-add-left" size="small" />
{language.t("command.project.open")}
</button>
</div>
</Show>
</div>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
>
<Icon name="branch" size="small" class="shrink-0" />
<span>Branch: dev</span>
</Button>
</div>
<Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="w-full flex flex-col items-center gap-6">
<div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md">
<div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}>
Ask anything, / for commands, @ for context...
</div>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={chooseProject}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Open project</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
</div>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
<Button class="px-3 mt-1" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
+3 -1
View File
@@ -59,6 +59,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
@@ -263,7 +264,8 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () => import.meta.env.VITE_OPENCODE_CHANNEL === "prod" || !params.id
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
})
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
})
})
@@ -0,0 +1,3 @@
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.7",
"version": "1.15.10",
"type": "module",
"license": "MIT",
"scripts": {
@@ -10,9 +10,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits()
const headersExist = Object.entries(limits.checkHeaders).every(
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
)
// temporarily disable check headers
//const headersExist = Object.entries(limits.checkHeaders).every(
// ([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
//)
//const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
const headersExist = true
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
const isDefaultModel = headersExist && !rateLimit
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.7",
"version": "1.15.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.7",
"version": "1.15.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.7",
"version": "1.15.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+5 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.10",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -27,15 +27,15 @@
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -49,10 +49,8 @@
"@aws-sdk/credential-providers": "3.993.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
@@ -60,7 +58,6 @@
"@openrouter/ai-sdk-provider": "2.8.1",
"ai-gateway-provider": "3.1.2",
"cross-spawn": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"gitlab-ai-provider": "6.7.0",
"glob": "13.0.5",
@@ -70,7 +67,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"venice-ai-sdk-provider": "2.0.1",
"venice-ai-sdk-provider": "2.0.2",
"xdg-basedir": "5.1.0",
"zod": "catalog:"
},
-49
View File
@@ -1,49 +0,0 @@
export * as Database from "./database"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import path from "path"
import { DatabaseMigration } from "./migration"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
export class Service extends Context.Service<Service, DatabaseShape>()("@opencode/v2/storage/Database") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
console.log(DatabaseMigration.ensure
return db
}),
)
export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(SqliteClient.layer({ filename })))
}
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
return layerFromPath(
!Flag.OPENCODE_DB
? path.join(Global.Path.data, "opencode.db")
: Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)
? Flag.OPENCODE_DB
: path.join(Global.Path.data, Flag.OPENCODE_DB),
)
}),
).pipe(Layer.provide(Global.defaultLayer))
-248
View File
@@ -1,248 +0,0 @@
export * as DatabaseMigration from "./migration"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
import { getTableName, sql, type SQL, type Table } from "drizzle-orm"
import { getTableConfig, type AnySQLiteTable, type Index, type SQLiteColumn } from "drizzle-orm/sqlite-core"
export type SchemaAst = {
tables: Record<string, TableAst>
}
export type TableAst = {
name: string
columns: Record<string, ColumnAst>
indexes: Record<string, IndexAst>
}
export type ColumnAst = {
name: string
type: string
notNull: boolean
primaryKey: boolean
default?: string
}
export type IndexAst = {
name: string
table: string
columns: IndexColumnAst[]
unique: boolean
where?: string
}
export type IndexColumnAst = { type: "column"; name: string } | { type: "expression"; sql: string }
export type Operation =
| { type: "create_table"; table: TableAst }
| { type: "add_column"; table: string; column: ColumnAst }
| { type: "create_index"; index: IndexAst }
export function diff(db: EffectDrizzleSqlite.EffectSQLiteDatabase, tables: Table[]) {
return read(db).pipe(Effect.map((actual) => diffSchema(actual, fromTables(tables))))
}
export function apply(db: EffectDrizzleSqlite.EffectSQLiteDatabase, operations: Operation[]) {
return Effect.forEach(operations, (operation) => db.run(toSql(operation))).pipe(Effect.asVoid)
}
function fromTables(tables: Table[]): SchemaAst {
return {
tables: Object.fromEntries(tables.map((table) => {
const config = getTableConfig(table as AnySQLiteTable)
const name = getTableName(table)
return [name, tableFromConfig(name, config.columns, config.indexes)]
})),
}
}
function diffSchema(actual: SchemaAst, desired: SchemaAst): Operation[] {
return Object.values(desired.tables).flatMap<Operation>((table) => {
const current = actual.tables[table.name]
if (!current) {
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
}
return [
...Object.values(table.columns)
.filter((column) => current.columns[column.name] === undefined)
.map((column) => addColumnOperation(table.name, column)),
...Object.values(table.indexes)
.filter((index) => current.indexes[index.name] === undefined)
.map(createIndexOperation),
]
})
}
function createTableOperation(table: TableAst): Operation {
return { type: "create_table", table }
}
function addColumnOperation(table: string, column: ColumnAst): Operation {
return { type: "add_column", table, column }
}
function createIndexOperation(index: IndexAst): Operation {
return { type: "create_index", index }
}
function toSql(operation: Operation) {
if (operation.type === "create_table") {
return `CREATE TABLE ${quoteIdentifier(operation.table.name)} (${Object.values(operation.table.columns)
.map((column) => columnSql(column, true))
.join(", ")})`
}
if (operation.type === "add_column") {
return `ALTER TABLE ${quoteIdentifier(operation.table)} ADD COLUMN ${columnSql(operation.column, false)}`
}
return [
"CREATE",
operation.index.unique ? "UNIQUE" : undefined,
"INDEX",
quoteIdentifier(operation.index.name),
"ON",
quoteIdentifier(operation.index.table),
`(${operation.index.columns.map(indexColumnSql).join(", ")})`,
operation.index.where === undefined ? undefined : `WHERE ${operation.index.where}`,
]
.filter((part) => part !== undefined)
.join(" ")
}
function read(db: EffectDrizzleSqlite.EffectSQLiteDatabase) {
return Effect.gen(function* () {
const rows = yield* db.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
const tables = yield* Effect.forEach(rows, (row) => readTable(db, row.name))
return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) }
})
}
function readTable(db: EffectDrizzleSqlite.EffectSQLiteDatabase, name: string) {
return Effect.gen(function* () {
const columns = yield* db.all<{
name: string
type: string
notnull: number
pk: number
dflt_value: string | null
}>(`PRAGMA table_info(${quoteIdentifier(name)})`)
const indexes = yield* db.all<{ name: string; unique: number }>(`PRAGMA index_list(${quoteIdentifier(name)})`)
const indexEntries = yield* Effect.forEach(indexes, (index) =>
Effect.gen(function* () {
const statement = yield* db.get<{ sql: string | null }>(sql`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ${index.name}`)
if (statement?.sql === null || statement?.sql === undefined) return undefined
const columns = yield* db.all<{ seqno: number; name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index.name)})`)
return [
index.name,
{
name: index.name,
table: name,
columns: columns.map((column) =>
column.name === null
? ({ type: "expression", sql: "" } as const)
: ({ type: "column", name: column.name } as const),
),
unique: index.unique === 1,
},
] as const
}),
)
return {
name,
columns: Object.fromEntries(columns.map((column) => [
column.name,
{
name: column.name,
type: column.type,
notNull: column.notnull === 1,
primaryKey: column.pk > 0,
...(column.dflt_value === null ? {} : { default: column.dflt_value }),
},
])),
indexes: Object.fromEntries(indexEntries.filter((entry) => entry !== undefined)),
}
})
}
function tableFromConfig(name: string, columns: SQLiteColumn[], indexes: Index[]): TableAst {
return {
name,
columns: Object.fromEntries(columns.map((column) => [column.name, columnFromConfig(column)])),
indexes: Object.fromEntries(indexes.map((index) => [index.config.name, indexFromConfig(index)])),
}
}
function columnFromConfig(column: SQLiteColumn): ColumnAst {
return {
name: column.name,
type: column.getSQLType(),
notNull: column.notNull,
primaryKey: column.primary,
...defaultFromColumn(column),
}
}
function defaultFromColumn(column: SQLiteColumn) {
if (column.default !== undefined) return { default: literal(column.default) }
if (column.defaultFn !== undefined) return { default: literal(column.defaultFn()) }
return {}
}
function indexFromConfig(index: Index): IndexAst {
return {
name: index.config.name,
table: getTableName(index.config.table),
columns: index.config.columns.map(indexColumnName),
unique: index.config.unique,
...(index.config.where === undefined ? {} : { where: compileSql(index.config.where) }),
}
}
function indexColumnName(column: SQLiteColumn | SQL) {
if ("name" in column) return { type: "column", name: column.name } as const
return { type: "expression", sql: compileSql(column) } as const
}
function compileSql(value: SQL) {
return value.getSQL().toQuery(new SQLiteCompiler()).sql.replace(/"(?:""|[^"])*"\./g, "")
}
function indexColumnSql(column: IndexColumnAst) {
if (column.type === "column") return quoteIdentifier(column.name)
return column.sql
}
function columnSql(column: ColumnAst, includePrimaryKey: boolean) {
return [
quoteIdentifier(column.name),
column.type,
includePrimaryKey && column.primaryKey ? "PRIMARY KEY" : undefined,
column.notNull ? "NOT NULL" : undefined,
column.default === undefined ? undefined : `DEFAULT ${column.default}`,
]
.filter((part) => part !== undefined)
.join(" ")
}
class SQLiteCompiler {
inlineParams = true
escapeName = (name: string) => {
return quoteIdentifier(name)
}
escapeParam = () => {
return "?"
}
escapeString = (value: string) => {
return `'${value.replaceAll("'", "''")}'`
}
}
function literal(value: unknown) {
if (typeof value === "number") return String(value)
if (typeof value === "boolean") return value ? "1" : "0"
if (value === null) return "NULL"
return `'${String(value).replaceAll("'", "''")}'`
}
function quoteIdentifier(value: string) {
return `"${value.replaceAll('"', '""')}"`
}
@@ -15,6 +15,7 @@ type ServiceUse<Identifier, Shape> = {
}
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
const cache = new Map<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>>()
// This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values.
const access = new Proxy(
@@ -22,7 +23,9 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
{
get: (_, key) => {
if (typeof key !== "string") return undefined
return (...args: unknown[]) =>
const cached = cache.get(key)
if (cached) return cached
const accessor = (...args: unknown[]) =>
tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape]
@@ -30,6 +33,8 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
})
cache.set(key, accessor)
return accessor
},
},
)
+4 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -39,6 +40,8 @@ export namespace AppFileSystem {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+114
View File
@@ -0,0 +1,114 @@
export * as Git from "./git"
import path from "path"
import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { AppFileSystem } from "./filesystem"
import { AppProcess } from "./process"
export interface Repo {
/**
* The root directory of the working tree that contains the input path.
*
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
* `/home/me/app-feature`.
*/
readonly directory: AbsolutePath
/**
* The shared Git storage directory used by this repo and any linked worktrees.
*
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
* For a linked worktree at `/home/me/app-feature` whose main checkout is
* `/home/me/app`, this is usually `/home/me/app/.git`.
*/
readonly store: AbsolutePath
}
export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
readonly roots: (repo: Repo) => Effect.Effect<string[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const proc = yield* AppProcess.Service
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
if (!dotgit) return undefined
const cwd = path.dirname(dotgit)
const git = run(cwd, proc)
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
if (commonDir.exitCode !== 0) return undefined
return {
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
} satisfies Repo
})
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
if (result.exitCode !== 0) return []
return result.text
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.toSorted()
})
return Service.of({ find, remote, roots })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
)
interface Result {
readonly exitCode: number
readonly text: string
}
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
const normalized = AppFileSystem.windowsPath(trimmed)
if (path.isAbsolute(normalized)) return path.normalize(normalized)
return path.resolve(cwd, normalized)
}
+1 -2
View File
@@ -1,10 +1,9 @@
import { Context, Schema } from "effect"
import { AbsolutePath } from "./schema"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: AbsolutePath,
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
-2
View File
@@ -3,8 +3,6 @@ import { AccountV2 } from "../account"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
// Depending on what account is active, enable matching providers for that
// service
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
+2 -1
View File
@@ -56,6 +56,7 @@ export const ModelsDevPlugin = PluginV2.define({
const catalog = yield* Catalog.Service
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const load = yield* catalog.loader()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
@@ -113,7 +114,7 @@ export const ModelsDevPlugin = PluginV2.define({
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }),
Effect.forkIn(scope, { startImmediately: true }),
)
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
})
+98 -99
View File
@@ -1,130 +1,129 @@
export * as Project from "./project"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppFileSystem } from "./filesystem"
import { AppProcess } from "./process"
import path from "path"
import { AbsolutePath, withStatics } from "./schema"
import type { Location } from "./location"
import { AppFileSystem } from "./filesystem"
import { Git } from "./git"
import { Hash } from "./util/hash"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
Schema.brand("Project.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export type ID = typeof ID.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
store: AbsolutePath,
}),
])
export type Vcs = typeof Vcs.Type
export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
vcs: Schema.optional(Vcs),
}) {}
export interface Interface {
readonly create: (input: AbsolutePath) => Promise<ID>
readonly locations: (projectID: ID) => Promise<Location.Ref[]>
// opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"]
// global -> ["~/.config/nvim", "/etc/nixos"]
readonly resolve: (input: AbsolutePath) => Promise<ID>
// ~/dev/projects/anomalyco/opencode -> opencode
// ~/dev/projects/anomalyco/opencode/packages/core -> opencode
// ~/.gitworktrees/anomalyci/opencode -> opencode
// ~/.config/nvim -> global
readonly resolve: (input: AbsolutePath) => Effect.Effect<
{
previous?: ID
id: ID
directory: AbsolutePath
vcs?: Vcs
},
never
>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
*
* This exists while the old opencode project service and this core project
* service work together: core resolves the ID, while the old service still owns
* database migration and persistence. The old service should call this after it
* finishes migrating from `resolve().previous` to `resolve().id`; once project
* persistence moves into core, this separate bridge method can go away.
*/
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
interface GitResult {
readonly exitCode: number
readonly text: () => string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const proc = yield* AppProcess.Service
const git = yield* Git.Service
const runGit = Effect.fn("Project.git")(
function* (args: string[], cwd: string) {
const result = yield* proc.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
}),
)
return {
exitCode: result.exitCode,
text: () => result.stdout.toString("utf8"),
} satisfies GitResult
},
Effect.catch(() =>
Effect.succeed({
exitCode: 1,
text: () => "",
} satisfies GitResult),
),
)
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
Effect.map((value) => (value ? ID.make(value) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
)
})
const resolveGitPath = (cwd: string, value: string) => {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
const normalized = AppFileSystem.windowsPath(trimmed)
if (path.isAbsolute(normalized)) return path.normalize(normalized)
return path.resolve(cwd, normalized)
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
const origin = yield* git.remote(repo)
if (!origin) return undefined
const normalized = url(origin)
if (!normalized) return undefined
return ID.make(Hash.fast(`git-remote:${normalized}`))
})
function url(input: string) {
const value = input.trim()
if (!value) return undefined
try {
const parsed = new URL(value)
if (parsed.protocol === "file:") return undefined
return parts(parsed.hostname, parsed.pathname)
} catch {
const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
if (scp) return parts(scp[2], scp[3])
return undefined
}
}
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map((x) => ID.make(x)),
Effect.catch(() => Effect.void),
)
function parts(host: string, name: string) {
const pathname = name
.replace(/^\/+/, "")
.replace(/\.git\/?$/, "")
.replace(/\/+$/, "")
if (!host || !pathname) return undefined
return `${host.toLowerCase()}/${pathname}`
}
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
const root = (yield* git.roots(repo))[0]
return root ? ID.make(root) : undefined
})
const resolve = async (input: AbsolutePath) =>
Effect.runPromise(
Effect.gen(function* () {
const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.void),
)
if (!repoPath) return ID.global
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
const cwd = path.dirname(repoPath)
const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd)
if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global
const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const gitPaths = parsed
.text()
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined
if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global
const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir))
if (cached) return cached
const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd))
.text()
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.toSorted()[0]
if (!id) return ID.global
yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore)
return ID.make(id)
}),
)
return Service.of({
create: async () => {
throw new Error("Project.create is not implemented")
},
locations: async () => [],
resolve,
return {
previous,
id: id ?? ID.global,
directory: repo.directory,
vcs: { type: "git" as const, store: repo.store },
}
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ resolve, commit })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
+6 -12
View File
@@ -1,5 +1,11 @@
import { Option, Schema, SchemaGetter } from "effect"
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = typeof AbsolutePath.Type
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = typeof RelativePath.Type
/**
* Integer greater than zero.
*/
@@ -10,18 +16,6 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
*/
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
/**
* Relative file path (e.g., `src/components/Button.tsx`).
*/
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = Schema.Schema.Type<typeof RelativePath>
/**
* Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`).
*/
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = Schema.Schema.Type<typeof AbsolutePath>
/**
* Optional public JSON field that can hold explicit `undefined` on the type
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
@@ -1,11 +1,11 @@
import { Schema } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { Session } from "./index"
import { FileAttachment, Prompt } from "./prompt"
import { EventV2 } from "./event"
import { ModelV2 } from "./model"
import { NonNegativeInt } from "./schema"
import { Session } from "./session"
import { FileAttachment, Prompt } from "./session-prompt"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
export { FileAttachment }
@@ -399,4 +399,4 @@ export const All = Schema.Union(
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./event"
export * as SessionEvent from "./session-event"
@@ -1,6 +1,6 @@
import { produce, type WritableDraft } from "immer"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionEvent } from "./session-event"
import { SessionMessage } from "./session-message"
export type MemoryState = {
messages: SessionMessage.Message[]
@@ -414,4 +414,4 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
return adapter.finish()
}
export * as SessionMessageUpdater from "./message-updater"
export * as SessionMessageUpdater from "./session-message-updater"
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
import { Prompt } from "./session-prompt"
import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
import { ModelV2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -170,4 +170,4 @@ export type Message = Schema.Schema.Type<typeof Message>
export type Type = Message["type"]
export * as SessionMessage from "./message"
export * as SessionMessage from "./session-message"
+13
View File
@@ -0,0 +1,13 @@
export * as Session from "./session"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
-127
View File
@@ -1,127 +0,0 @@
export * as Session from "."
import { Effect, Schema } from "effect"
import { AbsolutePath, RelativePath, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { Project } from "../project"
import { Workspace } from "../workspace"
import type { ModelV2 } from "../model"
import { Location } from "../location"
import type { SessionMessage } from "./message"
import type { Prompt } from "./prompt"
import type { EventV2 } from "../event"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
})
export type Delivery = Schema.Schema.Type<typeof Delivery>
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
location: Location.Ref,
subpath: RelativePath, // derived from location
project: Project.ID, // derived from location
})
export type Info = typeof Info.Type
// get project -> project.locations
//
// get all sessions
//
// - by project
// - by subpath
// - by workspace (home is special)
type Cursor = {}
type ListInput = {
workspaceID?: Workspace.ID
search?: string
cursor?: Cursor
limit?: number
order?: "asc" | "desc"
} & (
| {
project: Project.ID
subpath?: RelativePath
}
| {
directory?: AbsolutePath
}
)
type CreateInput = {
id?: ID
agent?: string
model?: ModelV2.Ref
location: Location.Ref
}
type MoveInput = {
sessionID: ID
location: Location.Ref
}
type CompactInput = {
sessionID: ID
prompt?: Prompt
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: ID,
}) {}
export type Error = NotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly messages: (input: {
sessionID: ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
time: number
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly context: (sessionID: ID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly prompt: (input: {
id?: EventV2.ID
sessionID: ID
prompt: Prompt
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, NotFoundError>
readonly shell: (input: {
id?: EventV2.ID
sessionID: ID
command: string
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly skill: (input: {
id?: EventV2.ID
sessionID: ID
skill: string
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError>
readonly wait: (id: ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: ID) => Effect.Effect<void>
}
-49
View File
@@ -1,49 +0,0 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Session } from "."
export const SessionTable = sqliteTable(
"session",
{
id: text().$type<Session.ID>().primaryKey(),
project_id: text().notNull(),
workspace_id: text(),
parent_id: text().$type<Session.ID>(),
slug: text().notNull(),
directory: text().notNull(),
path: text(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0),
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }),
permission: text({ mode: "json" }),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
providerID: string
variant?: string
}>(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
time_updated: integer()
.notNull()
.$onUpdate(() => Date.now()),
time_compacting: integer(),
time_archived: integer(),
},
(table) => [
index("session_project_idx").on(table.project_id),
index("session_workspace_idx").on(table.workspace_id),
index("session_parent_idx").on(table.parent_id),
],
)
-11
View File
@@ -1,11 +0,0 @@
export * as Workspace from "./workspace"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
@@ -1,158 +0,0 @@
import { describe, expect, test } from "bun:test"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
const rand = (seed: number) => () => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 0x100000000
}
describe("DatabaseMigration", () => {
test("diff creates missing tables before indexes", () => {
const table = makeTable(
"session",
[makeColumn("id", { primaryKey: true })],
[makeIndex("session_id_idx", "session", ["id"])],
)
expect(DatabaseMigration.diff(emptySchema(), schema(table))).toEqual([
{ type: "create_table", table },
{ type: "create_index", index: table.indexes.session_id_idx },
])
})
test("diff adds missing columns and indexes without recreating existing tables", () => {
const actual = makeTable("session", [makeColumn("id", { primaryKey: true })], [])
const desired = makeTable(
"session",
[makeColumn("id", { primaryKey: true }), makeColumn("title", { notNull: true })],
[makeIndex("session_title_idx", "session", ["title"])],
)
expect(DatabaseMigration.diff(schema(actual), schema(desired))).toEqual([
{ type: "add_column", table: "session", column: desired.columns.title },
{ type: "create_index", index: desired.indexes.session_title_idx },
])
})
test("diff is empty when actual already satisfies desired", () => {
const table = makeTable(
"session",
[makeColumn("id", { primaryKey: true }), makeColumn("title")],
[makeIndex("session_title_idx", "session", ["title"])],
)
expect(DatabaseMigration.diff(schema(table), schema(table))).toEqual([])
})
test("random desired schemas generate exactly missing additive operations", () => {
for (let seed = 1; seed <= 200; seed++) {
const random = rand(seed)
const desiredTables = Array.from({ length: 1 + Math.floor(random() * 5) }, (_, i) =>
makeRandomTable(random, `table_${i}`),
)
const actualTables = desiredTables
.filter(() => random() > 0.25)
.map((table) =>
makeTable(
table.name,
Object.values(table.columns).filter((column) => column.primaryKey || random() > 0.35),
Object.values(table.indexes).filter(() => random() > 0.5),
),
)
const operations = DatabaseMigration.diff(schema(...actualTables), schema(...desiredTables))
const expected = desiredTables.flatMap<DatabaseMigration.Operation>((table) => {
const actual = actualTables.find((item) => item.name === table.name)
if (!actual) {
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
}
return [
...Object.values(table.columns)
.filter((column) => actual.columns[column.name] === undefined)
.map((column) => addColumnOperation(table.name, column)),
...Object.values(table.indexes)
.filter((index) => actual.indexes[index.name] === undefined)
.map(createIndexOperation),
]
})
expect(operations).toEqual(expected)
}
})
test("random operations render quoted SQL", () => {
for (let seed = 1; seed <= 200; seed++) {
const random = rand(seed)
const table = makeRandomTable(random, `table_"${seed}`)
const operations = DatabaseMigration.diff(emptySchema(), schema(table))
for (const operation of operations) {
const rendered = DatabaseMigration.toSql(operation)
expect(rendered).not.toContain("undefined")
expect(rendered).toContain('"')
}
}
})
})
function emptySchema(): DatabaseMigration.SchemaAst {
return { tables: {} }
}
function schema(...tables: DatabaseMigration.TableAst[]): DatabaseMigration.SchemaAst {
return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) }
}
function makeTable(
name: string,
columns: DatabaseMigration.ColumnAst[],
indexes: DatabaseMigration.IndexAst[],
): DatabaseMigration.TableAst {
return {
name,
columns: Object.fromEntries(columns.map((column) => [column.name, column])),
indexes: Object.fromEntries(indexes.map((index) => [index.name, index])),
}
}
function createTableOperation(table: DatabaseMigration.TableAst): DatabaseMigration.Operation {
return { type: "create_table", table }
}
function addColumnOperation(table: string, column: DatabaseMigration.ColumnAst): DatabaseMigration.Operation {
return { type: "add_column", table, column }
}
function createIndexOperation(index: DatabaseMigration.IndexAst): DatabaseMigration.Operation {
return { type: "create_index", index }
}
function makeColumn(
name: string,
options: Partial<Omit<DatabaseMigration.ColumnAst, "name" | "type">> = {},
): DatabaseMigration.ColumnAst {
return {
name,
type: "text",
notNull: options.notNull ?? false,
primaryKey: options.primaryKey ?? false,
...(options.default === undefined ? {} : { default: options.default }),
}
}
function makeIndex(name: string, table: string, columns: string[], unique = false): DatabaseMigration.IndexAst {
return { name, table, columns, unique }
}
function makeRandomTable(random: () => number, name: string) {
const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i) =>
makeColumn(`column_${i}`, {
primaryKey: i === 0,
notNull: i === 0 || random() > 0.5,
default: random() > 0.7 ? String(Math.floor(random() * 100)) : undefined,
}),
)
const indexes = columns
.filter((column) => !column.primaryKey && random() > 0.5)
.map((column) => makeIndex(`${name}_${column.name}_idx`, name, [column.name], random() > 0.75))
return makeTable(name, columns, indexes)
}
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Project.defaultLayer)
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
return AbsolutePath.make(value)
}
function real(value: string) {
return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
}
async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
await $`git init`.cwd(dir).quiet()
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email test@opencode.test`.cwd(dir).quiet()
await $`git config user.name Test`.cwd(dir).quiet()
if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
}
async function rootCommit(dir: string) {
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
}
describe("ProjectV2.resolve", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
)
it.live("returns git global for repo with no commits and no remote", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
)
it.live("falls back to root commit when origin is missing", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
)
it.live("prefers normalized origin over root commit", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
)
it.live("normalizes ssh and https remotes to the same id", () =>
Effect.gen(function* () {
const ssh = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const https = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
expect(a.id).toBe(remoteID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("ignores file remotes and falls back to root commit", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
}),
)
it.live("returns previous cached id from common dir", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
}),
)
it.live("does not write the cache while resolving", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
const project = yield* Project.Service
yield* project.resolve(abs(tmp.path))
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
}),
)
it.live("resolves from nested directories to repo root", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.directory).toBe(yield* real(tmp.path))
}),
)
it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const worktree = `${tmp.path}-worktree`
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* Project.Service
const result = yield* project.resolve(abs(worktree))
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
}),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.7",
"version": "1.15.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+15 -2
View File
@@ -1,4 +1,7 @@
import windowState from "electron-window-state"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
@@ -15,6 +18,11 @@ const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
const documentPolicyHeader = "Document-Policy"
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
@@ -46,6 +54,7 @@ export function setRelaunchHandler(handler: () => void) {
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
}
export function getBackgroundColor(): string | undefined {
@@ -65,6 +74,10 @@ function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function defaultBackgroundColor() {
return oc2Background[tone()]
}
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
const mode = theme.mode ?? tone()
return {
@@ -120,7 +133,7 @@ export function createMainWindow() {
autoHideMenuBar: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -178,7 +191,7 @@ export function createLoadingWindow() {
show: true,
autoHideMenuBar: true,
icon: iconPath(),
backgroundColor,
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32"
? {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.10",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.7",
"version": "1.15.10",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.7"
version = "1.15.10"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.7",
"version": "1.15.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.10",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.10",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
@@ -14,6 +14,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -96,10 +97,18 @@ const AnthropicServerToolResultBlock = Schema.Struct({
})
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
// Anthropic accepts either a plain string or an ordered array of text/image
// blocks inside `tool_result.content`. The array form is required when a tool
// returns image bytes (screenshot, image search, etc.) so they can be passed
// to the model as proper image inputs instead of being JSON-stringified into
// the prompt — which silently inflates context by megabytes and can push the
// conversation over the model's token limit.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
const AnthropicToolResultBlock = Schema.Struct({
type: Schema.tag("tool_result"),
tool_use_id: Schema.String,
content: Schema.String,
content: Schema.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]),
is_error: Schema.optional(Schema.Boolean),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -197,7 +206,13 @@ const AnthropicEvent = Schema.Struct({
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
@@ -298,6 +313,33 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
} satisfies AnthropicImageBlock
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolResultContentPart,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
if (item.mediaType.startsWith("image/"))
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: item.mediaType,
data: ProviderShared.mediaBase64(item),
},
} satisfies AnthropicImageBlock
return yield* invalid(`Anthropic Messages tool-result media content only supports images, got ${item.mediaType}`)
})
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
// Text / json / error results stay as a string for backward compatibility
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
@@ -360,7 +402,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
content.push({
type: "tool_result",
tool_use_id: part.id,
content: ProviderShared.toolResultText(part),
content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
})
@@ -667,9 +709,18 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
return [{ ...state, lifecycle, usage }, events]
}
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
// even when the provider message is generic or empty.
const providerErrorMessage = (event: AnthropicEvent): string => {
const type = event.error?.type
const message = event.error?.message
if (type && message) return `${type}: ${message}`
return message || type || "Anthropic Messages stream error"
}
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
[LLMEvent.providerError({ message: providerErrorMessage(event) })],
]
const step = (state: ParserState, event: AnthropicEvent) => {
+283 -25
View File
@@ -14,6 +14,8 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
@@ -55,11 +57,27 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenAIResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images in addition to text.
// https://platform.openai.com/docs/api-reference/responses/object
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesFunctionCallOutput = Schema.Union([
Schema.String,
Schema.Array(OpenAIResponsesFunctionCallOutputContent),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
OpenAIResponsesReasoningItem,
OpenAIResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
call_id: Schema.String,
@@ -69,11 +87,20 @@ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("function_call_output"),
call_id: Schema.String,
output: Schema.String,
output: OpenAIResponsesFunctionCallOutput,
}),
])
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
type OpenAIResponsesReasoningInput = {
type: "reasoning"
id: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
const OpenAIResponsesTool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
@@ -100,7 +127,7 @@ const OpenAIResponsesCoreFields = {
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
reasoning: Schema.optional(
Schema.Struct({
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
@@ -166,10 +193,22 @@ const OpenAIResponsesStreamItem = Schema.Struct({
})
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
// OpenAI Responses surfaces provider failures in two related shapes. The
// streaming `error` event carries the details at the top level
// (`{ type: "error", code, message, param, sequence_number }`), while
// `response.failed` carries them under `response.error`. We capture both so
// the parser can surface a useful provider-error message in either path.
const OpenAIResponsesErrorPayload = Schema.Struct({
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenAIResponsesEvent = Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(OpenAIResponsesStreamItem),
response: Schema.optional(
Schema.StructWithRest(
@@ -178,12 +217,14 @@ const OpenAIResponsesEvent = Schema.Struct({
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
usage: optionalNull(OpenAIResponsesUsage),
error: optionalNull(OpenAIResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
param: Schema.optional(Schema.String),
})
type OpenAIResponsesEvent = Schema.Schema.Type<typeof OpenAIResponsesEvent>
@@ -191,6 +232,18 @@ interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly encryptedContent: string | null | undefined
// Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
}
const invalid = ProviderShared.invalidRequest
@@ -220,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
const openai = part.providerMetadata?.openai
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
// With store:false, OpenAI only accepts previous reasoning items when the
// encrypted state is present. Bare rs_* ids point to non-persisted items.
if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
return undefined
const encryptedContent =
typeof openai.reasoningEncryptedContent === "string"
? openai.reasoningEncryptedContent
: openai.reasoningEncryptedContent === null
? null
: undefined
return {
type: "reasoning",
id: openai.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content:
typeof openai.reasoningEncryptedContent === "string"
? openai.reasoningEncryptedContent
: openai.reasoningEncryptedContent === null
? null
: undefined,
encrypted_content: encryptedContent,
}
}
@@ -250,6 +302,27 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function*
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolResultContentPart,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
if (item.mediaType.startsWith("image/"))
return {
type: "input_image" as const,
image_url: ProviderShared.mediaDataUrl(item),
}
return yield* invalid(`OpenAI Responses tool-result media content only supports images, got ${item.mediaType}`)
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
@@ -264,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
@@ -276,8 +351,22 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, store)
if (reasoning) input.push(reasoning)
const reasoning = lowerReasoning(part)
if (!reasoning) continue
if (store !== false && reasoning.id) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
@@ -298,11 +387,22 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"])
input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) })
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part),
})
}
}
return input
// With store:false, OpenAI only accepts previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
})
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
@@ -312,14 +412,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
if (effort && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
const summary = OpenAIOptions.reasoningSummary(request)
const encryptedState = OpenAIOptions.encryptedReasoning(request)
const include = OpenAIOptions.include(request)
const verbosity = OpenAIOptions.textVerbosity(request)
const instructions = OpenAIOptions.instructions(request)
return {
...(instructions ? { instructions } : {}),
...(store !== undefined ? { store } : {}),
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
...(include ? { include } : {}),
...(effort || summary ? { reasoning: { effort, summary } } : {}),
...(verbosity ? { text: { verbosity } } : {}),
}
@@ -467,24 +567,51 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "reasoning-0"
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
}
// The summary done event does not carry encrypted continuation state. Finish the
// common reasoning block when the full reasoning item arrives in output_item.done.
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// OpenAI Responses streams reasoning items in a stable order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
},
},
events,
]
}
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const providerMetadata = openaiMetadata({ itemId: item.id })
const events: LLMEvent[] = []
@@ -505,6 +632,103 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste
]
}
const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
openaiMetadata({ itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
events,
`${event.item_id}:${event.summary_index}`,
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
[event.summary_index]: "active",
},
},
},
},
events,
]
}
const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
openaiMetadata({ itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
},
},
},
},
events,
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: OpenAIResponsesEvent,
@@ -565,6 +789,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const providerMetadata = reasoningMetadata(item)
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
@@ -596,14 +831,27 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step
return [{ ...state, lifecycle }, events]
}
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => {
const nested = event.response?.error ?? undefined
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code || fallback
}
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
@@ -620,6 +868,10 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
event.type === "response.reasoning_summary_text.done"
)
return Effect.succeed(onReasoningDone(state, event))
if (event.type === "response.reasoning_summary_part.added")
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
if (event.type === "response.reasoning_summary_part.done")
return Effect.succeed(onReasoningSummaryPartDone(state, event))
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
@@ -646,7 +898,13 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIResponsesEvent),
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
initial: (request) => ({
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
reasoningItems: {},
store: OpenAIOptions.store(request),
}),
step,
terminal: (event) => TERMINAL_TYPES.has(event.type),
},
+14 -6
View File
@@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
export const reasoningStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
): State => {
if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
if (stepped.reasoning.has(id)) {
events.push(LLMEvent.reasoningDelta({ id, text }))
return stepped
}
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = reasoningStart(state, events, id)
events.push(LLMEvent.reasoningDelta({ id, text }))
return started
}
export const reasoningEnd = (
state: State,
events: LLMEvent[],
@@ -7,12 +7,28 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
)
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = [
"file_search_call.results",
"web_search_call.results",
"web_search_call.action.sources",
"message.input_image.image_url",
"computer_call_output.output.image_url",
"code_interpreter_call.outputs",
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
typeof effort === "string" && REASONING_EFFORTS.has(effort)
@@ -35,12 +51,20 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine
return isAnyReasoningEffort(value) ? value : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
export const encryptedReasoning = (request: LLMRequest) =>
options(request)?.includeEncryptedReasoning === true ? true : undefined
// Resolve the OpenAI Responses `include` field. Filters out unknown
// includable values defensively so a typo in upstream config drops the
// invalid entry instead of poisoning the wire body. An empty array (either
// passed directly or produced by filtering) is treated as "no include" and
// returns undefined so the request body omits the field entirely.
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
const value = options(request)?.include
if (!Array.isArray(value)) return undefined
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
return filtered.length > 0 ? filtered : undefined
}
export const promptCacheKey = (request: LLMRequest) => {
const value = options(request)?.promptCacheKey
+14 -2
View File
@@ -1,5 +1,8 @@
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
import { mergeProviderOptions } from "../schema"
import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
export interface OpenAIOptionsInput {
readonly [key: string]: unknown
@@ -7,7 +10,10 @@ export interface OpenAIOptionsInput {
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto"
readonly includeEncryptedReasoning?: boolean
// OpenAI Responses `include` wire field. Mirrors the official SDK's
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
// native-SDK callers share one shape and no translation is required.
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
readonly textVerbosity?: TextVerbosity
}
@@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
promptCacheKey: options?.promptCacheKey,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
includeEncryptedReasoning: options?.includeEncryptedReasoning,
include: options?.include,
textVerbosity: options?.textVerbosity,
}),
)
@@ -42,6 +48,12 @@ export const gpt5DefaultOptions = (
return openAIProviderOptions({
reasoningEffort: "medium",
reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by
// `openAIDefaultOptions` below, so the only way a follow-up turn can
// carry reasoning state is via the encrypted reasoning include. Without
// this, callers using the default model facade get reasoning summaries
// they cannot replay statelessly.
include: ["reasoning.encrypted_content"],
textVerbosity:
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
+1 -1
View File
@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export type { OpenAIOptionsInput } from "./openai-options"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export const id = ProviderID.make("openai")
+1 -1
View File
@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
)
return events.pipe(
Stream.mapAccumEffect(
protocol.stream.initial,
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
+2 -2
View File
@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
export interface ProtocolStream<Frame, Event, State> {
/** Schema for one decoded streaming event, decoded from a transport frame. */
readonly event: Schema.Codec<Event, Frame>
/** Initial parser state. Called once per response. */
readonly initial: () => State
/** Initial parser state. Called once per response with the resolved request. */
readonly initial: (request: LLMRequest) => State
/** Translate one event into emitted `LLMEvent`s plus the next state. */
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
/** Optional request-completion signal for transports that do not end naturally. */
+1 -1
View File
@@ -97,7 +97,7 @@ export function continuationRequest(input: {
tools: features.has("tool-call") ? [continuationTool] : [],
cache: "none",
providerOptions: features.has("encrypted-reasoning")
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
: undefined,
generation: { maxTokens: 80, temperature: 0 },
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -24,6 +24,19 @@ const request = LLM.request({
generation: { maxTokens: 20, temperature: 0 },
})
type AnthropicToolResult = Extract<
AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number],
{ readonly type: "tool_result" }
>
const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): AnthropicToolResult => {
const result = body.messages
.flatMap((message) => (message.role === "user" ? message.content : []))
.find((block): block is AnthropicToolResult => block.type === "tool_result")
expect(result).toBeDefined()
return result!
}
describe("Anthropic Messages route", () => {
it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () {
@@ -71,6 +84,87 @@ describe("Anthropic Messages route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `tool_result.content`.
it.effect("lowers image tool-result content as structured image blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result_image",
model,
messages: [
Message.user("Show me the screenshot."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
],
}),
],
cache: "none",
}),
)
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
])
}),
)
it.effect("lowers single-image tool-result content as a structured image block", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result_image_only",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
Message.tool({
id: "call_1",
name: "screenshot",
resultType: "content",
result: [{ type: "media", mediaType: "image/jpeg", data: "/9j/AA==" }],
}),
],
cache: "none",
}),
)
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "/9j/AA==" } },
])
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
}),
],
cache: "none",
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic Messages")
expect(error.message).toContain("audio/mpeg")
}),
)
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
@@ -243,7 +337,29 @@ describe("Anthropic Messages route", () => {
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
// Prefix the error type so consumers can distinguish overloads, rate
// limits, and quota errors without parsing the message string.
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
}),
)
it.effect("falls back to error type when no message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
}),
)
it.effect("falls back to a stable default when error payload is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
}),
)
@@ -87,6 +87,7 @@ describeRecordedGoldenScenarios([
{ id: "reasoning-continuation", temperature: false },
{ id: "tool-call", temperature: false },
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
@@ -112,7 +113,10 @@ describeRecordedGoldenScenarios([
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: [{ id: "tool-loop", temperature: false }],
scenarios: [
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
name: "Gemini 2.5 Flash",
@@ -26,6 +26,19 @@ const request = LLM.request({
const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
type OpenAIToolOutput = Extract<
OpenAIResponses.OpenAIResponsesBody["input"][number],
{ readonly type: "function_call_output" }
>
const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => {
const output = body.input.find(
(item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output",
)
expect(output).toBeDefined()
return output!
}
describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
@@ -248,6 +261,84 @@ describe("OpenAI Responses route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `function_call_output.output`.
it.effect("lowers image tool-result content as structured input_image items", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_image",
model,
messages: [
Message.user("Show me the screenshot."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_text", text: "Image read successfully" },
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
])
}),
)
it.effect("lowers single-image tool-result content as structured input_image array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_image_only",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
Message.tool({
id: "call_1",
name: "screenshot",
resultType: "content",
result: [{ type: "media", mediaType: "image/png", data: "AAECAw==" }],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
])
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
}),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Responses")
expect(error.message).toContain("audio/mpeg")
}),
)
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -302,7 +393,7 @@ describe("OpenAI Responses route", () => {
promptCacheKey: "session_123",
reasoningEffort: "high",
reasoningSummary: "auto",
includeEncryptedReasoning: true,
include: ["reasoning.encrypted_content"],
},
},
}),
@@ -316,6 +407,108 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "hi",
providerOptions: {
openai: {
include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
},
},
}),
)
expect(prepared.body.include).toEqual([
"reasoning.encrypted_content",
"code_interpreter_call.outputs",
"web_search_call.results",
])
}),
)
it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}),
)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("omits include when no include is set", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
Effect.gen(function* () {
// The native OpenAI facade configures GPT-5 stateless (store: false) with
// reasoningSummary: "auto" by default. Without `include`, a follow-up
// turn cannot replay reasoning state, so the facade also opts into
// `reasoning.encrypted_content` automatically.
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
}),
)
it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
providerOptions: { openai: { include: [] } },
}),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -456,6 +649,94 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
])
}),
)
it.effect("continues a stateless reasoning conversation", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -479,6 +760,7 @@ describe("OpenAI Responses route", () => {
]),
Message.user("Summarize it."),
],
providerOptions: { openai: { store: false } },
}),
).pipe(
Effect.provide(
@@ -536,6 +818,7 @@ describe("OpenAI Responses route", () => {
{ type: "text", text: "After." },
]),
],
providerOptions: { openai: { store: false } },
}),
)
@@ -552,6 +835,66 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: { openai: { itemId: "rs_1" } },
},
]),
],
providerOptions: { openai: { store: true } },
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_multi_summary_continuation",
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
]),
],
providerOptions: { openai: { store: false } },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
])
}),
)
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
@@ -786,7 +1129,11 @@ describe("OpenAI Responses route", () => {
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }])
// Prefix the code so consumers see the failure mode, not just the
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
}),
)
@@ -800,6 +1147,99 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to error code when message is empty", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
}),
)
// Regression: `response.failed` carries the failure details under
// `response.error`, not at the top level. The previous handler only
// checked top-level `message`/`code` and so always emitted the bare
// "OpenAI Responses response failed" string, hiding the real cause.
it.effect("surfaces response.failed details from response.error", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.failed",
response: {
id: "resp_failed_1",
error: { code: "server_error", message: "Upstream model unavailable" },
},
}),
),
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.failed",
response: { id: "resp_failed_2", error: { code: "invalid_prompt" } },
}),
),
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
}),
)
it.effect("surfaces error event details even when they arrive nested under response.error", () =>
Effect.gen(function* () {
// Some OpenAI-compatible proxies and older SDK versions wrap the
// top-level error fields into a nested `response.error` payload
// when they bubble up an HTTP error as an SSE `error` event. Honour
// both shapes so the user still sees the underlying cause instead
// of the catch-all string.
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "error",
response: { error: { code: "context_length_exceeded", message: "prompt too long" } },
}),
),
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "context_length_exceeded: prompt too long" }])
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
}),
)
it.effect("fails HTTP provider errors before stream parsing", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
+47 -1
View File
@@ -158,7 +158,7 @@ const normalizeImageText = (value: string) =>
const encryptedReasoningOptions = {
openai: {
store: false,
includeEncryptedReasoning: true,
include: ["reasoning.encrypted_content"],
reasoningEffort: "low",
reasoningSummary: "auto",
},
@@ -317,6 +317,47 @@ const runImageScenario = (context: GoldenScenarioContext) =>
])
})
// Reproduces a tool-result image round trip: a tool returns image bytes, and
// the next model turn must receive provider-native image content instead of a
// JSON-stringified base64 blob.
const screenshotToolName = "read_screenshot"
const runImageToolResultScenario = (context: GoldenScenarioContext) =>
Effect.gen(function* () {
const image = yield* restroomImage()
const response = yield* generate(
LLM.request({
id: `${context.id}_image_tool_result`,
model: context.model,
system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.",
cache: "none",
generation: generation(context, context.maxTokens ?? 40),
messages: [
Message.user("Use the read_screenshot tool, then reply with the words shown."),
Message.assistant([{ type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }]),
Message.tool({
id: "call_screenshot_1",
name: screenshotToolName,
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: image },
],
}),
],
tools: [
ToolDefinition.make({
name: screenshotToolName,
description: "Capture a screenshot of the current screen.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}),
],
}),
)
expectFinish(response.events, "stop")
expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
})
const runReasoningScenario = (context: GoldenScenarioContext) =>
runGeneratedConversation(context, [
user("Think briefly, then reply exactly with: Hello!"),
@@ -359,6 +400,11 @@ const goldenScenarios = {
"tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario },
"tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario },
image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
"image-tool-result": {
title: "reads image returned from tool result",
tags: ["media", "image", "vision", "tool", "tool-result", "golden"],
run: runImageToolResultScenario,
},
reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario },
"reasoning-continuation": {
title: "continues encrypted reasoning",
+75
View File
@@ -4,6 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
import { Auth, LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
import { ToolRuntime } from "../src/tool-runtime"
import { it } from "./lib/effect"
@@ -309,6 +310,80 @@ describe("LLMClient tools", () => {
}),
)
it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
Effect.gen(function* () {
const bodies: unknown[] = []
const layer = dynamicResponse((input) =>
Effect.sync(() => {
bodies.push(decodeJson(input.text))
return input.respond(
bodies.length === 1
? sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{
type: "response.output_item.added",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "get_weather",
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "get_weather",
arguments: '{"city":"Paris"}',
},
},
{ type: "response.completed", response: {} },
)
: sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
{ type: "response.completed", response: {} },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
)
yield* TestToolRuntime.runTools({
request: LLM.request({
model: OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-5.5" }),
prompt: "Use the tool.",
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
}),
tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer))
expect(bodies[1]).toMatchObject({
include: ["reasoning.encrypted_content"],
input: [
{ role: "user" },
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
{ type: "function_call", call_id: "call_1", name: "get_weather" },
{ type: "function_call_output", call_id: "call_1" },
],
})
}),
)
it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
+5 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -74,15 +74,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -159,7 +159,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.1",
"venice-ai-sdk-provider": "2.0.2",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
+1 -1
View File
@@ -1,5 +1,5 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import {
FetchHttpClient,
HttpClient,
+1 -1
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
+2 -2
View File
@@ -1,5 +1,5 @@
import { Config } from "@/config/config"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
@@ -69,7 +69,7 @@ export interface Interface {
whenToUse: string
systemPrompt: string
},
Provider.ModelNotFoundError
Provider.DefaultModelError
>
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
+30 -3
View File
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Effect } from "effect"
import { Cause, Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
@@ -80,7 +80,21 @@ const run = Effect.fn("Cli.debug.agent.body")(function* (
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
const provider = yield* Provider.Service
const registry = yield* ToolRegistry.Service
const model = agent.model ?? (yield* provider.defaultModel())
const model =
agent.model ??
(yield* provider.defaultModel().pipe(
Effect.matchCauseEffect({
onSuccess: Effect.succeed,
onFailure: (cause) => {
const error = Cause.squash(cause) as Provider.DefaultModelError
if (error instanceof Provider.ModelNotFoundError) {
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
}
if (error instanceof Provider.NoModelsError) return fail(`No models found for provider ${error.providerID}`)
return fail("No providers found")
},
}),
))
return yield* registry.tools({ ...model, agent })
})
@@ -133,7 +147,20 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
return yield* provider.defaultModel().pipe(
Effect.matchCauseEffect({
onSuccess: Effect.succeed,
onFailure: (cause) => {
const error = Cause.squash(cause) as Provider.DefaultModelError
if (error instanceof Provider.ModelNotFoundError) {
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
}
if (error instanceof Provider.NoModelsError)
return fail(`No models found for provider ${error.providerID}`)
return fail("No providers found")
},
}),
)
})
const now = Date.now()
const message: MessageV2.Assistant = {
@@ -62,14 +62,16 @@ export const Definitions = {
diff_close: keybind("escape,q", "Close diff viewer"),
diff_toggle: keybind("enter,space", "Toggle diff viewer item"),
diff_expand: keybind("right", "Expand diff viewer item"),
diff_expand_all: keybind("E", "Expand all diff viewer folders"),
diff_collapse: keybind("left", "Collapse diff viewer item"),
diff_switch_focus: keybind("tab", "Switch diff viewer focus"),
diff_next_file: keybind("n", "Jump to next diff file"),
diff_previous_file: keybind("p", "Jump to previous diff file"),
diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"),
diff_single_patch: keybind("s", "Toggle single patch view"),
diff_switch_diff: keybind("d", "Switch diff viewer source"),
diff_switch_source: keybind("d", "Switch diff viewer source"),
diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"),
diff_help: keybind("?", "Show more diff viewer shortcuts"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
@@ -259,14 +261,16 @@ export const CommandMap = {
diff_close: "diff.close",
diff_toggle: "diff.toggle",
diff_expand: "diff.expand",
diff_expand_all: "diff.expand_all",
diff_collapse: "diff.collapse",
diff_switch_focus: "diff.switch_focus",
diff_next_file: "diff.next_file",
diff_previous_file: "diff.previous_file",
diff_toggle_file_tree: "diff.toggle_file_tree",
diff_single_patch: "diff.single_patch",
diff_switch_diff: "diff.switch_diff",
diff_switch_source: "diff.switch_source",
diff_toggle_view: "diff.toggle_view",
diff_help: "diff.help",
editor_open: "prompt.editor",
theme_list: "theme.switch",
theme_switch_mode: "theme.switch_mode",
@@ -157,6 +157,39 @@ export function moveFileTreeSelectionToFile(
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
return {
highlightedNode: node.id,
expandedNodes: fileTreeParentDirectories(tree, node.id),
}
}
export function singlePatchFileIndex(
selected: number | undefined,
active: number | undefined,
current: number | undefined,
first: number | undefined,
) {
return selected ?? active ?? current ?? first
}
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
}
export function showDiffViewerFileTree(showFileTree: boolean, fileCount: number) {
return showFileTree && fileCount > 0
}
export function movePatchFileIndex(fileIndexes: readonly number[], current: number | undefined, offset: number) {
if (fileIndexes.length === 0) return undefined
const index = current === undefined ? -1 : fileIndexes.indexOf(current)
if (index === -1) return fileIndexes[0]
return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
}
export function allExpandedFileTreeDirectories(tree: FileTree) {
return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
}
@@ -189,3 +222,11 @@ function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<Fil
else nodes[input.parent]!.children.push(id)
return id
}
function fileTreeParentDirectories(tree: FileTree, id: number) {
const result = new Set<number>()
for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
result.add(parent)
}
return result
}
@@ -6,7 +6,6 @@ import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
const FILE_TREE_HORIZONTAL_PADDING = 2
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeTheme = {
@@ -32,6 +31,7 @@ export type DiffViewerFileTreeProps = {
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number>
readonly onRowClick?: (row: FileTreeRow) => void
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
@@ -72,20 +72,18 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && props.reviewedFileNames?.has(file)
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const name = () =>
Locale.truncate(
row.name,
Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length),
)
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
return (
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? props.theme.primary : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
{prefix()}
@@ -95,13 +93,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
fg={
highlighted()
? props.theme.background
: reviewed()
? props.theme.textMuted
: selected()
? props.theme.primary
: row.kind === "directory"
? tint(props.theme.text, props.theme.background, 0.35)
: props.theme.text
: selected()
? props.theme.primary
: reviewed() || row.kind === "directory"
? props.theme.textMuted
: props.theme.text
}
wrapMode="none"
>
@@ -158,11 +154,9 @@ function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: num
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
}
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) {
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
if (row.fileIndex === undefined) return ""
const status = files[row.fileIndex]?.status
if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH)
if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH)
if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH)
return "?".padStart(FILE_TREE_STATUS_WIDTH)
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
}
@@ -1,7 +1,7 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { useTheme } from "@tui/context/theme"
import { createContext, splitProps, useContext } from "solid-js"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
@@ -63,22 +63,30 @@ export function Separator(props: { axis?: Axis; color?: ColorInput; start?: Sepa
const color = () => props.color ?? theme.border
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
if (axis() === "y") {
if (!props.start && !props.end) return <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
return (
<box width={1} flexShrink={0} flexDirection="column">
{props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
<box flexGrow={1} border={["left"]} borderColor={color()} />
{props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
</box>
<Show
when={props.start || props.end}
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
>
<box width={1} flexShrink={0} flexDirection="column">
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["left"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
return (
<box height={1} flexShrink={0} flexDirection="row">
{props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
<box flexGrow={1} border={["top"]} borderColor={color()} />
{props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
</box>
<Show
when={props.start || props.end}
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
>
<box height={1} flexShrink={0} flexDirection="row">
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["top"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
@@ -1,25 +1,30 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { useBindings, useCommandShortcut } from "@tui/keymap"
import { useTheme } from "@tui/context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "@tui/ui/dialog-select"
import {
allExpandedFileTreeDirectories,
buildFileTree,
fileTreeFileSelection,
type FileTreeRow,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
setFileTreeDirectoryExpanded,
showDiffViewerFileTree,
singlePatchFileIndex,
toggleFileTreeDirectory,
} from "./diff-viewer-file-tree-utils"
@@ -27,8 +32,13 @@ const ROUTE = "diff"
const MIN_SPLIT_WIDTH = 100
const FILE_TREE_WIDTH = 32
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
const WORKING_TREE_DIFF_CONTEXT_LINES = 12
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
const KV_VIEW = "diff_viewer_view"
type DiffMode = "git" | "last-turn"
type DiffViewerFocus = "patches" | "files"
type DiffView = "split" | "unified"
type DiffFile = {
readonly file: string
@@ -60,13 +70,22 @@ function filetype(input?: string) {
return language
}
function storedView(value: unknown): DiffView | undefined {
if (value === "split" || value === "unified") return value
}
function DiffViewer(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const themeState = useTheme()
const theme = () => props.api.theme.current
const params = () =>
("params" in props.api.route.current ? props.api.route.current.params : undefined) as
| { mode?: DiffMode; sessionID?: string; messageID?: string }
| {
mode?: DiffMode
sessionID?: string
messageID?: string
returnRoute?: TuiRouteCurrent
}
| undefined
const mode = () => params()?.mode ?? "git"
const diffInput = createMemo(() => ({
@@ -85,20 +104,27 @@ function DiffViewer(props: { api: TuiPluginApi }) {
return normalizeDiffs(result.data ?? [])
}
const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true })
const result = await props.api.client.vcs.diff(
{ mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES },
{ throwOnError: true },
)
return normalizeDiffs(result.data ?? [])
})
const files = createMemo(() => diff() ?? [])
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
const [showFileTree, setShowFileTree] = createSignal(true)
const [singlePatch, setSinglePatch] = createSignal(false)
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
)
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const defaultView = createMemo(() => {
if (props.api.tuiConfig.diff_style === "stacked") return "unified"
return splitAvailable() ? "split" : "unified"
})
const [viewOverride, setViewOverride] = createSignal<"split" | "unified">()
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
const fileTree = createMemo(() => buildFileTree(files()))
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
@@ -108,18 +134,23 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
const nextFileShortcut = useCommandShortcut("diff.next_file")
const previousFileShortcut = useCommandShortcut("diff.previous_file")
const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree")
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
const switchSourceShortcut = useCommandShortcut("diff.switch_source")
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
const helpShortcut = useCommandShortcut("diff.help")
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
onCleanup(() => props.api.ui.dialog.clear())
createEffect(() => {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
@@ -154,99 +185,155 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setActivePatchFileIndex(undefined)
}
const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
if (!scroll) return
const offset = fileIndex === 0 ? 0 : 1
scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
requestAnimationFrame(() => {
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
if (!scroll) return
const scrollDelta = patchNode.y - scroll.viewport.y
const contentY = scroll.scrollTop + scrollDelta
const offset = contentY === 0 ? 0 : 1
scroll.scrollBy(scrollDelta + offset)
})
}
const revealFileTreeFile = (fileIndex: number) => {
const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return
const selection = fileTreeFileSelection(fileTree(), fileIndex)
if (!selection) return
setExpandedFileNodes((expanded) => {
const next = new Set(expanded)
for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) {
next.add(parent)
}
selection.expandedNodes.forEach((node) => next.add(node))
return next
})
setHighlighted(node.id)
setHighlighted(selection.highlightedNode)
}
const selectPatchFile = (fileIndex: number) => {
revealFileTreeFile(fileIndex)
setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
}
const scrollToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
selectPatchFile(fileIndex)
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
}
const jumpToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
revealFileTreeFile(fileIndex)
scrollToFileIndex(fileIndex)
}
const currentPatchFileIndex = () => {
if (!scroll) return undefined
const entries = files()
.map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
const viewportContentY = scroll.scrollTop + 1
const entries = patchFileIndexes()
.map((fileIndex) => ({
fileIndex,
node: patchNodeByFileIndex.get(fileIndex),
}))
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
.sort((left, right) => left.node.y - right.node.y)
return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex
.map((entry) => ({
...entry,
contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y,
}))
.sort((left, right) => left.contentY - right.contentY)
return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex
}
const jumpRelativePatchFile = (offset: number) => {
const current = focus() === "files" ? highlightedFileNode() : undefined
const nextFromSelection =
current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset)
if (nextFromSelection !== undefined) {
jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex)
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
if (singlePatch()) {
if (next === undefined) return
selectPatchFile(next)
scrollSinglePatchToTop()
return
}
const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex()
const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex)
scrollToFileIndex(
fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex,
)
scrollToFileIndex(next)
}
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
const visiblePatchFiles = createMemo(() => {
if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex }))
const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex()
if (!singlePatch()) {
return patchFileIndexes().flatMap((fileIndex) => {
const file = files()[fileIndex]
return file ? [{ file, fileIndex }] : []
})
}
const fileIndex = singlePatchFileIndex(
selectedFileIndex(),
activePatchFileIndex(),
currentPatchFileIndex(),
firstPatchFileIndex(),
)
const file = fileIndex === undefined ? undefined : files()[fileIndex]
return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
})
const ensureHighlightedPatchFile = () => {
if (activePatchFileIndex() !== undefined) return
const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex()
if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex)
}
const scrollToHighlightedPatchFile = () => {
const fileIndex = activePatchFileIndex()
const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
if (fileIndex === undefined) return
setPendingPatchScrollFileIndex(fileIndex)
selectPatchFile(fileIndex)
}
const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
patchNodeByFileIndex.set(fileIndex, element)
if (pendingPatchScrollFileIndex() !== fileIndex) return
const scrollToPatchFileIndexAfterRender = (fileIndex: number) => {
setPendingPatchScrollFileIndex(fileIndex)
requestAnimationFrame(() => {
scrollPatchNodeToTop(element, fileIndex)
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
requestAnimationFrame(() => {
scrollPatchNodeToTop(element, fileIndex)
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
setPendingPatchScrollFileIndex(undefined)
})
})
}
const scrollSinglePatchToTop = () => {
requestAnimationFrame(() => {
scroll?.scrollTo(0)
requestAnimationFrame(() => scroll?.scrollTo(0))
})
}
const measurePatchFiller = () => {
requestAnimationFrame(() => {
if (!scroll) return
const entries = visiblePatchFiles()
.map((entry) => patchNodeByFileIndex.get(entry.fileIndex))
.filter((node): node is BoxRenderable => Boolean(node))
if (entries.length === 0) {
setPatchFillerHeight(0)
return
}
const contentHeight = Math.max(
...entries.map((node) => scroll!.scrollTop + node.y - scroll!.viewport.y + node.height),
)
setPatchFillerHeight(Math.max(0, scroll.viewport.height - contentHeight))
})
}
const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
patchNodeByFileIndex.set(fileIndex, element)
measurePatchFiller()
if (pendingPatchScrollFileIndex() !== fileIndex) return
requestAnimationFrame(() => {
scrollPatchNodeToTop(element)
requestAnimationFrame(() => {
scrollPatchNodeToTop(element)
setPendingPatchScrollFileIndex(undefined)
})
})
}
createEffect(() => {
visiblePatchFiles()
dimensions()
view()
measurePatchFiller()
})
const toggleSelectedFileTreeRow = () => {
const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
if (highlighted?.fileIndex !== undefined) {
@@ -256,6 +343,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
}
const clickFileTreeRow = (row: FileTreeRow) => {
setFocus("files")
setHighlighted(row.id)
if (row.fileIndex !== undefined) {
jumpToFileIndex(row.fileIndex)
return
}
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, row.id))
}
const toggleSelectedFileReviewed = () => {
const fileIndex =
focus() === "files"
@@ -277,7 +374,13 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Close diff viewer",
category: "VCS",
run() {
props.api.route.navigate("home")
const returnRoute = params()?.returnRoute
props.api.ui.dialog.clear()
props.api.route.navigate(
returnRoute?.name ?? "home",
returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
)
},
},
{
@@ -365,6 +468,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
patches() {},
}),
},
{
name: "diff.expand_all",
title: "Expand all diff viewer folders",
category: "VCS",
run: focusRunner({
files() {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
},
patches() {},
}),
},
{
name: "diff.collapse",
title: "Collapse diff viewer item",
@@ -426,10 +540,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle diff viewer file tree",
category: "VCS",
run() {
setShowFileTree((value) => {
if (value) setFocus("patches")
return !value
})
const next = !fileTreeEnabled()
if (!next) setFocus("patches")
setFileTreeEnabled(next)
props.api.kv.set(KV_SHOW_FILE_TREE, next)
},
},
{
@@ -437,16 +551,29 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle single patch view",
category: "VCS",
run() {
setSinglePatch((value) => {
const next = !value
if (next) ensureHighlightedPatchFile()
else scrollToHighlightedPatchFile()
return next
})
if (!singlePatch()) {
ensureHighlightedPatchFile()
setSinglePatch(true)
props.api.kv.set(KV_SINGLE_PATCH, true)
scrollSinglePatchToTop()
return
}
const fileIndex =
visiblePatchFiles()[0]?.fileIndex ??
singlePatchFileIndex(
selectedFileIndex(),
activePatchFileIndex(),
currentPatchFileIndex(),
firstPatchFileIndex(),
)
if (fileIndex !== undefined) selectPatchFile(fileIndex)
setSinglePatch(false)
props.api.kv.set(KV_SINGLE_PATCH, false)
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
},
},
{
name: "diff.switch_diff",
name: "diff.switch_source",
title: "Switch diff viewer source",
category: "VCS",
run() {
@@ -459,7 +586,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS",
run() {
if (!splitAvailable()) return
setViewOverride(view() === "split" ? "unified" : "split")
const next = view() === "split" ? "unified" : "split"
setViewOverride(next)
props.api.kv.set(KV_VIEW, next)
},
},
{
name: "diff.help",
title: "Show more diff viewer shortcuts",
category: "VCS",
run() {
openHelpDialog()
},
},
]
@@ -480,7 +617,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const openSwitchDiffDialog = () => {
props.api.ui.dialog.replace(() => (
<DialogSelect
title="Switch diff"
title="Switch source"
skipFilter={true}
renderFilter={false}
current={mode()}
@@ -492,6 +629,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
mode: option.value,
sessionID: params()?.sessionID,
messageID: params()?.messageID,
returnRoute: params()?.returnRoute,
})
},
}))}
@@ -499,6 +637,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
))
}
const openHelpDialog = () => {
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
props.api.ui.dialog.setSize("large")
}
useBindings(() => ({
commands,
bindings: [
@@ -529,10 +672,23 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<box flexGrow={1} minHeight={0}>
<Switch>
<Match when={diff.loading}>
<box flexGrow={1} alignItems="center" justifyContent="center">
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>Loading diff...</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No diff!</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().error}>Failed to load diff</text>
</box>
</Match>
<Match when={!diff.loading}>
<PanelGroup axis="x">
<Show when={showFileTree()}>
@@ -547,93 +703,83 @@ function DiffViewer(props: { api: TuiPluginApi }) {
selectedFileIndex={selectedFileIndex()}
reviewedFileNames={reviewedFileNames()}
expandedNodes={expandedFileNodes()}
onRowClick={clickFileTreeRow}
/>
</Show>
<Panel flexGrow={1} minHeight={0} border="none">
<Separator axis="x" start="edge-out" />
<Switch>
<Match when={diff.error}>
<box paddingTop={1}>
<text fg={theme().error}>Failed to load diff</text>
</box>
</Match>
<Match when={files().length === 0}>
<box paddingTop={1}>
<text fg={theme().textMuted}>No diff to show</text>
</box>
</Match>
<Match when={files().length > 0}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<For each={visiblePatchFiles()}>
{(entry, index) => {
const reviewed = () => reviewedFileNames().has(entry.file.file)
return (
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
{index() !== 0 ? <Separator axis="x" start="edge" /> : null}
<box
flexDirection="row"
gap={1}
flexShrink={0}
paddingLeft={2}
paddingRight={1}
border={["left"]}
borderColor={theme().border}
>
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
-{entry.file.deletions}
</text>
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<For each={visiblePatchFiles()}>
{(entry, index) => {
const reviewed = () => reviewedFileNames().has(entry.file.file)
return (
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
{index() !== 0 ? <Separator axis="x" start={showFileTree() ? "edge" : undefined} /> : null}
<box
flexDirection="row"
gap={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
border={patchLeftBorder()}
borderColor={theme().border}
>
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={theme().border}>
<diff
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? theme().textMuted : theme().text}
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
lineNumberFg={theme().diffLineNumber}
addedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
}
removedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
}
/>
</box>
<Separator axis="x" start="edge" />
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
>
{(patch) => (
<box border={["left"]} borderColor={theme().border}>
<diff
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? theme().textMuted : theme().text}
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
lineNumberFg={theme().diffLineNumber}
addedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
}
removedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
}
/>
</box>
)}
</Show>
</box>
)
}}
</For>
</scrollbox>
</Match>
</Switch>
<Separator axis="x" start="edge-in" />
)}
</Show>
</box>
)
}}
</For>
<Show when={patchFillerHeight() > 0}>
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} />
</Show>
</scrollbox>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
</Panel>
</PanelGroup>
</Match>
@@ -662,34 +808,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
</text>
)}
</Show>
<Show when={toggleFileTreeShortcut()}>
<Show when={switchSourceShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
</text>
)}
</Show>
<Show when={singlePatchShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
</text>
)}
</Show>
<Show when={switchDiffShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
</text>
)}
</Show>
<Show when={toggleViewShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
</text>
)}
</Show>
@@ -700,12 +822,108 @@ function DiffViewer(props: { api: TuiPluginApi }) {
</text>
)}
</Show>
<Show when={helpShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>all</span>
</text>
)}
</Show>
</Panel>
</PanelGroup>
</box>
)
}
function DiffViewerHelpDialog() {
const { theme } = useTheme()
const rows = [
{
shortcut: () => "q",
action: "Close viewer",
description: "Quit the diff viewer",
},
{
shortcut: useCommandShortcut("diff.switch_focus"),
action: "Focus file tree",
description: "Move keyboard focus between the file tree and patch pane",
},
{
shortcut: useCommandShortcut("diff.next_file"),
action: "Next file",
description: "Select the next changed file in file-tree order",
},
{
shortcut: useCommandShortcut("diff.previous_file"),
action: "Previous file",
description: "Select the previous changed file in file-tree order",
},
{
shortcut: useCommandShortcut("diff.toggle_file_tree"),
action: "Toggle file tree",
description: "Show or hide the file tree sidebar",
},
{
shortcut: useCommandShortcut("diff.single_patch"),
action: "Toggle patches",
description: "Switch between one selected patch and all patches",
},
{
shortcut: useCommandShortcut("diff.switch_source"),
action: "Switch source",
description: "Choose working tree or last-turn changes",
},
{
shortcut: useCommandShortcut("diff.toggle_view"),
action: "Toggle view",
description: "Switch between split and unified diff layout",
},
{
shortcut: useCommandShortcut("diff.expand_all"),
action: "Expand all folders",
description: "Open every folder in the file tree",
},
{
shortcut: useCommandShortcut("diff.mark_reviewed"),
action: "Mark reviewed",
description: "Toggle reviewed state for the selected file",
},
]
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Diff shortcuts
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box flexDirection="row">
<text fg={theme.textMuted} width={5} wrapMode="none">
Key
</text>
<text fg={theme.textMuted} width={22} wrapMode="none">
Action
</text>
<text fg={theme.textMuted}>Description</text>
</box>
<For each={rows}>
{(row) => (
<box flexDirection="row">
<text fg={theme.text} width={5} wrapMode="none">
{row.shortcut() || "-"}
</text>
<text fg={theme.text} width={22} wrapMode="none">
{row.action}
</text>
<text fg={theme.textMuted}>{row.description}</text>
</box>
)}
</For>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([
{
@@ -726,6 +944,7 @@ const tui: TuiPlugin = async (api) => {
api.route.navigate(ROUTE, {
mode: "git",
sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
returnRoute: api.route.current,
})
api.ui.dialog.clear()
},
@@ -20,9 +20,7 @@ export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function internalTuiPlugins(
flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">,
): InternalTuiPlugin[] {
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -35,7 +33,7 @@ export function internalTuiPlugins(
Notifications,
PluginManager,
WhichKey,
...(flags.diffViewer ? [DiffViewer] : []),
DiffViewer,
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
]
}
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
@@ -7,13 +7,16 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTuiConfig } from "../../context/tui-config"
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
import { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -119,8 +122,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
pick(opt.label)
}
onMount(() => {
const popMode = modeStack.push(QUESTION_MODE)
onCleanup(popMode)
})
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
mode: QUESTION_MODE,
enabled: store.editing && !confirm(),
commands: [
{
@@ -201,7 +209,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const max = Math.min(total, 9)
return {
mode: OPENCODE_BASE_MODE,
mode: QUESTION_MODE,
enabled: !store.editing,
commands: [
{
+12 -9
View File
@@ -1,5 +1,5 @@
import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
@@ -253,17 +253,20 @@ export const Info = Schema.Struct({
}),
),
tool_output: Schema.optional(
Schema.Struct({
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
Schema.Union([
Schema.Literal(false).annotate({ description: "Disable tool output truncation" }),
Schema.Struct({
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
]),
).annotate({
description:
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
"Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
}),
compaction: Schema.optional(
Schema.Struct({
@@ -1,5 +1,5 @@
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
@@ -15,7 +15,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
autoShare: bool("OPENCODE_AUTO_SHARE"),
pure: bool("OPENCODE_PURE"),
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
diffViewer: bool("OPENCODE_DIFF_VIEWER"),
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context, Effect, Layer } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
type State = Record<string, string | undefined>
+1 -1
View File
@@ -9,7 +9,7 @@ import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
import "@opencode-ai/core/session/event"
import "@opencode-ai/core/session-event"
import { Context, Effect, Layer, Option } from "effect"
export function toSyncDefinition<D extends EventV2.Definition>(definition: D) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Context, Schema } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state"
+34 -22
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error"
@@ -67,7 +67,11 @@ export function isLocal() {
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
stderr: Schema.String,
}) {}
}) {
override get message() {
return this.stderr
}
}
// Response schemas for external version APIs
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
@@ -139,23 +143,32 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const result = yield* appProcess.run(
ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
}),
)
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
}, Effect.orDie)
const upgradeFailure = (method: Method, result?: { code: number; stdout: string; stderr: string }) => {
if (method === "choco") return "not running from an elevated command shell"
if (result) return `Upgrade failed for ${method} (exit code ${result.code}).`
return `Upgrade failed for ${method}.`
}
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const result = yield* appProcess.run(
ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
}),
)
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
},
Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })),
)
const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
@@ -299,11 +312,10 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` })
}
if (!upgradeResult || upgradeResult.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) })
}
log.info("upgraded", {
method: m,
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
@@ -1,5 +1,5 @@
import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
+95 -119
View File
@@ -2,7 +2,8 @@ import { and } from "drizzle-orm"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { PermissionTable, SessionTable } from "../session/session.sql"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { BusEvent } from "@/bus/bus-event"
@@ -12,13 +13,14 @@ import { ProjectID } from "./schema"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "project" })
@@ -86,6 +88,10 @@ export function fromRow(row: Row): Info {
}
}
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
}
export const UpdateInput = Schema.Struct({
projectID: ProjectID,
name: Schema.optional(Schema.String),
@@ -101,6 +107,10 @@ export const UpdatePayload = Schema.Struct({
}).annotate({ identifier: "ProjectUpdateInput" })
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
projectID: ProjectID,
}) {}
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
@@ -116,7 +126,7 @@ export interface Interface {
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
@@ -128,16 +138,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service | RuntimeFlags.Service
> = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const proc = yield* AppProcess.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
const bus = yield* Bus.Service
const flags = yield* RuntimeFlags.Service
@@ -171,115 +178,74 @@ export const layer: Layer.Layer<
const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = AppFileSystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
const scope = yield* Scope.Scope
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map((x) => ProjectID.make(x)),
Effect.catch(() => Effect.void),
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (
oldID: ProjectID | undefined,
newID: ProjectID,
) {
if (!oldID) return
if (oldID === ProjectID.global) return
if (oldID === newID) return
yield* Effect.sync(() =>
Database.transaction(
(d) => {
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
if (oldProject && !newProject) {
d.insert(ProjectTable)
.values({
...oldProject,
id: newID,
time_updated: Date.now(),
})
.run()
}
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
if (oldPermission && newPermission) {
d.update(PermissionTable)
.set({
data: mergePermissionRules(oldPermission.data, newPermission.data),
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
time_updated: Date.now(),
})
.where(eq(PermissionTable.project_id, newID))
.run()
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
}
if (oldPermission && !newPermission) {
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
}
d.update(SessionTable).set({ project_id: newID }).where(eq(SessionTable.project_id, oldID)).run()
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
},
{ behavior: "immediate" },
),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: fakeVcs,
}
}
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = yield* Effect.sync(() => which("git"))
let id = yield* readCachedProjectId(dotgit)
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const common = resolveGitPath(sandbox, commonDir.text.trim())
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
if (id == null) {
id = yield* readCachedProjectId(common)
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fs.writeFileString(pathSvc.join(common, "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
})
const data = yield* projectV2.resolve(AbsolutePath.make(directory))
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const projectID = ProjectID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
id: projectID,
worktree,
vcs: data.vcs?.type ?? fakeVcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
@@ -288,12 +254,16 @@ export const layer: Layer.Layer<
const result: Info = {
...existing,
worktree: data.worktree,
vcs: data.vcs,
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
vcs: data.vcs?.type ?? fakeVcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
if (
projectID !== ProjectID.global &&
data.directory !== result.worktree &&
!result.sandboxes.includes(data.directory)
)
result.sandboxes.push(data.directory)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
@@ -339,18 +309,21 @@ export const layer: Layer.Layer<
.run(),
)
if (data.id !== ProjectID.global) {
if (projectID !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.set({ project_id: projectID })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
.run(),
)
}
yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
}
return { project: result, sandbox: data.vcs ? data.directory : worktree }
})
const discover = Effect.fn("Project.discover")(function* (input: Info) {
@@ -372,7 +345,9 @@ export const layer: Layer.Layer<
const base64 = Buffer.from(buffer).toString("base64")
const mime = AppFileSystem.mimeType(shortest)
const url = `data:${mime};base64,${base64}`
yield* update({ projectID: input.id, icon: { url } })
yield* update({ projectID: input.id, icon: { url } }).pipe(
Effect.catchTag("Project.NotFoundError", () => Effect.void),
)
})
const list = Effect.fn("Project.list")(function* () {
@@ -400,7 +375,7 @@ export const layer: Layer.Layer<
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
const data = fromRow(result)
yield* emitUpdated(data)
return data
@@ -504,9 +479,10 @@ export const layer: Layer.Layer<
export const defaultLayer = layer.pipe(
Layer.provide(Bus.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
+44 -15
View File
@@ -11,6 +11,9 @@ const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
const MAX_PATCH_BYTES = 10_000_000
const MAX_TOTAL_PATCH_BYTES = 10_000_000
type DiffOptions = {
readonly context?: number
}
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
@@ -91,11 +94,17 @@ const splitGitPatch = (patch: Git.Patch) => {
return chunks.slice(0, -1)
}
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
const batchPatches = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string,
list: Git.Item[],
options?: DiffOptions,
) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
const result = yield* git.patchAll(cwd, ref, {
context: PATCH_CONTEXT_LINES,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
@@ -116,11 +125,18 @@ const nativePatch = Effect.fnUntraced(function* (
cwd: string,
ref: string | undefined,
item: Git.Item,
options?: DiffOptions,
) {
const result =
item.code === "??" || !ref
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
? yield* git.patchUntracked(cwd, item.file, {
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
: yield* git.patch(cwd, ref, item.file, {
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
@@ -140,13 +156,14 @@ const patchForItem = Effect.fnUntraced(function* (
item: Git.Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
options?: DiffOptions,
) {
if (capped) return emptyPatch(item.file)
const batched = batch.patches.get(item.file)
if (batched !== undefined) return batched
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
return yield* nativePatch(git, cwd, ref, item)
return yield* nativePatch(git, cwd, ref, item, options)
})
const files = Effect.fnUntraced(function* (
@@ -156,6 +173,7 @@ const files = Effect.fnUntraced(function* (
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
options?: DiffOptions,
) {
const next: FileDiff[] = []
let total = 0
@@ -163,7 +181,7 @@ const files = Effect.fnUntraced(function* (
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped, options)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
: totalPatch(item.file, patch, total)
@@ -184,7 +202,12 @@ const files = Effect.fnUntraced(function* (
return next
})
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
const diffAgainstRef = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string,
options?: DiffOptions,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
@@ -197,13 +220,19 @@ const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: str
extra.filter((item) => item.code === "??"),
),
nums(stats),
yield* batchPatches(git, cwd, ref, list),
yield* batchPatches(git, cwd, ref, list, options),
options,
)
})
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
return yield* diffAgainstRef(git, cwd, ref)
const track = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
options?: DiffOptions,
) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch(), options)
return yield* diffAgainstRef(git, cwd, ref, options)
})
export const Mode = Schema.Literals(["git", "branch"])
@@ -264,7 +293,7 @@ export interface Interface {
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
}
@@ -352,19 +381,19 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
}),
)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
const value = yield* InstanceState.get(state)
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref)
return yield* diffAgainstRef(git, ctx.directory, ref, options)
}),
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
const ctx = yield* InstanceState.context
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
+20 -5
View File
@@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models-dev"
import { Auth } from "../auth"
@@ -994,7 +994,22 @@ export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderIni
}
}
export type Error = ModelNotFoundError | InitError
export class NoProvidersError extends Schema.TaggedErrorClass<NoProvidersError>()("ProviderNoProvidersError", {}) {
static isInstance(input: unknown): input is NoProvidersError {
return input instanceof NoProvidersError
}
}
export class NoModelsError extends Schema.TaggedErrorClass<NoModelsError>()("ProviderNoModelsError", {
providerID: ProviderID,
}) {
static isInstance(input: unknown): input is NoModelsError {
return input instanceof NoModelsError
}
}
export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModelsError
export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
@@ -1006,7 +1021,7 @@ export interface Interface {
query: string[],
) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }, DefaultModelError>
}
interface State {
@@ -1821,9 +1836,9 @@ export const layer = Layer.effect(
}
const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) throw new Error("no providers found")
if (!provider) return yield* new NoProvidersError()
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
if (!model) return yield* new NoModelsError({ providerID: provider.id })
return {
providerID: provider.id,
modelID: model.id,
+12 -4
View File
@@ -17,6 +17,11 @@ function mimeToModality(mime: string): Modality | undefined {
export const OUTPUT_TOKEN_MAX = 32_000
// OpenAI Responses `include` value that returns the encrypted reasoning state
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
// branch that requests it stays in lockstep.
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}
@@ -756,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -790,7 +795,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -803,7 +808,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -1134,6 +1139,9 @@ export function options(input: {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
if (input.model.api.npm === "@ai-sdk/openai") {
result["include"] = INCLUDE_ENCRYPTED_REASONING
}
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -1149,7 +1157,7 @@ export function options(input: {
if (input.model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = input.sessionID
result["include"] = ["reasoning.encrypted_content"]
result["include"] = INCLUDE_ENCRYPTED_REASONING
result["reasoningSummary"] = "auto"
}
}
+33 -25
View File
@@ -87,6 +87,10 @@ export const UpdateInput = Schema.Struct({
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
export const Event = {
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
@@ -96,17 +100,20 @@ export const Event = {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
readonly remove: (id: PtyID) => Effect.Effect<void>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
@@ -150,10 +157,15 @@ export const layer = Layer.effect(
}),
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = (yield* InstanceState.get(state)).sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
const session = yield* requireSession(id)
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
@@ -166,8 +178,7 @@ export const layer = Layer.effect(
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
return s.sessions.get(id)?.info
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
@@ -262,9 +273,7 @@ export const layer = Layer.effect(
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
const session = yield* requireSession(id)
if (input.title) {
session.info.title = input.title
}
@@ -276,28 +285,27 @@ export const layer = Layer.effect(
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
const session = yield* requireSession(id)
if (session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
const session = yield* requireSession(id)
if (session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) {
ws.close()
return
}
const session = yield* requireSession(id).pipe(
Effect.tapError(() =>
Effect.sync(() => {
ws.close()
}),
),
)
log.info("client connected to session", { id })
const sub = sock(ws)

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