Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a480abc15 | ||
|
|
5ee0238c4a | ||
|
|
871d38cbd7 | ||
|
|
1a329e4e67 | ||
|
|
bfb2d8dc76 | ||
|
|
8f7a6c4a00 | ||
|
|
1857c73565 | ||
|
|
5f42351159 | ||
|
|
968aaa3cfe | ||
|
|
b8266e5819 | ||
|
|
ba746e36d8 | ||
|
|
d5068ba28e | ||
|
|
7265c46af6 | ||
|
|
536ee857c6 | ||
|
|
dda69d77e8 | ||
|
|
0e14404e5f | ||
|
|
05f51bfe45 | ||
|
|
aee552c043 | ||
|
|
4f6eaf859b | ||
|
|
d0cb58782f | ||
|
|
a3430db73a | ||
|
|
3e1972fd92 | ||
|
|
b368e5adbe | ||
|
|
69e4f52272 | ||
|
|
8a55920531 | ||
|
|
9db90a0b76 | ||
|
|
700d012025 | ||
|
|
59e486a917 | ||
|
|
8596967415 | ||
|
|
00038027c8 | ||
|
|
5cf597d583 | ||
|
|
d92b8d8009 | ||
|
|
854c535536 | ||
|
|
3cf955e9ad | ||
|
|
63f3e84792 | ||
|
|
0beb4de3e8 | ||
|
|
51da3483a9 | ||
|
|
060fbc9ce7 | ||
|
|
7a769dab3a | ||
|
|
2663ecd39d | ||
|
|
4ce247eaba |
@@ -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.9",
|
||||
"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.9",
|
||||
"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.9",
|
||||
"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.9",
|
||||
"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.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -309,7 +309,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -323,7 +323,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -369,7 +369,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -400,7 +400,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -538,7 +538,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -576,7 +576,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -591,7 +591,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -626,7 +626,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -675,7 +675,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+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-pbVW7cOLT76Q7f++xaYYrwuN7eS6FRen80xoaVog3M4=",
|
||||
"aarch64-linux": "sha256-nk/q4PezhQSf/UHhJuL/188q6e+Gr+PFgrlhEEG5ouo=",
|
||||
"aarch64-darwin": "sha256-IfIJwgvsonrfjG+btK/5YedzX+MJtf/5bYuLrAmnHvU=",
|
||||
"x86_64-darwin": "sha256-Cwd9gEymjFn6XAgkkyX4ZmRTAE1JiurxF4VvVws9C/E="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -524,7 +524,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
const code = createMemo(() => {
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":")[1]?.trim()
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
@@ -1387,7 +1387,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return getFilename(value)
|
||||
}
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const USE_V2_INPUT =
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
|
||||
return (
|
||||
<div class="relative size-full flex flex-col gap-0">
|
||||
|
||||
@@ -51,7 +51,8 @@ const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 44
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const USE_V2_TITLEBAR =
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
|
||||
+59
-182
@@ -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,20 +18,19 @@ 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"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
|
||||
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const USE_HOME_DESIGN =
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
const HOME_SESSION_LIMIT = 15
|
||||
const HOME_ROW =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
@@ -468,10 +467,6 @@ function LegacyHome() {
|
||||
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 +475,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 +513,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>
|
||||
|
||||
@@ -89,7 +89,8 @@ import {
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const USE_NEW_DESIGN =
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
|
||||
@@ -74,7 +74,8 @@ const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const USE_NEW_SESSION_DESIGN =
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
@@ -263,7 +264,7 @@ 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 = () => !USE_NEW_SESSION_DESIGN || !params.id
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
|
||||
@@ -28,6 +28,9 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_NEW_SESSION_DESIGN =
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
@@ -207,7 +210,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
|
||||
<Show when={isDesktop() && !(USE_NEW_SESSION_DESIGN && !params.id)}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { fileURLToPath } from "url"
|
||||
|
||||
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||
|
||||
const channel = (() => {
|
||||
const channel = (command) => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
|
||||
return "dev"
|
||||
})()
|
||||
if (command === "serve") return "dev"
|
||||
return "prod"
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("vite").PluginOption}
|
||||
@@ -18,7 +19,7 @@ const channel = (() => {
|
||||
export default [
|
||||
{
|
||||
name: "opencode-desktop:config",
|
||||
config() {
|
||||
config(_, env) {
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -26,7 +27,7 @@ export default [
|
||||
},
|
||||
},
|
||||
define: {
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel(env.command)),
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"$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.9",
|
||||
"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.9",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"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.9",
|
||||
"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.9"
|
||||
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.9/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.9/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.9/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.9/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.9/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"$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.9",
|
||||
"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.9",
|
||||
"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,31 @@ 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)
|
||||
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: Cache.Breakpoints,
|
||||
@@ -360,7 +400,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 +707,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,6 +57,16 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||
encrypted_content: optionalNull(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) }),
|
||||
@@ -69,7 +81,7 @@ 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>
|
||||
@@ -166,6 +178,17 @@ 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),
|
||||
@@ -178,12 +201,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>
|
||||
|
||||
@@ -250,6 +275,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) }]
|
||||
@@ -298,7 +344,11 @@ 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,14 +646,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) => {
|
||||
|
||||
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
@@ -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>(
|
||||
@@ -786,7 +877,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 +895,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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface Interface {
|
||||
whenToUse: string
|
||||
systemPrompt: string
|
||||
},
|
||||
Provider.ModelNotFoundError
|
||||
Provider.DefaultModelError
|
||||
>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { ProjectProvider } from "@tui/context/project"
|
||||
import { ProjectProvider, useProject } from "@tui/context/project"
|
||||
import { EditorContextProvider } from "@tui/context/editor"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
@@ -279,6 +279,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const routes: RouteMap = new Map()
|
||||
@@ -447,6 +448,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
)
|
||||
|
||||
const connected = useConnected()
|
||||
const currentWorktreeWorkspace = createMemo(() => {
|
||||
const workspaceID = project.workspace.current()
|
||||
if (!workspaceID) return
|
||||
const workspace = project.workspace.get(workspaceID)
|
||||
if (workspace?.type !== "worktree" || !workspace.directory) return
|
||||
return workspace
|
||||
})
|
||||
const appCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
@@ -483,6 +491,20 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspace.copy_path",
|
||||
title: "Copy worktree path",
|
||||
category: "Workspace",
|
||||
enabled: () => currentWorktreeWorkspace() !== undefined,
|
||||
run: async () => {
|
||||
const workspace = currentWorktreeWorkspace()
|
||||
if (!workspace?.directory) return
|
||||
await Clipboard.copy(workspace.directory)
|
||||
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -20,9 +20,7 @@ export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function internalTuiPlugins(
|
||||
flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">,
|
||||
): InternalTuiPlugin[] {
|
||||
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
|
||||
return [
|
||||
HomeFooter,
|
||||
HomeTips,
|
||||
@@ -35,7 +33,7 @@ export function internalTuiPlugins(
|
||||
Notifications,
|
||||
PluginManager,
|
||||
WhichKey,
|
||||
...(flags.diffViewer ? [DiffViewer] : []),
|
||||
DiffViewer,
|
||||
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
@@ -7,13 +7,16 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
|
||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
|
||||
const QUESTION_MODE = "question"
|
||||
|
||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
@@ -119,8 +122,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const popMode = modeStack.push(QUESTION_MODE)
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
mode: QUESTION_MODE,
|
||||
enabled: store.editing && !confirm(),
|
||||
commands: [
|
||||
{
|
||||
@@ -201,7 +209,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const max = Math.min(total, 9)
|
||||
|
||||
return {
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
mode: QUESTION_MODE,
|
||||
enabled: !store.editing,
|
||||
commands: [
|
||||
{
|
||||
|
||||
@@ -15,7 +15,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
autoShare: bool("OPENCODE_AUTO_SHARE"),
|
||||
pure: bool("OPENCODE_PURE"),
|
||||
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
diffViewer: bool("OPENCODE_DIFF_VIEWER"),
|
||||
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
|
||||
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,6 +67,10 @@ export const Failed = NamedError.create("MCPFailed", {
|
||||
name: Schema.String,
|
||||
})
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
type MCPClient = Client
|
||||
|
||||
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
|
||||
@@ -242,8 +246,8 @@ export interface Interface {
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
||||
readonly connect: (name: string) => Effect.Effect<void>
|
||||
readonly disconnect: (name: string) => Effect.Effect<void>
|
||||
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly getPrompt: (
|
||||
clientName: string,
|
||||
name: string,
|
||||
@@ -253,11 +257,13 @@ export interface Interface {
|
||||
clientName: string,
|
||||
resourceUri: string,
|
||||
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
|
||||
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
|
||||
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
|
||||
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
|
||||
readonly startAuth: (
|
||||
mcpName: string,
|
||||
) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError>
|
||||
readonly authenticate: (mcpName: string) => Effect.Effect<Status, NotFoundError>
|
||||
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
|
||||
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
|
||||
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
|
||||
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
|
||||
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
|
||||
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
|
||||
}
|
||||
@@ -642,15 +648,12 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const connect = Effect.fn("MCP.connect")(function* (name: string) {
|
||||
const mcp = yield* getMcpConfig(name)
|
||||
if (!mcp) {
|
||||
log.error("MCP config not found or invalid", { name })
|
||||
return
|
||||
}
|
||||
const mcp = yield* requireMcpConfig(name)
|
||||
yield* createAndStore(name, { ...mcp, enabled: true })
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||
yield* requireMcpConfig(name)
|
||||
const s = yield* InstanceState.get(state)
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
@@ -759,9 +762,14 @@ export const layer = Layer.effect(
|
||||
return mcpConfig
|
||||
})
|
||||
|
||||
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
|
||||
const requireMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
|
||||
if (!mcpConfig) return yield* new NotFoundError({ name: mcpName })
|
||||
return mcpConfig
|
||||
})
|
||||
|
||||
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
|
||||
const mcpConfig = yield* requireMcpConfig(mcpName)
|
||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||
const url = remoteURL(mcpName, mcpConfig.url)
|
||||
@@ -773,9 +781,7 @@ export const layer = Layer.effect(
|
||||
// Resolve effective redirect URI: explicit redirectUri > callbackPort shorthand > default
|
||||
const effectiveRedirectUri =
|
||||
oauthConfig?.redirectUri ??
|
||||
(oauthConfig?.callbackPort
|
||||
? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}`
|
||||
: undefined)
|
||||
(oauthConfig?.callbackPort ? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}` : undefined)
|
||||
|
||||
// Start the callback server with custom redirectUri if configured
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(effectiveRedirectUri))
|
||||
@@ -827,11 +833,9 @@ export const layer = Layer.effect(
|
||||
const result = yield* startAuth(mcpName)
|
||||
if (!result.authorizationUrl) {
|
||||
const client = "client" in result ? result.client : undefined
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) {
|
||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||
return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||
}
|
||||
const mcpConfig = yield* requireMcpConfig(mcpName).pipe(
|
||||
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
|
||||
)
|
||||
|
||||
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
||||
if (!client || !listed) {
|
||||
@@ -882,6 +886,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||
yield* requireMcpConfig(mcpName)
|
||||
const transport = pendingOAuthTransports.get(mcpName)
|
||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
|
||||
@@ -900,8 +905,7 @@ export const layer = Layer.effect(
|
||||
yield* auth.clearCodeVerifier(mcpName)
|
||||
pendingOAuthTransports.delete(mcpName)
|
||||
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||
const mcpConfig = yield* requireMcpConfig(mcpName)
|
||||
|
||||
return yield* createAndStore(mcpName, mcpConfig)
|
||||
})
|
||||
@@ -914,8 +918,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) return false
|
||||
const mcpConfig = yield* requireMcpConfig(mcpName)
|
||||
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
|
||||
})
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("Permiss
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
|
||||
requestID: PermissionID,
|
||||
}) {}
|
||||
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const AskInput = Schema.Struct({
|
||||
@@ -117,7 +121,7 @@ export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
@@ -209,7 +213,7 @@ export const layer = Layer.effect(
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
|
||||
const { approved, pending } = yield* InstanceState.get(state)
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
|
||||
pending.delete(input.requestID)
|
||||
yield* bus.publish(Event.Replied, {
|
||||
|
||||
@@ -101,6 +101,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 +120,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[]>
|
||||
@@ -372,7 +376,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 +406,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -98,6 +98,10 @@ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Que
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
|
||||
requestID: QuestionID,
|
||||
}) {}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
@@ -115,8 +119,11 @@ export interface Interface {
|
||||
questions: ReadonlyArray<Info>
|
||||
tool?: Tool
|
||||
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly reply: (input: {
|
||||
requestID: QuestionID
|
||||
answers: ReadonlyArray<Answer>
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
@@ -180,7 +187,7 @@ export const layer = Layer.effect(
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
return yield* new NotFoundError({ requestID: input.requestID })
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
@@ -197,7 +204,7 @@ export const layer = Layer.effect(
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
return yield* new NotFoundError({ requestID })
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,6 +122,59 @@ export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>(
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
|
||||
"QuestionNotFoundError",
|
||||
{
|
||||
requestID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionNotFoundError>()(
|
||||
"PermissionNotFoundError",
|
||||
{
|
||||
requestID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
|
||||
"McpServerNotFoundError",
|
||||
{
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ 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 { MCP } from "@/mcp"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { McpServerNotFoundError } from "../errors"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
@@ -67,7 +68,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(AuthStartResponse, "OAuth flow started"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
error: [UnsupportedOAuthError, McpServerNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.start",
|
||||
@@ -80,7 +81,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: AuthCallbackPayload,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
error: [HttpApiError.BadRequest, McpServerNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.callback",
|
||||
@@ -93,7 +94,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
error: [UnsupportedOAuthError, McpServerNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.authenticate",
|
||||
@@ -105,7 +106,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||
error: HttpApiError.NotFound,
|
||||
error: McpServerNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.remove",
|
||||
@@ -117,6 +118,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||
error: McpServerNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.connect",
|
||||
@@ -127,6 +129,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||
error: McpServerNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.disconnect",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { PermissionNotFoundError } from "../errors"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
@@ -32,7 +33,7 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
error: [HttpApiError.BadRequest, PermissionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.reply",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Question } from "@/question"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { QuestionNotFoundError } from "../errors"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
@@ -33,7 +34,7 @@ export const QuestionApi = HttpApi.make("question")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Question answered successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
error: [HttpApiError.BadRequest, QuestionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
@@ -45,7 +46,7 @@ export const QuestionApi = HttpApi.make("question")
|
||||
params: { requestID: QuestionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Question rejected successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
error: [HttpApiError.BadRequest, QuestionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reject",
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError, SessionBusyError } from "../errors"
|
||||
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
|
||||
@@ -393,7 +393,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PermissionResponsePayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError, PermissionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.respond",
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MCP } from "@/mcp"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { McpServerNotFoundError } from "../errors"
|
||||
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
|
||||
|
||||
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
|
||||
@@ -20,38 +21,80 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
|
||||
})
|
||||
|
||||
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.startAuth(ctx.params.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.startAuth(ctx.params.name)
|
||||
}).pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
||||
params: { name: string }
|
||||
payload: typeof AuthCallbackPayload.Type
|
||||
}) {
|
||||
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
|
||||
return yield* mcp
|
||||
.finishAuth(ctx.params.name, ctx.payload.code)
|
||||
.pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.authenticate(ctx.params.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.authenticate(ctx.params.name)
|
||||
}).pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
||||
const status = yield* mcp.status()
|
||||
if (!(ctx.params.name in status))
|
||||
return yield* new McpServerNotFoundError({
|
||||
name: ctx.params.name,
|
||||
message: `MCP server not found: ${ctx.params.name}`,
|
||||
})
|
||||
yield* mcp.removeAuth(ctx.params.name)
|
||||
return { success: true as const }
|
||||
})
|
||||
|
||||
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.connect(ctx.params.name)
|
||||
yield* mcp
|
||||
.connect(ctx.params.name)
|
||||
.pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.disconnect(ctx.params.name)
|
||||
yield* mcp
|
||||
.disconnect(ctx.params.name)
|
||||
.pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PermissionID } from "@/permission/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { PermissionNotFoundError } from "../errors"
|
||||
|
||||
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -16,11 +17,22 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
|
||||
params: { requestID: PermissionID }
|
||||
payload: Permission.ReplyBody
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
reply: ctx.payload.reply,
|
||||
message: ctx.payload.message,
|
||||
})
|
||||
yield* svc
|
||||
.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
reply: ctx.payload.reply,
|
||||
message: ctx.payload.message,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Permission.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new PermissionNotFoundError({
|
||||
requestID: String(error.requestID),
|
||||
message: `Permission request not found: ${error.requestID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QuestionID } from "@/question/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { QuestionNotFoundError } from "../errors"
|
||||
|
||||
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -16,15 +17,35 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
|
||||
params: { requestID: QuestionID }
|
||||
payload: Question.Reply
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
yield* svc
|
||||
.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Question.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new QuestionNotFoundError({
|
||||
requestID: String(error.requestID),
|
||||
message: `Question request not found: ${error.requestID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
|
||||
yield* svc.reject(ctx.params.requestID)
|
||||
yield* svc.reject(ctx.params.requestID).pipe(
|
||||
Effect.catchTag("Question.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new QuestionNotFoundError({
|
||||
requestID: String(error.requestID),
|
||||
message: `Question request not found: ${error.requestID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
SummarizePayload,
|
||||
UpdatePayload,
|
||||
} from "../groups/session"
|
||||
import { PermissionNotFoundError } from "../errors"
|
||||
import * as SessionError from "./session-errors"
|
||||
|
||||
const tryParseJson = (text: string) =>
|
||||
@@ -356,7 +357,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
payload: typeof PermissionResponsePayload.Type
|
||||
}) {
|
||||
yield* requireSession(ctx.params.sessionID)
|
||||
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
|
||||
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }).pipe(
|
||||
Effect.catchTag("Permission.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new PermissionNotFoundError({
|
||||
requestID: String(error.requestID),
|
||||
message: `Permission request not found: ${error.requestID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -22,12 +22,9 @@ export const SkillTool = Tool.define(
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((item) => item.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
const info = yield* skill
|
||||
.require(params.name)
|
||||
.pipe(Effect.catchTag("Skill.NotFoundError", (error) => Effect.die(new Error(error.message))))
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "skill",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Schema } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
type BaseReference = {
|
||||
@@ -23,6 +24,43 @@ export type FileReference = BaseReference & {
|
||||
|
||||
export type Reference = RemoteReference | FileReference
|
||||
|
||||
export class InvalidRepositoryReferenceError extends Schema.TaggedErrorClass<InvalidRepositoryReferenceError>()(
|
||||
"RepositoryInvalidReferenceError",
|
||||
{
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class UnsupportedLocalRepositoryError extends Schema.TaggedErrorClass<UnsupportedLocalRepositoryError>()(
|
||||
"RepositoryUnsupportedLocalRepositoryError",
|
||||
{
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidRepositoryBranchError extends Schema.TaggedErrorClass<InvalidRepositoryBranchError>()(
|
||||
"RepositoryInvalidBranchError",
|
||||
{
|
||||
branch: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type RepositoryError =
|
||||
| InvalidRepositoryReferenceError
|
||||
| UnsupportedLocalRepositoryError
|
||||
| InvalidRepositoryBranchError
|
||||
|
||||
export function isRepositoryError(error: unknown): error is RepositoryError {
|
||||
return (
|
||||
error instanceof InvalidRepositoryReferenceError ||
|
||||
error instanceof UnsupportedLocalRepositoryError ||
|
||||
error instanceof InvalidRepositoryBranchError
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRepositoryInput(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
@@ -147,16 +185,28 @@ export function isRemoteRepositoryReference(reference: Reference): reference is
|
||||
|
||||
export function parseRemoteRepositoryReference(input: string) {
|
||||
const reference = parseRepositoryReference(input)
|
||||
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||
if (!isRemoteRepositoryReference(reference)) throw new Error("Local file repositories are not supported")
|
||||
if (!reference) {
|
||||
throw new InvalidRepositoryReferenceError({
|
||||
repository: input,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
})
|
||||
}
|
||||
if (!isRemoteRepositoryReference(reference)) {
|
||||
throw new UnsupportedLocalRepositoryError({
|
||||
repository: input,
|
||||
message: "Local file repositories are not supported",
|
||||
})
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
export function validateRepositoryBranch(branch: string) {
|
||||
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
|
||||
throw new Error(
|
||||
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
|
||||
)
|
||||
throw new InvalidRepositoryBranchError({
|
||||
branch,
|
||||
message:
|
||||
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@ import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
fileTreeFileSelection,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
movePatchFileIndex,
|
||||
orderedPatchFileIndexes,
|
||||
setFileTreeDirectoryExpanded,
|
||||
showDiffViewerFileTree,
|
||||
singlePatchFileIndex,
|
||||
toggleFileTreeDirectory,
|
||||
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
|
||||
@@ -233,6 +238,56 @@ describe("diff viewer file tree utilities", () => {
|
||||
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
|
||||
})
|
||||
|
||||
test("selects a file tree node and expands its parents for a patch file", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
||||
const selection = fileTreeFileSelection(tree, 1)
|
||||
|
||||
expect(selection?.highlightedNode).toBe(
|
||||
tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
|
||||
)
|
||||
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
|
||||
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers the selected file when choosing the single patch file", () => {
|
||||
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
|
||||
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
|
||||
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
|
||||
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
|
||||
})
|
||||
|
||||
test("orders patches by the flattened file tree order", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([
|
||||
{ file: "src/dir-8/juniper-4.ts" },
|
||||
{ file: "src/dir-8/harbor-94.ts" },
|
||||
{ file: "src/dir-8/cedar-16.ts" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(orderedPatchFileIndexes(rows)).toEqual([2, 1, 0])
|
||||
})
|
||||
|
||||
test("shows the diff viewer file tree only when enabled and files exist", () => {
|
||||
expect(showDiffViewerFileTree(true, 1)).toBe(true)
|
||||
expect(showDiffViewerFileTree(true, 0)).toBe(false)
|
||||
expect(showDiffViewerFileTree(false, 1)).toBe(false)
|
||||
expect(showDiffViewerFileTree(false, 0)).toBe(false)
|
||||
})
|
||||
|
||||
test("moves patch selection through the ordered patch file indexes", () => {
|
||||
const fileIndexes = [2, 1, 0]
|
||||
|
||||
expect(movePatchFileIndex(fileIndexes, undefined, 1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, undefined, -1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, 2, 1)).toBe(1)
|
||||
expect(movePatchFileIndex(fileIndexes, 1, -1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, 0, 1)).toBe(0)
|
||||
expect(movePatchFileIndex(fileIndexes, 99, 1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, 99, -1)).toBe(2)
|
||||
expect(movePatchFileIndex([], undefined, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("toggles only selected directory expansion", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
|
||||
@@ -26,7 +26,7 @@ const theme = {
|
||||
}
|
||||
|
||||
describe("DiffViewerFileTree", () => {
|
||||
test("renders sorted hierarchical file rows", async () => {
|
||||
test.skip("renders sorted hierarchical file rows", async () => {
|
||||
const app = await testRender(
|
||||
() =>
|
||||
withTheme(() => (
|
||||
@@ -155,7 +155,7 @@ async function renderFrame(component: () => JSX.Element) {
|
||||
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
|
||||
try {
|
||||
await renderOnceSettled(app)
|
||||
return app.captureCharFrame()
|
||||
return await captureSettledFrame(app)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -167,6 +167,16 @@ async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
await app.renderOnce()
|
||||
}
|
||||
|
||||
async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const frame = app.captureCharFrame()
|
||||
if (frame.trim().length > 0) return frame
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await app.renderOnce()
|
||||
}
|
||||
return app.captureCharFrame()
|
||||
}
|
||||
|
||||
function withTheme(component: () => JSX.Element) {
|
||||
return (
|
||||
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdir } from "fs/promises"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
|
||||
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
|
||||
import { OpencodeKeymapProvider } from "../../../src/cli/cmd/tui/keymap"
|
||||
import diffViewerPlugin from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("closing the diff viewer returns to the route it opened from", async () => {
|
||||
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
|
||||
const commands = new Map<
|
||||
string,
|
||||
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
|
||||
>()
|
||||
let current = startRoute
|
||||
let renderDiff: TuiRouteDefinition["render"] | undefined
|
||||
await mkdir(Global.Path.state, { recursive: true })
|
||||
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const registerLayer = keymap.registerLayer.bind(keymap)
|
||||
keymap.registerLayer = (layer) => {
|
||||
layer.commands?.forEach((command) => commands.set(command.name, command))
|
||||
return registerLayer(layer)
|
||||
}
|
||||
const base = createTuiPluginApi({
|
||||
keymap,
|
||||
client: {
|
||||
vcs: { diff: async () => ({ data: [] }) },
|
||||
session: { diff: async () => ({ data: [] }) },
|
||||
} as unknown as TuiPluginApi["client"],
|
||||
})
|
||||
const api = {
|
||||
...base,
|
||||
route: {
|
||||
register(routes) {
|
||||
renderDiff = routes.find((route) => route.name === "diff")?.render
|
||||
return () => {}
|
||||
},
|
||||
navigate(name, params) {
|
||||
current = params ? { name, params } : { name }
|
||||
},
|
||||
get current() {
|
||||
return current
|
||||
},
|
||||
},
|
||||
} satisfies TuiPluginApi
|
||||
|
||||
void diffViewerPlugin.tui(api, undefined, pluginMeta)
|
||||
commands.get("diff.open")?.run?.({} as never)
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
</TuiConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: 20 })
|
||||
try {
|
||||
await waitForCommand(app, commands, "diff.close")
|
||||
expect(current).toEqual({ name: "diff", params: { mode: "git", sessionID: "session-1", returnRoute: startRoute } })
|
||||
|
||||
expect(commands.has("diff.close")).toBe(true)
|
||||
commands.get("diff.close")!.run?.({} as never)
|
||||
expect(current).toEqual(startRoute)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function waitForCommand(
|
||||
app: Awaited<ReturnType<typeof testRender>>,
|
||||
commands: Map<string, unknown>,
|
||||
command: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await app.renderOnce()
|
||||
if (commands.has(command)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
|
||||
const pluginMeta = {
|
||||
id: "diff-viewer",
|
||||
source: "internal",
|
||||
spec: "diff-viewer",
|
||||
target: "diff-viewer",
|
||||
first_time: 0,
|
||||
last_time: 0,
|
||||
time_changed: 0,
|
||||
load_count: 1,
|
||||
fingerprint: "test",
|
||||
state: "same",
|
||||
} satisfies TuiPluginMeta
|
||||
@@ -14,19 +14,23 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest)
|
||||
return Layer.succeed(HttpClient.HttpClient, client)
|
||||
}
|
||||
|
||||
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
|
||||
function mockSpawner(
|
||||
handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () =>
|
||||
"",
|
||||
) {
|
||||
const spawner = ChildProcessSpawner.make((command) => {
|
||||
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
||||
const output = handler(std?.command ?? "", std?.args ?? [])
|
||||
const result = handler(std?.command ?? "", std?.args ?? [])
|
||||
const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result
|
||||
return Effect.succeed(
|
||||
ChildProcessSpawner.makeHandle({
|
||||
pid: ChildProcessSpawner.ProcessId(0),
|
||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)),
|
||||
isRunning: Effect.succeed(false),
|
||||
kill: () => Effect.void,
|
||||
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
|
||||
stderr: Stream.empty,
|
||||
stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty,
|
||||
stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty,
|
||||
all: Stream.empty,
|
||||
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||
getOutputFd: () => Stream.empty,
|
||||
@@ -46,7 +50,7 @@ function jsonResponse(body: unknown) {
|
||||
|
||||
function testLayer(
|
||||
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
|
||||
spawnHandler?: (cmd: string, args: readonly string[]) => string,
|
||||
spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string },
|
||||
) {
|
||||
const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler)))
|
||||
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess))
|
||||
@@ -166,4 +170,44 @@ describe("installation", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("upgrade", () => {
|
||||
testEffect(
|
||||
testLayer(
|
||||
() => jsonResponse({}),
|
||||
(cmd) => {
|
||||
if (cmd === "npm") return { code: 1, stderr: "token=secret command output" }
|
||||
return ""
|
||||
},
|
||||
),
|
||||
).effect("returns sanitized typed errors for failed package upgrades", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Effect.flip(Installation.use.upgrade("npm", "9.9.9"))
|
||||
expect(error).toBeInstanceOf(Installation.UpgradeFailedError)
|
||||
expect(error.stderr).toBe("Upgrade failed for npm (exit code 1).")
|
||||
expect(error.message).toBe(error.stderr)
|
||||
expect(error.stderr).not.toContain("secret")
|
||||
expect(error.stderr).not.toContain("command output")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
testLayer(
|
||||
() => new Response("install script with token=secret", { status: 200 }),
|
||||
(cmd) => {
|
||||
if (cmd === "bash") return { code: 1, stderr: "script output with token=secret" }
|
||||
return ""
|
||||
},
|
||||
),
|
||||
).effect("returns sanitized typed errors when the curl install script fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Effect.flip(Installation.use.upgrade("curl", "9.9.9"))
|
||||
expect(error).toBeInstanceOf(Installation.UpgradeFailedError)
|
||||
expect(error.stderr).toBe("Upgrade failed for curl (exit code 1).")
|
||||
expect(error.message).toBe(error.stderr)
|
||||
expect(error.stderr).not.toContain("secret")
|
||||
expect(error.stderr).not.toContain("script output")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, mock, beforeEach } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -635,12 +635,15 @@ it.instance(
|
||||
// ========================================================================
|
||||
|
||||
it.instance(
|
||||
"connect() on nonexistent server does not throw",
|
||||
"connect() on nonexistent server fails with NotFoundError",
|
||||
() =>
|
||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
// Should not throw
|
||||
yield* mcp.connect("nonexistent")
|
||||
const exit = yield* mcp.connect("nonexistent").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
|
||||
}
|
||||
const status = yield* mcp.status()
|
||||
expect(status["nonexistent"]).toBeUndefined()
|
||||
}),
|
||||
@@ -653,12 +656,15 @@ it.instance(
|
||||
// ========================================================================
|
||||
|
||||
it.instance(
|
||||
"disconnect() on nonexistent server does not throw",
|
||||
"disconnect() on nonexistent server fails with NotFoundError",
|
||||
() =>
|
||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mcp.disconnect("nonexistent")
|
||||
// Should complete without error
|
||||
const exit = yield* mcp.disconnect("nonexistent").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { mcp: {} } },
|
||||
|
||||
@@ -1057,10 +1057,14 @@ it.instance(
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"reply - does nothing for unknown requestID",
|
||||
"reply - fails for unknown requestID",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" })
|
||||
const exit = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
|
||||
}
|
||||
expect(yield* list()).toHaveLength(0)
|
||||
}),
|
||||
{ git: true },
|
||||
|
||||
@@ -22,7 +22,7 @@ const encoder = new TextEncoder()
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const it = testEffect(layer)
|
||||
|
||||
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
|
||||
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
@@ -481,7 +481,7 @@ describe("Project.update", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should throw error when project not found", () =>
|
||||
it.live("should fail when project not found", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* run((svc) =>
|
||||
svc.update({
|
||||
@@ -492,9 +492,7 @@ describe("Project.update", () => {
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain(
|
||||
"Project not found: nonexistent-project-id",
|
||||
)
|
||||
expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -351,6 +351,16 @@ it.instance(
|
||||
{ config: { model: "anthropic/claude-sonnet-4-20250514" } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"defaultModel returns a typed error when config excludes every provider",
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Provider.use.defaultModel().pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Provider.NoProvidersError)
|
||||
expect(error._tag).toBe("ProviderNoProvidersError")
|
||||
}),
|
||||
{ config: { enabled_providers: [] } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"provider with baseURL from config",
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Config } from "../../src/config/config"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Pty } from "../../src/pty"
|
||||
import type { PtyID } from "../../src/pty/schema"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { Cause, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
|
||||
@@ -66,6 +66,54 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
|
||||
}
|
||||
|
||||
describe("pty", () => {
|
||||
it.instance(
|
||||
"returns typed not found errors for missing sessions",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = "pty_missing" as PtyID
|
||||
let closed = false
|
||||
const socket = {
|
||||
readyState: 1,
|
||||
send: () => {},
|
||||
close: () => {
|
||||
closed = true
|
||||
},
|
||||
}
|
||||
|
||||
const get = yield* pty.get(id).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(get)).toBe(true)
|
||||
if (Exit.isFailure(get)) expect(Cause.squash(get.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
|
||||
const update = yield* pty.update(id, { title: "missing" }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(update)).toBe(true)
|
||||
if (Exit.isFailure(update))
|
||||
expect(Cause.squash(update.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
|
||||
const remove = yield* pty.remove(id).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(remove)).toBe(true)
|
||||
if (Exit.isFailure(remove))
|
||||
expect(Cause.squash(remove.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
|
||||
const resize = yield* pty.resize(id, 80, 24).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(resize)).toBe(true)
|
||||
if (Exit.isFailure(resize))
|
||||
expect(Cause.squash(resize.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
|
||||
const write = yield* pty.write(id, "input").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(write)).toBe(true)
|
||||
if (Exit.isFailure(write))
|
||||
expect(Cause.squash(write.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
|
||||
const connect = yield* pty.connect(id, socket).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(connect)).toBe(true)
|
||||
if (Exit.isFailure(connect))
|
||||
expect(Cause.squash(connect.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
expect(closed).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
ptyTest(
|
||||
"publishes created, exited, deleted in order for a short-lived process",
|
||||
() =>
|
||||
@@ -93,7 +141,7 @@ describe("pty", () => {
|
||||
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"])
|
||||
yield* pty.write(info.id, "exit\n")
|
||||
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"])
|
||||
yield* pty.remove(info.id)
|
||||
yield* pty.remove(info.id).pipe(Effect.ignore)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -184,11 +184,17 @@ it.instance(
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"reply - does nothing for unknown requestID",
|
||||
"reply - fails for unknown requestID",
|
||||
() =>
|
||||
replyEffect({
|
||||
requestID: QuestionID.make("que_unknown"),
|
||||
answers: [["Option 1"]],
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* replyEffect({
|
||||
requestID: QuestionID.make("que_unknown"),
|
||||
answers: [["Option 1"]],
|
||||
}).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Question.NotFoundError", requestID: "que_unknown" })
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -253,9 +259,18 @@ it.instance(
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), {
|
||||
git: true,
|
||||
})
|
||||
it.instance(
|
||||
"reject - fails for unknown requestID",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* rejectEffect(QuestionID.make("que_unknown")).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Question.NotFoundError", requestID: "que_unknown" })
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
// multiple questions tests
|
||||
|
||||
|
||||
@@ -177,6 +177,15 @@ const scenarios: Scenario[] = [
|
||||
},
|
||||
"status",
|
||||
),
|
||||
http.protected
|
||||
.patch("/project/{projectID}", "project.update.missing")
|
||||
.mutating()
|
||||
.at((ctx) => ({
|
||||
path: route("/project/{projectID}", { projectID: "project_httpapi_missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: { name: "Missing Project" },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/project/git/init", "project.initGit")
|
||||
.mutating()
|
||||
@@ -224,9 +233,7 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
body: { reply: "once" },
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "permission reply should return true even when request is no longer pending")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/question", "question.list").json(200, array),
|
||||
http.protected
|
||||
.post("/question/{requestID}/reply", "question.reply.invalid")
|
||||
@@ -243,18 +250,14 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
body: { answers: [["Yes"]] },
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "question reply should return true even when request is no longer pending")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/question/{requestID}/reject", "question.reject")
|
||||
.at((ctx) => ({
|
||||
path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "question reject should return true even when request is no longer pending")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/file", "file.list")
|
||||
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
|
||||
@@ -335,58 +338,37 @@ const scenarios: Scenario[] = [
|
||||
http.protected
|
||||
.post("/mcp/{name}/auth", "mcp.auth.start")
|
||||
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||
.json(
|
||||
400,
|
||||
(body) => {
|
||||
object(body)
|
||||
check(typeof body.error === "string", "unsupported MCP OAuth response should include error")
|
||||
},
|
||||
"status",
|
||||
),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.delete("/mcp/{name}/auth", "mcp.auth.remove")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
check(body.success === true, "MCP auth removal should return success")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
|
||||
.at((ctx) => ({
|
||||
path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
400,
|
||||
(body) => {
|
||||
object(body)
|
||||
check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error")
|
||||
},
|
||||
"status",
|
||||
),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/mcp/{name}/auth/callback", "mcp.auth.callback")
|
||||
.at((ctx) => ({
|
||||
path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: { code: 1 },
|
||||
body: { code: "code" },
|
||||
}))
|
||||
.status(400),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/mcp/{name}/connect", "mcp.connect")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "missing MCP connect should remain a no-op success")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/mcp/{name}/disconnect", "mcp.disconnect")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "missing MCP disconnect should remain a no-op success")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/pty/shells", "pty.shells").json(200, array),
|
||||
http.protected.get("/pty", "pty.list").json(200, array),
|
||||
http.protected
|
||||
@@ -431,9 +413,7 @@ const scenarios: Scenario[] = [
|
||||
.delete("/pty/{ptyID}", "pty.remove")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "PTY remove should return true")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/pty/{ptyID}/connect", "pty.connect")
|
||||
.at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
|
||||
@@ -1249,9 +1229,7 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
body: { response: "once" },
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
check(body === true, "deprecated permission response should return true")
|
||||
}),
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/session/{sessionID}/share", "session.share")
|
||||
.mutating()
|
||||
|
||||
@@ -8,6 +8,9 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -151,6 +154,82 @@ describe("instance HttpApi", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns typed not found bodies for missing permission and question requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${path}`, {
|
||||
...init,
|
||||
headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
|
||||
}),
|
||||
handlerContext,
|
||||
),
|
||||
)
|
||||
const permissionID = PermissionID.ascending()
|
||||
const questionReplyID = QuestionID.ascending()
|
||||
const questionRejectID = QuestionID.ascending()
|
||||
const [permission, questionReply, questionReject] = yield* Effect.all(
|
||||
[
|
||||
request(`/permission/${permissionID}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reply: "once" }),
|
||||
}),
|
||||
request(`/question/${questionReplyID}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ answers: [["Yes"]] }),
|
||||
}),
|
||||
request(`/question/${questionRejectID}/reject`, { method: "POST" }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(permission.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => permission.json())).toEqual({
|
||||
_tag: "PermissionNotFoundError",
|
||||
requestID: permissionID,
|
||||
message: `Permission request not found: ${permissionID}`,
|
||||
})
|
||||
expect(questionReply.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => questionReply.json())).toEqual({
|
||||
_tag: "QuestionNotFoundError",
|
||||
requestID: questionReplyID,
|
||||
message: `Question request not found: ${questionReplyID}`,
|
||||
})
|
||||
expect(questionReject.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => questionReject.json())).toEqual({
|
||||
_tag: "QuestionNotFoundError",
|
||||
requestID: questionRejectID,
|
||||
message: `Question request not found: ${questionRejectID}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns typed not found bodies for missing projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const projectID = ProjectID.make("project_missing")
|
||||
const response = yield* Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost/project/${projectID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-opencode-directory": dir, "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "Missing" }),
|
||||
}),
|
||||
handlerContext,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "ProjectNotFoundError",
|
||||
projectID,
|
||||
message: `Project not found: ${projectID}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves path and VCS read endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
|
||||
@@ -192,4 +192,36 @@ describe("mcp HttpApi", () => {
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns typed not found errors for missing MCP servers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = yield* handlerScoped
|
||||
|
||||
for (const input of [
|
||||
{ method: "POST", route: "/mcp/missing/auth" },
|
||||
{ method: "POST", route: "/mcp/missing/auth/authenticate" },
|
||||
{ method: "POST", route: "/mcp/missing/auth/callback", body: JSON.stringify({ code: "code" }) },
|
||||
{ method: "DELETE", route: "/mcp/missing/auth" },
|
||||
{ method: "POST", route: "/mcp/missing/connect" },
|
||||
{ method: "POST", route: "/mcp/missing/disconnect" },
|
||||
]) {
|
||||
const response = yield* request(handler, input.route, tmp.directory, {
|
||||
method: input.method,
|
||||
headers: input.body ? { "content-type": "application/json" } : undefined,
|
||||
body: input.body,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* json(response)).toEqual({
|
||||
_tag: "McpServerNotFoundError",
|
||||
name: "missing",
|
||||
message: "MCP server not found: missing",
|
||||
})
|
||||
}
|
||||
}),
|
||||
{ config: { mcp: {} } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -112,6 +112,31 @@ describe("pty HttpApi bridge", () => {
|
||||
|
||||
const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: info.id,
|
||||
message: `PTY session not found: ${info.id}`,
|
||||
})
|
||||
|
||||
const missingUpdate = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "missing" }),
|
||||
})
|
||||
expect(missingUpdate.status).toBe(404)
|
||||
expect(await missingUpdate.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: info.id,
|
||||
message: `PTY session not found: ${info.id}`,
|
||||
})
|
||||
|
||||
const missingRemove = await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
|
||||
expect(missingRemove.status).toBe(404)
|
||||
expect(await missingRemove.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: info.id,
|
||||
message: `PTY session not found: ${info.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before upgrade", async () => {
|
||||
@@ -121,6 +146,63 @@ describe("pty HttpApi bridge", () => {
|
||||
})
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns typed not found errors for missing PTY HTTP resources", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const missingID = String(PtyID.ascending())
|
||||
const expected = {
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: missingID,
|
||||
message: `PTY session not found: ${missingID}`,
|
||||
}
|
||||
|
||||
const found = await app().request(PtyPaths.get.replace(":ptyID", missingID), { headers })
|
||||
expect(found.status).toBe(404)
|
||||
expect(await found.json()).toEqual(expected)
|
||||
|
||||
const updated = await app().request(PtyPaths.update.replace(":ptyID", missingID), {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "missing" }),
|
||||
})
|
||||
expect(updated.status).toBe(404)
|
||||
expect(await updated.json()).toEqual(expected)
|
||||
|
||||
const removed = await app().request(PtyPaths.remove.replace(":ptyID", missingID), { method: "DELETE", headers })
|
||||
expect(removed.status).toBe(404)
|
||||
expect(await removed.json()).toEqual(expected)
|
||||
})
|
||||
|
||||
test("returns typed errors for PTY connect token failures", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const missingID = String(PtyID.ascending())
|
||||
|
||||
const forbidden = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), {
|
||||
method: "POST",
|
||||
headers,
|
||||
})
|
||||
expect(forbidden.status).toBe(403)
|
||||
expect(await forbidden.json()).toEqual({
|
||||
_tag: "PtyForbiddenError",
|
||||
message: "Invalid PTY connect token request",
|
||||
})
|
||||
|
||||
const missing = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"x-opencode-ticket": "1",
|
||||
},
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: missingID,
|
||||
message: `PTY session not found: ${missingID}`,
|
||||
})
|
||||
})
|
||||
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||
"serves PTY websocket output and input through Effect routes",
|
||||
() =>
|
||||
|
||||
@@ -157,4 +157,63 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents permission and question not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
expect(
|
||||
componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
|
||||
).toBe("PermissionNotFoundError")
|
||||
for (const route of [
|
||||
["post", "/question/{requestID}/reply"],
|
||||
["post", "/question/{requestID}/reject"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||
"QuestionNotFoundError",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents MCP server not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/mcp/{name}/auth"],
|
||||
["post", "/mcp/{name}/auth/authenticate"],
|
||||
["post", "/mcp/{name}/auth/callback"],
|
||||
["delete", "/mcp/{name}/auth"],
|
||||
["post", "/mcp/{name}/connect"],
|
||||
["post", "/mcp/{name}/disconnect"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||
"McpServerNotFoundError",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents PTY resource and ticket errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["get", "/pty/{ptyID}"],
|
||||
["put", "/pty/{ptyID}"],
|
||||
["delete", "/pty/{ptyID}"],
|
||||
["post", "/pty/{ptyID}/connect-token"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||
"PtyNotFoundError",
|
||||
)
|
||||
}
|
||||
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
|
||||
"PtyForbiddenError",
|
||||
)
|
||||
})
|
||||
|
||||
test("documents project not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
|
||||
"ProjectNotFoundError",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -821,19 +821,24 @@ describe("session HttpApi", () => {
|
||||
}),
|
||||
).toMatchObject({ id: session.id })
|
||||
|
||||
expect(
|
||||
yield* requestJson<boolean>(
|
||||
pathFor(SessionPaths.permissions, {
|
||||
sessionID: session.id,
|
||||
permissionID: String(PermissionID.ascending()),
|
||||
}),
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ response: "once" }),
|
||||
},
|
||||
),
|
||||
).toBe(true)
|
||||
const permissionID = String(PermissionID.ascending())
|
||||
const permission = yield* request(
|
||||
pathFor(SessionPaths.permissions, {
|
||||
sessionID: session.id,
|
||||
permissionID,
|
||||
}),
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ response: "once" }),
|
||||
},
|
||||
)
|
||||
expect(permission.status).toBe(404)
|
||||
expect(yield* responseJson(permission)).toEqual({
|
||||
_tag: "PermissionNotFoundError",
|
||||
requestID: permissionID,
|
||||
message: `Permission request not found: ${permissionID}`,
|
||||
})
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
@@ -250,6 +251,26 @@ describe("workspace HttpApi", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns a declared not found error when warping into a missing workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing_warp")
|
||||
|
||||
const response = yield* request(WorkspacePaths.warp, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: workspaceID, sessionID: session.id }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
name: "NotFoundError",
|
||||
data: { message: `Workspace not found: ${workspaceID}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates workspace with the TUI payload shape", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
@@ -47,6 +47,11 @@ const it = testEffect(
|
||||
Skill.Service,
|
||||
Skill.Service.of({
|
||||
get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)),
|
||||
require: (name) => {
|
||||
const info = skills.find((skill) => skill.name === name)
|
||||
if (info) return Effect.succeed(info)
|
||||
return Effect.fail(new Skill.NotFoundError({ name, available: skills.map((skill) => skill.name) }))
|
||||
},
|
||||
all: () => Effect.succeed(skills),
|
||||
dirs: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed(skills),
|
||||
|
||||
@@ -289,6 +289,37 @@ description: A skill in the .claude/skills directory.
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fails with typed error when requiring a missing skill", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const error = yield* Effect.flip(skill.require("missing-skill"))
|
||||
expect(error).toBeInstanceOf(Skill.NotFoundError)
|
||||
expect(error._tag).toBe("Skill.NotFoundError")
|
||||
expect(error.name).toBe("missing-skill")
|
||||
expect(error.message).toContain('Skill "missing-skill" not found.')
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("exposes tagged expected skill failure classes", () =>
|
||||
Effect.sync(() => {
|
||||
const invalid = new Skill.InvalidError({ path: "/tmp/SKILL.md", message: "Invalid skill frontmatter" })
|
||||
const mismatch = new Skill.NameMismatchError({
|
||||
path: "/tmp/SKILL.md",
|
||||
expected: "expected-skill",
|
||||
actual: "actual-skill",
|
||||
})
|
||||
|
||||
expect(invalid).toBeInstanceOf(Skill.InvalidError)
|
||||
expect(invalid._tag).toBe("SkillInvalidError")
|
||||
expect(mismatch).toBeInstanceOf(Skill.NameMismatchError)
|
||||
expect(mismatch._tag).toBe("SkillNameMismatchError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers skills from .agents/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -90,4 +90,44 @@ Use this skill.
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute preserves not found message", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const tool = (yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)
|
||||
if (!tool) throw new Error("Skill tool not found")
|
||||
|
||||
const exit = yield* tool
|
||||
.execute(
|
||||
{ name: "missing-skill" },
|
||||
{
|
||||
...baseCtx,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.')
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,9 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import {
|
||||
InvalidRepositoryBranchError,
|
||||
InvalidRepositoryReferenceError,
|
||||
UnsupportedLocalRepositoryError,
|
||||
isFileRepositoryReference,
|
||||
isRemoteRepositoryReference,
|
||||
parseRemoteRepositoryReference,
|
||||
@@ -61,6 +64,14 @@ describe("util.repository", () => {
|
||||
expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(
|
||||
"Local file repositories are not supported",
|
||||
)
|
||||
expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(UnsupportedLocalRepositoryError)
|
||||
})
|
||||
|
||||
test("rejects invalid remote repository references with typed errors", () => {
|
||||
expect(() => parseRemoteRepositoryReference("not-a-repo")).toThrow(InvalidRepositoryReferenceError)
|
||||
expect(() => parseRemoteRepositoryReference("git@github.com:../../../etc/passwd")).toThrow(
|
||||
InvalidRepositoryReferenceError,
|
||||
)
|
||||
})
|
||||
|
||||
test("compares cache identity independent of input spelling", () => {
|
||||
@@ -77,5 +88,6 @@ describe("util.repository", () => {
|
||||
expect(() => validateRepositoryBranch("-bad")).toThrow("Branch must contain only alphanumeric characters")
|
||||
expect(() => validateRepositoryBranch("bad..branch")).toThrow("Branch must contain only alphanumeric characters")
|
||||
expect(() => validateRepositoryBranch("bad branch")).toThrow("Branch must contain only alphanumeric characters")
|
||||
expect(() => validateRepositoryBranch("bad branch")).toThrow(InvalidRepositoryBranchError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.7",
|
||||
"version": "1.15.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user