Merge remote-tracking branch 'origin/dev' into refactor/core-database-schema-ownership
# Conflicts: # packages/core/src/project.ts # packages/opencode/src/project/project.ts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -242,7 +242,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:",
|
||||
},
|
||||
@@ -258,7 +258,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:",
|
||||
@@ -313,7 +313,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:",
|
||||
@@ -327,7 +327,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -357,7 +357,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:",
|
||||
@@ -373,7 +373,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -386,7 +386,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",
|
||||
@@ -404,7 +404,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -413,15 +413,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",
|
||||
@@ -498,7 +498,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",
|
||||
@@ -542,7 +542,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -580,7 +580,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -595,7 +595,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",
|
||||
@@ -630,7 +630,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -679,7 +679,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",
|
||||
@@ -814,7 +814,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=="],
|
||||
|
||||
@@ -836,9 +836,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=="],
|
||||
|
||||
@@ -4916,7 +4916,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=="],
|
||||
|
||||
@@ -5094,9 +5094,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=="],
|
||||
|
||||
@@ -5112,11 +5116,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=="],
|
||||
|
||||
@@ -5960,7 +5970,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=="],
|
||||
|
||||
@@ -6006,6 +6020,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=="],
|
||||
@@ -6016,6 +6036,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=="],
|
||||
@@ -6662,6 +6690,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
@@ -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
@@ -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-szOnLhAI4d3O4NaQvCRBufklmtRU8AR8p5LyFxjlTXo=",
|
||||
"aarch64-linux": "sha256-mfyL4Pswl6HeObPg+TMK1r7RjvLCMgDWYrFnfHkXGj4=",
|
||||
"aarch64-darwin": "sha256-XMK5IFCIxlSwH21Nqya8ZkAQ4Skp6GghukIl7NMp+so=",
|
||||
"x86_64-darwin": "sha256-W7nLMO6qhR32442yMebEJJgaZRNpqTbZ8BJhdqMK3S4="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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,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,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,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -30,15 +30,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",
|
||||
@@ -73,7 +73,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:"
|
||||
},
|
||||
|
||||
+6
-1
@@ -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
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 +1,129 @@
|
||||
export * from "./project/index"
|
||||
export * as Project from "./project/index"
|
||||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath, withStatics } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Git } from "./git"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
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 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/ProjectV2") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
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 = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.find(input)
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
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))
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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) => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
Vendored
+43
File diff suppressed because one or more lines are too long
+42
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+3
-3
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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
@@ -72,15 +72,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",
|
||||
@@ -157,7 +157,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,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,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"
|
||||
|
||||
@@ -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
|
||||
>
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+41
@@ -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
|
||||
}
|
||||
|
||||
+13
-19
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
-1
@@ -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,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,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,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"
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { and } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
repositoryCachePath,
|
||||
sameRepositoryReference,
|
||||
parseRepositoryReference,
|
||||
parseRemoteRepositoryReference,
|
||||
validateRepositoryBranch,
|
||||
isRemoteRepositoryReference,
|
||||
InvalidRepositoryBranchError,
|
||||
InvalidRepositoryReferenceError,
|
||||
UnsupportedLocalRepositoryError,
|
||||
type RemoteReference,
|
||||
} from "@/util/repository"
|
||||
|
||||
@@ -138,23 +141,26 @@ export function isError(error: unknown): error is Error {
|
||||
}
|
||||
|
||||
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
|
||||
const reference = parseRepositoryReference(repository)
|
||||
if (!reference) {
|
||||
try {
|
||||
return parseRemoteRepositoryReference(repository)
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) {
|
||||
return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message })
|
||||
}
|
||||
return yield* new InvalidRepositoryError({
|
||||
repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
message: errorMessage(error),
|
||||
})
|
||||
}
|
||||
if (!isRemoteRepositoryReference(reference)) {
|
||||
return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" })
|
||||
}
|
||||
return reference
|
||||
})
|
||||
|
||||
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||
try {
|
||||
validateRepositoryBranch(branch)
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRepositoryBranchError) {
|
||||
return yield* new InvalidBranchError({ branch: error.branch, message: error.message })
|
||||
}
|
||||
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -149,6 +149,32 @@ export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNot
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>()(
|
||||
"PtyNotFoundError",
|
||||
{
|
||||
ptyID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class PtyForbiddenError extends Schema.TaggedErrorClass<PtyForbiddenError>()(
|
||||
"PtyForbiddenError",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 403 },
|
||||
) {}
|
||||
|
||||
export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
|
||||
"ProjectNotFoundError",
|
||||
{
|
||||
projectID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
|
||||
{
|
||||
name: Schema.Literal("NotFoundError"),
|
||||
|
||||
@@ -26,6 +26,7 @@ const PathInfo = Schema.Struct({
|
||||
export const VcsDiffQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
mode: Vcs.Mode,
|
||||
context: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
||||
})
|
||||
|
||||
export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Project } from "@/project/project"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ProjectNotFoundError } from "../errors"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
@@ -53,7 +54,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: UpdatePayload,
|
||||
success: described(Project.Info, "Updated project information"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
error: [HttpApiError.BadRequest, ProjectNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.update",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { PtyForbiddenError, PtyNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/pty"
|
||||
@@ -76,7 +76,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Pty.Info, "Session info"),
|
||||
error: ApiNotFoundError,
|
||||
error: PtyNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.get",
|
||||
@@ -89,7 +89,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Pty.UpdateInput,
|
||||
success: described(Pty.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
error: [PtyNotFoundError, HttpApiError.BadRequest],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.update",
|
||||
@@ -101,7 +101,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Session removed"),
|
||||
error: ApiNotFoundError,
|
||||
error: PtyNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.remove",
|
||||
@@ -113,7 +113,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
||||
error: [HttpApiError.Forbidden, ApiNotFoundError],
|
||||
error: [PtyForbiddenError, PtyNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connectToken",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ApiVcsApplyError } from "./instance"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
@@ -107,7 +108,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: WarpPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
|
||||
error: [ApiWorkspaceWarpError, ApiVcsApplyError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.warp",
|
||||
|
||||
@@ -48,8 +48,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
|
||||
return yield* vcs.status()
|
||||
})
|
||||
|
||||
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
|
||||
return yield* vcs.diff(ctx.query.mode)
|
||||
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: {
|
||||
query: { mode: Vcs.Mode; context?: number }
|
||||
}) {
|
||||
return yield* vcs.diff(ctx.query.mode, { context: ctx.query.context })
|
||||
})
|
||||
|
||||
const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProjectID } from "@/project/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { ProjectNotFoundError } from "../errors"
|
||||
import { markInstanceForReload } from "../lifecycle"
|
||||
|
||||
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
|
||||
@@ -35,7 +36,16 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
|
||||
params: { projectID: ProjectID }
|
||||
payload: Project.UpdatePayload
|
||||
}) {
|
||||
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
|
||||
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
|
||||
Effect.catchTag("Project.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ProjectNotFoundError({
|
||||
projectID: error.projectID,
|
||||
message: `Project not found: ${error.projectID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import * as ApiError from "../errors"
|
||||
@@ -46,33 +46,67 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
})
|
||||
|
||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const info = yield* pty.get(ctx.params.ptyID)
|
||||
if (!info) return yield* ApiError.notFound("Session not found")
|
||||
return info
|
||||
return yield* pty.get(ctx.params.ptyID).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
const info = yield* pty.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
if (!info) return yield* ApiError.notFound("Session not found")
|
||||
return info
|
||||
return yield* pty
|
||||
.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
yield* pty.remove(ctx.params.ptyID)
|
||||
yield* pty.remove(ctx.params.ptyID).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
|
||||
return yield* new HttpApiError.Forbidden({})
|
||||
if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found")
|
||||
return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
|
||||
yield* pty.get(ctx.params.ptyID).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
})
|
||||
|
||||
@@ -97,7 +131,11 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||
const exists = yield* pty.get(params.ptyID).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
@@ -147,11 +185,14 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
writeScoped(write(new Socket.CloseEvent(code, reason)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||
if (!handler) {
|
||||
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
const handler = yield* pty
|
||||
.connect(params.ptyID, adapter, cursor)
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// No `pending[]`-style early-frame buffer (the legacy handler had one).
|
||||
// `request.upgrade` returns a Socket without running the WS handshake; the
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Vcs } from "@/project/vcs"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { notFound } from "../errors"
|
||||
import { ApiVcsApplyError } from "../groups/instance"
|
||||
import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace"
|
||||
|
||||
@@ -54,6 +55,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof Workspace.WorkspaceNotFoundError) return notFound(error.message)
|
||||
if (error instanceof Vcs.PatchApplyError) {
|
||||
return new ApiVcsApplyError({
|
||||
name: "VcsApplyError",
|
||||
|
||||
@@ -66,6 +66,7 @@ const QueryParameterSchemas: Record<string, OpenApiSchema> = {
|
||||
"GET /session roots": QueryBooleanOpenApi,
|
||||
"GET /session limit": { type: "number" },
|
||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
"GET /vcs/diff context": { type: "integer", minimum: 0 },
|
||||
"GET /api/session limit": { type: "number" },
|
||||
"GET /api/session start": { type: "number" },
|
||||
"GET /api/session roots": QueryBooleanOpenApi,
|
||||
@@ -371,7 +372,6 @@ function referencesComponent(input: unknown, name: string): boolean {
|
||||
|
||||
function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
|
||||
if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
|
||||
if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]
|
||||
if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return
|
||||
const response = operation.responses?.["200"]?.content?.["application/json"]
|
||||
if (!response) return
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
|
||||
@@ -70,6 +70,14 @@ export function stream(input: StreamInput): StreamResult {
|
||||
|
||||
// Integration point with @opencode-ai/llm: native-request lowers session data
|
||||
// into an LLMRequest, then LLMClient handles route selection and transport.
|
||||
//
|
||||
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
|
||||
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
|
||||
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
|
||||
// include, textVerbosity, promptCacheKey). Both sides intentionally use
|
||||
// OpenAI's official wire field names, so this is identity, not translation
|
||||
// — if a field ever needs to differ between the two surfaces, the
|
||||
// translation belongs here, not split across both packages.
|
||||
const stream = input.llmClient.stream({
|
||||
request: LLMNative.request({
|
||||
model: input.model,
|
||||
|
||||
@@ -682,7 +682,7 @@ export const layer = Layer.effect(
|
||||
.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
|
||||
.pipe(Effect.orDie)
|
||||
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
|
||||
return yield* provider.defaultModel()
|
||||
return yield* provider.defaultModel().pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import path from "path"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Account } from "@/account/account"
|
||||
|
||||
@@ -57,17 +57,26 @@ function isSkillFrontmatter(data: unknown): data is { name: string; description?
|
||||
)
|
||||
}
|
||||
|
||||
export const InvalidError = NamedError.create("SkillInvalidError", {
|
||||
export class InvalidError extends Schema.TaggedErrorClass<InvalidError>()("SkillInvalidError", {
|
||||
path: Schema.String,
|
||||
message: Schema.optional(Schema.String),
|
||||
issues: Schema.optional(Schema.Array(Issue)),
|
||||
})
|
||||
}) {}
|
||||
|
||||
export const NameMismatchError = NamedError.create("SkillNameMismatchError", {
|
||||
export class NameMismatchError extends Schema.TaggedErrorClass<NameMismatchError>()("SkillNameMismatchError", {
|
||||
path: Schema.String,
|
||||
expected: Schema.String,
|
||||
actual: Schema.String,
|
||||
})
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Skill.NotFoundError", {
|
||||
name: Schema.String,
|
||||
available: Schema.Array(Schema.String),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Skill "${this.name}" not found. Available skills: ${this.available.join(", ") || "none"}`
|
||||
}
|
||||
}
|
||||
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
@@ -86,6 +95,7 @@ type ScanState = {
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly require: (name: string) => Effect.Effect<Info, NotFoundError>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
@@ -277,6 +287,13 @@ export const layer = Layer.effect(
|
||||
return s.skills[name]
|
||||
})
|
||||
|
||||
const require = Effect.fn("Skill.require")(function* (name: string) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const info = s.skills[name]
|
||||
if (info) return info
|
||||
return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() })
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Object.values(s.skills)
|
||||
@@ -293,7 +310,7 @@ export const layer = Layer.effect(
|
||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
return Service.of({ get, require, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user