Compare commits

..
Author SHA1 Message Date
Shoubhit Dash b4344098de fix(mcp): validate oauth callback port range 2026-05-22 16:47:36 +05:30
Sebin ThomasandClaude Sonnet 4.6 bca26b0094 test(mcp): add McpOAuthProvider unit tests for scope and callbackPort
Covers:
- redirectUrl uses callbackPort when set, falls back to default 19876
- redirectUri takes precedence over callbackPort when both are set
- clientMetadata includes scope when set in config
- clientMetadata omits scope when not set (backward compat)
- token_endpoint_auth_method based on clientSecret presence

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 12:08:51 +02:00
Sebin ThomasandClaude Sonnet 4.6 e9ca7d292b fix(mcp): include scope in clientMetadata and add callbackPort option
Two related OAuth issues with remote MCP servers:

1. `scope` config field was accepted but never propagated to
   `clientMetadata`. The MCP SDK uses `clientMetadata.scope` as its
   last-resort fallback when neither the WWW-Authenticate header nor
   the Protected Resource Metadata (scopes_supported) advertise scopes.
   For servers whose metadata returns no scopes (e.g. AWS Bedrock
   AgentCore), the authorization request was sent with no scope
   parameter, causing IdPs such as Okta to reject with
   "No scopes were requested."

2. The callback port was hardcoded to 19876 with no way to override it
   short of providing a full `redirectUri`. Added `callbackPort` as a
   shorthand in the OAuth config — when set it constructs the redirect
   URI as `http://127.0.0.1:<callbackPort>/mcp/oauth/callback`.
   `redirectUri` still takes precedence if both are provided.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 11:58:19 +02:00
102 changed files with 680 additions and 2742 deletions
+14
View File
@@ -34,11 +34,25 @@ jobs:
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000;
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
if (teamAssociations.includes(item.author_association)) {
core.info(`Skipping ${kind} #${item.number}: author association is ${item.author_association}`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
+2 -2
View File
@@ -6,7 +6,7 @@ on:
jobs:
check-duplicates:
if: github.event.action == 'opened'
if: github.event.action == 'opened' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
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')
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
+9 -6
View File
@@ -11,22 +11,25 @@ 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 }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
ASSOCIATION="${{ github.event.pull_request.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; 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
+6 -18
View File
@@ -28,15 +28,9 @@ jobs:
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
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`);
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (teamAssociations.includes(pr.author_association)) {
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
return;
}
@@ -175,15 +169,9 @@ jobs:
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
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`);
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (teamAssociations.includes(pr.author_association)) {
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
return;
}
+18 -18
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.9",
"version": "1.15.7",
"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.9",
"version": "1.15.7",
"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.9",
"version": "1.15.7",
"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.9",
"version": "1.15.7",
"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.9",
"version": "1.15.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -254,7 +254,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.9",
"version": "1.15.7",
"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.9",
"version": "1.15.7",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -323,7 +323,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -353,7 +353,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.9",
"version": "1.15.7",
"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.9",
"version": "1.15.7",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -382,7 +382,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -400,7 +400,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.9",
"version": "1.15.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -538,7 +538,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -576,7 +576,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -591,7 +591,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -626,7 +626,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -675,7 +675,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.9",
"version": "1.15.7",
"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(":").pop()?.trim()
return instructions.split(":")[1]?.trim()
}
return instructions
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.9",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.9",
"version": "1.15.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.9",
"version": "1.15.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.9",
"version": "1.15.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.9",
"version": "1.15.7",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.9",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.9",
"version": "1.15.7",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.9",
"version": "1.15.7",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.9"
version = "1.15.7"
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.9/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/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.9/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/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.9/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/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.9/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.9",
"version": "1.15.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.9",
"version": "1.15.7",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.9",
"version": "1.15.7",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
@@ -14,7 +14,6 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -97,18 +96,10 @@ 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.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]),
content: Schema.String,
is_error: Schema.optional(Schema.Boolean),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -206,13 +197,7 @@ const AnthropicEvent = Schema.Struct({
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
// `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) }),
),
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
@@ -313,31 +298,6 @@ 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,
@@ -400,7 +360,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
content.push({
type: "tool_result",
tool_use_id: part.id,
content: yield* lowerToolResultContent(part),
content: ProviderShared.toolResultText(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
})
@@ -707,18 +667,9 @@ 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: providerErrorMessage(event) })],
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
]
const step = (state: ParserState, event: AnthropicEvent) => {
+4 -67
View File
@@ -14,8 +14,6 @@ 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"
@@ -57,16 +55,6 @@ 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) }),
@@ -81,7 +69,7 @@ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("function_call_output"),
call_id: Schema.String,
output: OpenAIResponsesFunctionCallOutput,
output: Schema.String,
}),
])
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
@@ -178,17 +166,6 @@ 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),
@@ -201,14 +178,12 @@ 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>
@@ -275,27 +250,6 @@ 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) }]
@@ -344,11 +298,7 @@ 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: yield* lowerToolResultOutput(part),
})
input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) })
}
}
@@ -646,27 +596,14 @@ 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: providerErrorMessage(event, "OpenAI Responses response failed") })],
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })],
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -24,19 +24,6 @@ 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* () {
@@ -84,87 +71,6 @@ 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>(
@@ -337,29 +243,7 @@ describe("Anthropic Messages route", () => {
),
)
// 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" }])
expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
}),
)
@@ -87,7 +87,6 @@ describeRecordedGoldenScenarios([
{ id: "reasoning-continuation", temperature: false },
{ id: "tool-call", temperature: false },
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
@@ -113,10 +112,7 @@ describeRecordedGoldenScenarios([
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: [
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
scenarios: [{ id: "tool-loop", temperature: false }],
},
{
name: "Gemini 2.5 Flash",
@@ -26,19 +26,6 @@ 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* () {
@@ -261,84 +248,6 @@ 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>(
@@ -877,11 +786,7 @@ describe("OpenAI Responses route", () => {
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", 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" }])
expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }])
}),
)
@@ -895,99 +800,6 @@ 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(
-46
View File
@@ -317,47 +317,6 @@ 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!"),
@@ -400,11 +359,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.9",
"version": "1.15.7",
"name": "opencode",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -69,7 +69,7 @@ export interface Interface {
whenToUse: string
systemPrompt: string
},
Provider.DefaultModelError
Provider.ModelNotFoundError
>
}
+3 -30
View File
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Cause, Effect } from "effect"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
@@ -80,21 +80,7 @@ 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().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 model = agent.model ?? (yield* provider.defaultModel())
return yield* registry.tools({ ...model, agent })
})
@@ -147,20 +133,7 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
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")
},
}),
)
return yield* provider.defaultModel()
})
const now = Date.now()
const message: MessageV2.Assistant = {
+1 -23
View File
@@ -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, useProject } from "@tui/context/project"
import { ProjectProvider } from "@tui/context/project"
import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
@@ -279,7 +279,6 @@ 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()
@@ -448,13 +447,6 @@ 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(() =>
[
{
@@ -491,20 +483,6 @@ 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,16 +62,14 @@ 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_source: keybind("d", "Switch diff viewer source"),
diff_switch_diff: 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"),
@@ -261,16 +259,14 @@ 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_source: "diff.switch_source",
diff_switch_diff: "diff.switch_diff",
diff_toggle_view: "diff.toggle_view",
diff_help: "diff.help",
editor_open: "prompt.editor",
theme_list: "theme.switch",
theme_switch_mode: "theme.switch_mode",
@@ -157,39 +157,6 @@ 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))
}
@@ -222,11 +189,3 @@ function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<Fil
else nodes[input.parent]!.children.push(id)
return id
}
function fileTreeParentDirectories(tree: FileTree, id: number) {
const result = new Set<number>()
for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
result.add(parent)
}
return result
}
@@ -6,6 +6,7 @@ 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 = {
@@ -31,7 +32,6 @@ 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,18 +72,20 @@ 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) ?? false)
return file !== undefined && props.reviewedFileNames?.has(file)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const status = () => fileTreeRowStatus(row, props.files)
const name = () =>
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
Locale.truncate(
row.name,
Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().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()}
@@ -93,11 +95,13 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
fg={
highlighted()
? props.theme.background
: selected()
? props.theme.primary
: reviewed() || row.kind === "directory"
? props.theme.textMuted
: props.theme.text
: reviewed()
? props.theme.textMuted
: selected()
? props.theme.primary
: row.kind === "directory"
? tint(props.theme.text, props.theme.background, 0.35)
: props.theme.text
}
wrapMode="none"
>
@@ -154,9 +158,11 @@ 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[], reviewed: boolean) {
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) {
if (row.fileIndex === undefined) return ""
const status = files[row.fileIndex]?.status
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
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)
}
@@ -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, Show, splitProps, useContext } from "solid-js"
import { createContext, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
@@ -63,30 +63,22 @@ 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 (
<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>
<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>
)
}
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
return (
<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>
<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>
)
}
@@ -1,30 +1,25 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
import type { BoxRenderable, 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, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, 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"
@@ -32,13 +27,8 @@ 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
@@ -70,22 +60,13 @@ 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
returnRoute?: TuiRouteCurrent
}
| { mode?: DiffMode; sessionID?: string; messageID?: string }
| undefined
const mode = () => params()?.mode ?? "git"
const diffInput = createMemo(() => ({
@@ -104,27 +85,20 @@ function DiffViewer(props: { api: TuiPluginApi }) {
return normalizeDiffs(result.data ?? [])
}
const result = await props.api.client.vcs.diff(
{ mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES },
{ throwOnError: true },
)
const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true })
return normalizeDiffs(result.data ?? [])
})
const files = createMemo(() => diff() ?? [])
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
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 [showFileTree, setShowFileTree] = createSignal(true)
const [singlePatch, setSinglePatch] = createSignal(false)
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<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
const [viewOverride, setViewOverride] = createSignal<"split" | "unified">()
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
const fileTree = createMemo(() => buildFileTree(files()))
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
@@ -134,23 +108,18 @@ 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 switchSourceShortcut = useCommandShortcut("diff.switch_source")
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
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()))
@@ -185,155 +154,99 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setActivePatchFileIndex(undefined)
}
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
if (!scroll) return
const offset = fileIndex === 0 ? 0 : 1
scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
requestAnimationFrame(() => {
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)
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
})
}
const revealFileTreeFile = (fileIndex: number) => {
const selection = fileTreeFileSelection(fileTree(), fileIndex)
if (!selection) return
const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return
setExpandedFileNodes((expanded) => {
const next = new Set(expanded)
selection.expandedNodes.forEach((node) => next.add(node))
for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) {
next.add(parent)
}
return next
})
setHighlighted(selection.highlightedNode)
}
const selectPatchFile = (fileIndex: number) => {
revealFileTreeFile(fileIndex)
setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
setHighlighted(node.id)
}
const scrollToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
selectPatchFile(fileIndex)
setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
}
const jumpToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
revealFileTreeFile(fileIndex)
scrollToFileIndex(fileIndex)
}
const currentPatchFileIndex = () => {
if (!scroll) return undefined
const viewportContentY = scroll.scrollTop + 1
const entries = patchFileIndexes()
.map((fileIndex) => ({
fileIndex,
node: patchNodeByFileIndex.get(fileIndex),
}))
const entries = files()
.map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
.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
.sort((left, right) => left.node.y - right.node.y)
return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex
}
const jumpRelativePatchFile = (offset: number) => {
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
if (singlePatch()) {
if (next === undefined) return
selectPatchFile(next)
scrollSinglePatchToTop()
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)
return
}
scrollToFileIndex(next)
const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex()
const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex)
scrollToFileIndex(
fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex,
)
}
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
const visiblePatchFiles = createMemo(() => {
if (!singlePatch()) {
return patchFileIndexes().flatMap((fileIndex) => {
const file = files()[fileIndex]
return file ? [{ file, fileIndex }] : []
})
}
const fileIndex = singlePatchFileIndex(
selectedFileIndex(),
activePatchFileIndex(),
currentPatchFileIndex(),
firstPatchFileIndex(),
)
if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex }))
const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex()
const file = fileIndex === undefined ? undefined : files()[fileIndex]
return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
})
const ensureHighlightedPatchFile = () => {
const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
if (activePatchFileIndex() !== undefined) return
const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex()
if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex)
}
const scrollToHighlightedPatchFile = () => {
const fileIndex = activePatchFileIndex()
if (fileIndex === undefined) return
selectPatchFile(fileIndex)
}
const scrollToPatchFileIndexAfterRender = (fileIndex: number) => {
setPendingPatchScrollFileIndex(fileIndex)
requestAnimationFrame(() => {
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
requestAnimationFrame(() => {
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)
scrollPatchNodeToTop(element, fileIndex)
requestAnimationFrame(() => {
scrollPatchNodeToTop(element)
scrollPatchNodeToTop(element, fileIndex)
setPendingPatchScrollFileIndex(undefined)
})
})
}
createEffect(() => {
visiblePatchFiles()
dimensions()
view()
measurePatchFiller()
})
const toggleSelectedFileTreeRow = () => {
const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
if (highlighted?.fileIndex !== undefined) {
@@ -343,16 +256,6 @@ 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"
@@ -374,13 +277,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Close diff viewer",
category: "VCS",
run() {
const returnRoute = params()?.returnRoute
props.api.ui.dialog.clear()
props.api.route.navigate(
returnRoute?.name ?? "home",
returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
)
props.api.route.navigate("home")
},
},
{
@@ -468,17 +365,6 @@ 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",
@@ -540,10 +426,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle diff viewer file tree",
category: "VCS",
run() {
const next = !fileTreeEnabled()
if (!next) setFocus("patches")
setFileTreeEnabled(next)
props.api.kv.set(KV_SHOW_FILE_TREE, next)
setShowFileTree((value) => {
if (value) setFocus("patches")
return !value
})
},
},
{
@@ -551,29 +437,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle single patch view",
category: "VCS",
run() {
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)
setSinglePatch((value) => {
const next = !value
if (next) ensureHighlightedPatchFile()
else scrollToHighlightedPatchFile()
return next
})
},
},
{
name: "diff.switch_source",
name: "diff.switch_diff",
title: "Switch diff viewer source",
category: "VCS",
run() {
@@ -586,17 +459,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS",
run() {
if (!splitAvailable()) return
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()
setViewOverride(view() === "split" ? "unified" : "split")
},
},
]
@@ -617,7 +480,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const openSwitchDiffDialog = () => {
props.api.ui.dialog.replace(() => (
<DialogSelect
title="Switch source"
title="Switch diff"
skipFilter={true}
renderFilter={false}
current={mode()}
@@ -629,7 +492,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
mode: option.value,
sessionID: params()?.sessionID,
messageID: params()?.messageID,
returnRoute: params()?.returnRoute,
})
},
}))}
@@ -637,11 +499,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
))
}
const openHelpDialog = () => {
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
props.api.ui.dialog.setSize("large")
}
useBindings(() => ({
commands,
bindings: [
@@ -672,23 +529,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<box flexGrow={1} minHeight={0}>
<Switch>
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<box flexGrow={1} alignItems="center" justifyContent="center">
<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()}>
@@ -703,83 +547,93 @@ 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={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
}
/>
<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>
</box>
)}
</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} />
<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" />
</Panel>
</PanelGroup>
</Match>
@@ -808,10 +662,34 @@ function DiffViewer(props: { api: TuiPluginApi }) {
</text>
)}
</Show>
<Show when={switchSourceShortcut()}>
<Show when={toggleFileTreeShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
{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>
</text>
)}
</Show>
@@ -822,108 +700,12 @@ 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([
{
@@ -944,7 +726,6 @@ 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,7 +20,9 @@ export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
export function internalTuiPlugins(
flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">,
): InternalTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -33,7 +35,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
Notifications,
PluginManager,
WhichKey,
DiffViewer,
...(flags.diffViewer ? [DiffViewer] : []),
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
]
}
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
@@ -7,16 +7,13 @@ 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 { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question"
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
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)
@@ -122,13 +119,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
pick(opt.label)
}
onMount(() => {
const popMode = modeStack.push(QUESTION_MODE)
onCleanup(popMode)
})
useBindings(() => ({
mode: QUESTION_MODE,
mode: OPENCODE_BASE_MODE,
enabled: store.editing && !confirm(),
commands: [
{
@@ -209,7 +201,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const max = Math.min(total, 9)
return {
mode: QUESTION_MODE,
mode: OPENCODE_BASE_MODE,
enabled: !store.editing,
commands: [
{
@@ -15,6 +15,7 @@ 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"),
+21 -33
View File
@@ -67,11 +67,7 @@ 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 })
@@ -143,32 +139,23 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
return "opencode"
})
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 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 result: Interface = {
info: Effect.fn("Installation.info")(function* () {
@@ -312,10 +299,11 @@ 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 installation method: ${m}` })
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!upgradeResult || upgradeResult.code !== 0) {
return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) })
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
+25 -28
View File
@@ -67,10 +67,6 @@ 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({
@@ -246,8 +242,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, NotFoundError>
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
readonly connect: (name: string) => Effect.Effect<void>
readonly disconnect: (name: string) => Effect.Effect<void>
readonly getPrompt: (
clientName: string,
name: string,
@@ -257,13 +253,11 @@ export interface Interface {
clientName: string,
resourceUri: string,
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
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 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 removeAuth: (mcpName: string) => Effect.Effect<void>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
}
@@ -648,12 +642,15 @@ export const layer = Layer.effect(
})
const connect = Effect.fn("MCP.connect")(function* (name: string) {
const mcp = yield* requireMcpConfig(name)
const mcp = yield* getMcpConfig(name)
if (!mcp) {
log.error("MCP config not found or invalid", { name })
return
}
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]
@@ -762,14 +759,9 @@ export const layer = Layer.effect(
return mcpConfig
})
const requireMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return yield* new NotFoundError({ name: mcpName })
return mcpConfig
})
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
const mcpConfig = yield* requireMcpConfig(mcpName)
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
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)
@@ -781,7 +773,9 @@ 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))
@@ -833,9 +827,11 @@ export const layer = Layer.effect(
const result = yield* startAuth(mcpName)
if (!result.authorizationUrl) {
const client = "client" in result ? result.client : undefined
const mcpConfig = yield* requireMcpConfig(mcpName).pipe(
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
)
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 listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
if (!client || !listed) {
@@ -886,7 +882,6 @@ 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}`)
@@ -905,7 +900,8 @@ export const layer = Layer.effect(
yield* auth.clearCodeVerifier(mcpName)
pendingOAuthTransports.delete(mcpName)
const mcpConfig = yield* requireMcpConfig(mcpName)
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
return yield* createAndStore(mcpName, mcpConfig)
})
@@ -918,7 +914,8 @@ export const layer = Layer.effect(
})
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
const mcpConfig = yield* requireMcpConfig(mcpName)
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return false
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
})
+2 -6
View File
@@ -100,10 +100,6 @@ 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({
@@ -121,7 +117,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, NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
@@ -213,7 +209,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 yield* new NotFoundError({ requestID: input.requestID })
if (!existing) return
pending.delete(input.requestID)
yield* bus.publish(Event.Replied, {
+3 -9
View File
@@ -101,10 +101,6 @@ 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
// ---------------------------------------------------------------------------
@@ -120,7 +116,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, NotFoundError>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
@@ -376,9 +372,7 @@ 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 } }).pipe(
Effect.catchTag("Project.NotFoundError", () => Effect.void),
)
yield* update({ projectID: input.id, icon: { url } })
})
const list = Effect.fn("Project.list")(function* () {
@@ -406,7 +400,7 @@ export const layer: Layer.Layer<
.returning()
.get(),
)
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
yield* emitUpdated(data)
return data
+15 -44
View File
@@ -11,9 +11,6 @@ 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 }))
@@ -94,17 +91,11 @@ 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[],
options?: DiffOptions,
) {
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
const result = yield* git.patchAll(cwd, ref, {
context: options?.context ?? PATCH_CONTEXT_LINES,
context: PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
@@ -125,18 +116,11 @@ 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: 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,
})
? 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 })
if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
@@ -156,14 +140,13 @@ 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, options)
return yield* nativePatch(git, cwd, ref, item)
})
const files = Effect.fnUntraced(function* (
@@ -173,7 +156,6 @@ 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
@@ -181,7 +163,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, options)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
: totalPatch(item.file, patch, total)
@@ -202,12 +184,7 @@ const files = Effect.fnUntraced(function* (
return next
})
const diffAgainstRef = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string,
options?: DiffOptions,
) {
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
@@ -220,19 +197,13 @@ const diffAgainstRef = Effect.fnUntraced(function* (
extra.filter((item) => item.code === "??"),
),
nums(stats),
yield* batchPatches(git, cwd, ref, list, options),
options,
yield* batchPatches(git, cwd, ref, list),
)
})
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)
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)
})
export const Mode = Schema.Literals(["git", "branch"])
@@ -293,7 +264,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, options?: DiffOptions) => Effect.Effect<FileDiff[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
}
@@ -381,19 +352,19 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
}),
)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
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, options)
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
}
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, options)
return yield* diffAgainstRef(git, ctx.directory, ref)
}),
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
const ctx = yield* InstanceState.context
+4 -19
View File
@@ -994,22 +994,7 @@ export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderIni
}
}
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 type Error = ModelNotFoundError | InitError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
@@ -1021,7 +1006,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 }, DefaultModelError>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
}
interface State {
@@ -1836,9 +1821,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) return yield* new NoProvidersError()
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) return yield* new NoModelsError({ providerID: provider.id })
if (!model) throw new Error("no models found")
return {
providerID: provider.id,
modelID: model.id,
+25 -33
View File
@@ -87,10 +87,6 @@ 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 })),
@@ -100,20 +96,17 @@ export const Event = {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
readonly create: (input: CreateInput) => Effect.Effect<Info>
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 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 connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
@@ -157,15 +150,10 @@ 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 = yield* requireSession(id)
const session = s.sessions.get(id)
if (!session) return
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
@@ -178,7 +166,8 @@ export const layer = Layer.effect(
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
const s = yield* InstanceState.get(state)
return s.sessions.get(id)?.info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
@@ -273,7 +262,9 @@ export const layer = Layer.effect(
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
if (input.title) {
session.info.title = input.title
}
@@ -285,27 +276,28 @@ export const layer = Layer.effect(
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = yield* requireSession(id)
if (session.info.status === "running") {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(
Effect.tapError(() =>
Effect.sync(() => {
ws.close()
}),
),
)
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
const sub = sock(ws)
+4 -11
View File
@@ -98,10 +98,6 @@ 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>
@@ -119,11 +115,8 @@ export interface Interface {
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) => Effect.Effect<void, NotFoundError>
readonly reject: (requestID: QuestionID) => Effect.Effect<void, NotFoundError>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
@@ -187,7 +180,7 @@ export const layer = Layer.effect(
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return yield* new NotFoundError({ requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
@@ -204,7 +197,7 @@ export const layer = Layer.effect(
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return yield* new NotFoundError({ requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
@@ -7,11 +7,8 @@ import {
repositoryCachePath,
sameRepositoryReference,
parseRepositoryReference,
parseRemoteRepositoryReference,
validateRepositoryBranch,
InvalidRepositoryBranchError,
InvalidRepositoryReferenceError,
UnsupportedLocalRepositoryError,
isRemoteRepositoryReference,
type RemoteReference,
} from "@/util/repository"
@@ -141,26 +138,23 @@ export function isError(error: unknown): error is Error {
}
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
try {
return parseRemoteRepositoryReference(repository)
} catch (error) {
if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) {
return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message })
}
const reference = parseRepositoryReference(repository)
if (!reference) {
return yield* new InvalidRepositoryError({
repository,
message: errorMessage(error),
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
})
}
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,59 +122,6 @@ 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,7 +26,6 @@ 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,7 +2,6 @@ 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"
@@ -68,7 +67,7 @@ export const McpApi = HttpApi.make("mcp")
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, McpServerNotFoundError],
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.start",
@@ -81,7 +80,7 @@ export const McpApi = HttpApi.make("mcp")
query: WorkspaceRoutingQuery,
payload: AuthCallbackPayload,
success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, McpServerNotFoundError],
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.callback",
@@ -94,7 +93,7 @@ export const McpApi = HttpApi.make("mcp")
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, McpServerNotFoundError],
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.authenticate",
@@ -106,7 +105,7 @@ export const McpApi = HttpApi.make("mcp")
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: McpServerNotFoundError,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.remove",
@@ -118,7 +117,6 @@ 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",
@@ -129,7 +127,6 @@ 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,7 +2,6 @@ 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"
@@ -33,7 +32,7 @@ export const PermissionApi = HttpApi.make("permission")
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, PermissionNotFoundError],
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.reply",
@@ -2,7 +2,6 @@ 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"
@@ -54,7 +53,7 @@ export const ProjectApi = HttpApi.make("project")
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
error: [HttpApiError.BadRequest, ProjectNotFoundError],
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "project.update",
@@ -10,7 +10,7 @@ import {
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { PtyForbiddenError, PtyNotFoundError } from "../errors"
import { ApiNotFoundError } 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: PtyNotFoundError,
error: ApiNotFoundError,
}).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: [PtyNotFoundError, HttpApiError.BadRequest],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).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: PtyNotFoundError,
error: ApiNotFoundError,
}).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: [PtyForbiddenError, PtyNotFoundError],
error: [HttpApiError.Forbidden, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connectToken",
@@ -2,7 +2,6 @@ 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"
@@ -34,7 +33,7 @@ export const QuestionApi = HttpApi.make("question")
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, QuestionNotFoundError],
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reply",
@@ -46,7 +45,7 @@ export const QuestionApi = HttpApi.make("question")
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, QuestionNotFoundError],
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reject",
@@ -19,7 +19,7 @@ import {
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { ApiNotFoundError, 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, PermissionNotFoundError],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
@@ -3,7 +3,6 @@ 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"
@@ -108,7 +107,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
query: WorkspaceRoutingQuery,
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: [ApiWorkspaceWarpError, ApiVcsApplyError, ApiNotFoundError],
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.warp",
@@ -48,10 +48,8 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
return yield* vcs.status()
})
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 getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
@@ -2,7 +2,6 @@ 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) =>
@@ -21,80 +20,38 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
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}` })),
),
)
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)
})
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)
.pipe(
Effect.catchTag("MCP.NotFoundError", (error) =>
Effect.fail(
new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }),
),
),
)
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
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}` })),
),
)
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)
})
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)
.pipe(
Effect.catchTag("MCP.NotFoundError", (error) =>
Effect.fail(
new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }),
),
),
)
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
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}` }),
),
),
)
yield* mcp.disconnect(ctx.params.name)
return true
})
@@ -3,7 +3,6 @@ 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* () {
@@ -17,22 +16,11 @@ 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,
})
.pipe(
Effect.catchTag("Permission.NotFoundError", (error) =>
Effect.fail(
new PermissionNotFoundError({
requestID: String(error.requestID),
message: `Permission request not found: ${error.requestID}`,
}),
),
),
)
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
@@ -4,7 +4,6 @@ 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) =>
@@ -36,16 +35,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
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 yield* svc.update({ ...ctx.payload, projectID: ctx.params.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 } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
@@ -46,67 +46,33 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
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 info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* ApiError.notFound("Session not found")
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
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 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
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: 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}`,
}),
),
),
)
yield* pty.remove(ctx.params.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 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* new HttpApiError.Forbidden({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found")
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
})
@@ -131,11 +97,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
PtyPaths.connect,
Effect.gen(function* () {
const params = yield* HttpRouter.schemaPathParams(Params)
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 })
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const request = yield* HttpServerRequest.HttpServerRequest
@@ -185,14 +147,11 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
writeScoped(write(new Socket.CloseEvent(code, reason)))
},
}
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()
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) {
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
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,7 +3,6 @@ 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* () {
@@ -17,35 +16,15 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
params: { requestID: QuestionID }
payload: Question.Reply
}) {
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}`,
}),
),
),
)
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
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}`,
}),
),
),
)
yield* svc.reject(ctx.params.requestID)
return true
})
@@ -34,7 +34,6 @@ import {
SummarizePayload,
UpdatePayload,
} from "../groups/session"
import { PermissionNotFoundError } from "../errors"
import * as SessionError from "./session-errors"
const tryParseJson = (text: string) =>
@@ -357,16 +356,7 @@ 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 }).pipe(
Effect.catchTag("Permission.NotFoundError", (error) =>
Effect.fail(
new PermissionNotFoundError({
requestID: String(error.requestID),
message: `Permission request not found: ${error.requestID}`,
}),
),
),
)
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
return true
})
@@ -5,7 +5,6 @@ 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"
@@ -55,7 +54,6 @@ 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,7 +66,6 @@ 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,
@@ -372,6 +371,7 @@ 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
+1 -1
View File
@@ -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().pipe(Effect.orDie)
return yield* provider.defaultModel()
})
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
+5 -22
View File
@@ -57,26 +57,17 @@ function isSkillFrontmatter(data: unknown): data is { name: string; description?
)
}
export class InvalidError extends Schema.TaggedErrorClass<InvalidError>()("SkillInvalidError", {
export const InvalidError = NamedError.create("SkillInvalidError", {
path: Schema.String,
message: Schema.optional(Schema.String),
issues: Schema.optional(Schema.Array(Issue)),
}) {}
})
export class NameMismatchError extends Schema.TaggedErrorClass<NameMismatchError>()("SkillNameMismatchError", {
export const NameMismatchError = NamedError.create("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>
@@ -95,7 +86,6 @@ 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[]>
@@ -287,13 +277,6 @@ 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)
@@ -310,7 +293,7 @@ export const layer = Layer.effect(
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, require, all, dirs, available })
return Service.of({ get, all, dirs, available })
}),
)
+6 -3
View File
@@ -22,9 +22,12 @@ export const SkillTool = Tool.define(
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill
.require(params.name)
.pipe(Effect.catchTag("Skill.NotFoundError", (error) => Effect.die(new Error(error.message))))
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"}`)
}
yield* ctx.ask({
permission: "skill",
+5 -55
View File
@@ -1,6 +1,5 @@
import path from "path"
import { fileURLToPath } from "url"
import { Schema } from "effect"
import { Global } from "@opencode-ai/core/global"
type BaseReference = {
@@ -24,43 +23,6 @@ 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()
@@ -185,28 +147,16 @@ export function isRemoteRepositoryReference(reference: Reference): reference is
export function parseRemoteRepositoryReference(input: string) {
const reference = parseRepositoryReference(input)
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",
})
}
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")
return reference
}
export function validateRepositoryBranch(branch: string) {
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
throw new InvalidRepositoryBranchError({
branch,
message:
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
})
throw new Error(
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
)
}
}
@@ -2,17 +2,12 @@ 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"
@@ -238,56 +233,6 @@ 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.skip("renders sorted hierarchical file rows", async () => {
test("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 await captureSettledFrame(app)
return app.captureCharFrame()
} finally {
app.renderer.destroy()
}
@@ -167,16 +167,6 @@ 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()}>
@@ -1,111 +0,0 @@
/** @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,23 +14,19 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest)
return Layer.succeed(HttpClient.HttpClient, client)
}
function mockSpawner(
handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () =>
"",
) {
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const result = handler(std?.command ?? "", std?.args ?? [])
const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result
const output = handler(std?.command ?? "", std?.args ?? [])
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty,
stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty,
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
@@ -50,7 +46,7 @@ function jsonResponse(body: unknown) {
function testLayer(
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string },
spawnHandler?: (cmd: string, args: readonly string[]) => string,
) {
const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler)))
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess))
@@ -170,44 +166,4 @@ 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")
}),
)
})
})
+7 -13
View File
@@ -1,5 +1,5 @@
import { expect, mock, beforeEach } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
@@ -635,15 +635,12 @@ it.instance(
// ========================================================================
it.instance(
"connect() on nonexistent server fails with NotFoundError",
"connect() on nonexistent server does not throw",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
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" })
}
// Should not throw
yield* mcp.connect("nonexistent")
const status = yield* mcp.status()
expect(status["nonexistent"]).toBeUndefined()
}),
@@ -656,15 +653,12 @@ it.instance(
// ========================================================================
it.instance(
"disconnect() on nonexistent server fails with NotFoundError",
"disconnect() on nonexistent server does not throw",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
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" })
}
yield* mcp.disconnect("nonexistent")
// Should complete without error
}),
),
{ config: { mcp: {} } },
@@ -1057,14 +1057,10 @@ it.instance(
)
it.instance(
"reply - fails for unknown requestID",
"reply - does nothing for unknown requestID",
() =>
Effect.gen(function* () {
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" })
}
yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" })
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, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
@@ -481,7 +481,7 @@ describe("Project.update", () => {
}),
)
it.live("should fail when project not found", () =>
it.live("should throw error when project not found", () =>
Effect.gen(function* () {
const exit = yield* run((svc) =>
svc.update({
@@ -492,7 +492,9 @@ describe("Project.update", () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" })
expect(error instanceof Error ? error.message : String(error)).toContain(
"Project not found: nonexistent-project-id",
)
}
}),
)
@@ -351,16 +351,6 @@ 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* () {
+2 -50
View File
@@ -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 { Cause, Effect, Exit, Layer, Queue } from "effect"
import { Effect, Layer, Queue } from "effect"
import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
@@ -66,54 +66,6 @@ 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",
() =>
@@ -141,7 +93,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).pipe(Effect.ignore)
yield* pty.remove(info.id)
}),
{ git: true },
)
@@ -184,17 +184,11 @@ it.instance(
)
it.instance(
"reply - fails for unknown requestID",
"reply - does nothing for unknown requestID",
() =>
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" })
}
replyEffect({
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
}),
{ git: true },
)
@@ -259,18 +253,9 @@ it.instance(
{ 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 },
)
it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), {
git: true,
})
// multiple questions tests
@@ -177,15 +177,6 @@ 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()
@@ -233,7 +224,9 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
body: { reply: "once" },
}))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "permission reply should return true even when request is no longer pending")
}),
http.protected.get("/question", "question.list").json(200, array),
http.protected
.post("/question/{requestID}/reply", "question.reply.invalid")
@@ -250,14 +243,18 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
body: { answers: [["Yes"]] },
}))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "question reply should return true even when request is no longer pending")
}),
http.protected
.post("/question/{requestID}/reject", "question.reject")
.at((ctx) => ({
path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }),
headers: ctx.headers(),
}))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "question reject should return true even when request is no longer pending")
}),
http.protected
.get("/file", "file.list")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
@@ -338,37 +335,58 @@ 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(404, object, "status"),
.json(
400,
(body) => {
object(body)
check(typeof body.error === "string", "unsupported MCP OAuth response should include error")
},
"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(404, object, "status"),
.json(200, (body) => {
object(body)
check(body.success === true, "MCP auth removal should return success")
}),
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(404, object, "status"),
.json(
400,
(body) => {
object(body)
check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error")
},
"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: "code" },
body: { code: 1 },
}))
.json(404, object, "status"),
.status(400),
http.protected
.post("/mcp/{name}/connect", "mcp.connect")
.mutating()
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "missing MCP connect should remain a no-op success")
}),
http.protected
.post("/mcp/{name}/disconnect", "mcp.disconnect")
.mutating()
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "missing MCP disconnect should remain a no-op success")
}),
http.protected.get("/pty/shells", "pty.shells").json(200, array),
http.protected.get("/pty", "pty.list").json(200, array),
http.protected
@@ -413,7 +431,9 @@ const scenarios: Scenario[] = [
.delete("/pty/{ptyID}", "pty.remove")
.mutating()
.at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "PTY remove should return true")
}),
http.protected
.get("/pty/{ptyID}/connect", "pty.connect")
.at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
@@ -1229,7 +1249,9 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
body: { response: "once" },
}))
.json(404, object, "status"),
.json(200, (body) => {
check(body === true, "deprecated permission response should return true")
}),
http.protected
.post("/session/{sessionID}/share", "session.share")
.mutating()
@@ -8,9 +8,6 @@ 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"
@@ -154,82 +151,6 @@ 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,36 +192,4 @@ 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,31 +112,6 @@ 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 () => {
@@ -146,63 +121,6 @@ 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,63 +157,4 @@ 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,24 +821,19 @@ describe("session HttpApi", () => {
}),
).toMatchObject({ id: session.id })
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}`,
})
expect(
yield* requestJson<boolean>(
pathFor(SessionPaths.permissions, {
sessionID: session.id,
permissionID: String(PermissionID.ascending()),
}),
{
method: "POST",
headers,
body: JSON.stringify({ response: "once" }),
},
),
).toBe(true)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
@@ -5,7 +5,6 @@ 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"
@@ -251,26 +250,6 @@ 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,11 +47,6 @@ 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,37 +289,6 @@ 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 -41
View File
@@ -1,5 +1,5 @@
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Effect, Exit, Layer } from "effect"
import { Effect, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
@@ -90,44 +90,4 @@ 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,9 +3,6 @@ import path from "path"
import { pathToFileURL } from "url"
import { Global } from "@opencode-ai/core/global"
import {
InvalidRepositoryBranchError,
InvalidRepositoryReferenceError,
UnsupportedLocalRepositoryError,
isFileRepositoryReference,
isRemoteRepositoryReference,
parseRemoteRepositoryReference,
@@ -64,14 +61,6 @@ 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", () => {
@@ -88,6 +77,5 @@ 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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.15.9",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.15.9",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"scripts": {
-2
View File
@@ -1793,7 +1793,6 @@ export class Vcs extends HeyApiClient {
directory?: string
workspace?: string
mode: "git" | "branch"
context?: number
},
options?: Options<never, ThrowOnError>,
) {
@@ -1805,7 +1804,6 @@ export class Vcs extends HeyApiClient {
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "mode" },
{ in: "query", key: "context" },
],
},
],
+33 -86
View File
@@ -1136,7 +1136,6 @@ export type McpOAuthConfig = {
clientId?: string
clientSecret?: string
scope?: string
callbackPort?: number
redirectUri?: string
}
@@ -1694,39 +1693,15 @@ export type McpUnsupportedOAuthError = {
error: string
}
export type McpServerNotFoundError = {
_tag: "McpServerNotFoundError"
name: string
message: string
export type NotFoundError = {
name: "NotFoundError"
data: {
message: string
}
}
export type ProjectNotFoundError = {
_tag: "ProjectNotFoundError"
projectID: string
message: string
}
export type PtyNotFoundError = {
_tag: "PtyNotFoundError"
ptyID: string
message: string
}
export type PtyForbiddenError = {
_tag: "PtyForbiddenError"
message: string
}
export type QuestionNotFoundError = {
_tag: "QuestionNotFoundError"
requestID: string
message: string
}
export type PermissionNotFoundError = {
_tag: "PermissionNotFoundError"
requestID: string
message: string
export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type ProviderAuthMethod = {
@@ -1783,13 +1758,6 @@ export type ProviderAuthError1 = {
}
}
export type NotFoundError = {
name: "NotFoundError"
data: {
message: string
}
}
export type TextPartInput = {
id?: string
type: "text"
@@ -1963,10 +1931,6 @@ export type WorkspaceWarpError = {
}
}
export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type SyncEventMessageUpdated = {
type: "sync"
name: "message.updated.1"
@@ -4837,7 +4801,6 @@ export type VcsDiffData = {
directory?: string
workspace?: string
mode: "git" | "branch"
context?: number
}
url: "/vcs/diff"
}
@@ -5146,9 +5109,9 @@ export type McpAuthRemoveErrors = {
*/
400: BadRequestError
/**
* McpServerNotFoundError
* Not found
*/
404: McpServerNotFoundError
404: NotFoundError
}
export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors]
@@ -5182,9 +5145,9 @@ export type McpAuthStartErrors = {
*/
400: McpUnsupportedOAuthError | InvalidRequestError
/**
* McpServerNotFoundError
* Not found
*/
404: McpServerNotFoundError
404: NotFoundError
}
export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors]
@@ -5221,9 +5184,9 @@ export type McpAuthCallbackErrors = {
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* McpServerNotFoundError
* Not found
*/
404: McpServerNotFoundError
404: NotFoundError
}
export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors]
@@ -5255,9 +5218,9 @@ export type McpAuthAuthenticateErrors = {
*/
400: McpUnsupportedOAuthError | InvalidRequestError
/**
* McpServerNotFoundError
* Not found
*/
404: McpServerNotFoundError
404: NotFoundError
}
export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors]
@@ -5288,10 +5251,6 @@ export type McpConnectErrors = {
* Bad request
*/
400: BadRequestError
/**
* McpServerNotFoundError
*/
404: McpServerNotFoundError
}
export type McpConnectError = McpConnectErrors[keyof McpConnectErrors]
@@ -5322,10 +5281,6 @@ export type McpDisconnectErrors = {
* Bad request
*/
400: BadRequestError
/**
* McpServerNotFoundError
*/
404: McpServerNotFoundError
}
export type McpDisconnectError = McpDisconnectErrors[keyof McpDisconnectErrors]
@@ -5454,9 +5409,9 @@ export type ProjectUpdateErrors = {
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* ProjectNotFoundError
* Not found
*/
404: ProjectNotFoundError
404: NotFoundError
}
export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors]
@@ -5584,9 +5539,9 @@ export type PtyRemoveErrors = {
*/
400: BadRequestError
/**
* PtyNotFoundError
* NotFoundError
*/
404: PtyNotFoundError
404: NotFoundError
}
export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors]
@@ -5618,9 +5573,9 @@ export type PtyGetErrors = {
*/
400: BadRequestError
/**
* PtyNotFoundError
* NotFoundError
*/
404: PtyNotFoundError
404: NotFoundError
}
export type PtyGetError = PtyGetErrors[keyof PtyGetErrors]
@@ -5657,10 +5612,6 @@ export type PtyUpdateErrors = {
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* PtyNotFoundError
*/
404: PtyNotFoundError
}
export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors]
@@ -5692,13 +5643,13 @@ export type PtyConnectTokenErrors = {
*/
400: BadRequestError
/**
* PtyForbiddenError
* Forbidden
*/
403: PtyForbiddenError
403: EffectHttpApiErrorForbidden
/**
* PtyNotFoundError
* NotFoundError
*/
404: PtyNotFoundError
404: NotFoundError
}
export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors]
@@ -5766,9 +5717,9 @@ export type QuestionReplyErrors = {
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* QuestionNotFoundError
* Not found
*/
404: QuestionNotFoundError
404: NotFoundError
}
export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]
@@ -5800,9 +5751,9 @@ export type QuestionRejectErrors = {
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* QuestionNotFoundError
* Not found
*/
404: QuestionNotFoundError
404: NotFoundError
}
export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]
@@ -5865,9 +5816,9 @@ export type PermissionReplyErrors = {
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* PermissionNotFoundError
* Not found
*/
404: PermissionNotFoundError
404: NotFoundError
}
export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors]
@@ -6965,9 +6916,9 @@ export type PermissionRespondErrors = {
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* NotFoundError | PermissionNotFoundError
* NotFoundError
*/
404: NotFoundError | PermissionNotFoundError
404: NotFoundError
}
export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors]
@@ -8170,10 +8121,6 @@ export type ExperimentalWorkspaceWarpErrors = {
* WorkspaceWarpError | VcsApplyError | InvalidRequestError
*/
400: WorkspaceWarpError | VcsApplyError | InvalidRequestError
/**
* NotFoundError
*/
404: NotFoundError
}
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
+42 -202
View File
@@ -2288,15 +2288,6 @@
"enum": ["git", "branch"]
},
"required": true
},
{
"name": "context",
"in": "query",
"schema": {
"type": "integer",
"minimum": 0
},
"required": false
}
],
"responses": {
@@ -2997,11 +2988,11 @@
}
},
"404": {
"description": "McpServerNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpServerNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -3076,11 +3067,11 @@
}
},
"404": {
"description": "McpServerNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpServerNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -3155,11 +3146,11 @@
}
},
"404": {
"description": "McpServerNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpServerNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -3250,11 +3241,11 @@
}
},
"404": {
"description": "McpServerNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpServerNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -3321,16 +3312,6 @@
}
}
}
},
"404": {
"description": "McpServerNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpServerNotFoundError"
}
}
}
}
},
"description": "Connect an MCP server.",
@@ -3393,16 +3374,6 @@
}
}
}
},
"404": {
"description": "McpServerNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpServerNotFoundError"
}
}
}
}
},
"description": "Disconnect an MCP server.",
@@ -3639,11 +3610,11 @@
}
},
"404": {
"description": "ProjectNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -3973,11 +3944,11 @@
}
},
"404": {
"description": "PtyNotFoundError",
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PtyNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -4049,16 +4020,6 @@
}
}
}
},
"404": {
"description": "PtyNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PtyNotFoundError"
}
}
}
}
},
"description": "Update properties of an existing pseudo-terminal (PTY) session.",
@@ -4153,11 +4114,11 @@
}
},
"404": {
"description": "PtyNotFoundError",
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PtyNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -4238,21 +4199,21 @@
}
},
"403": {
"description": "PtyForbiddenError",
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PtyForbiddenError"
"$ref": "#/components/schemas/effect_HttpApiError_Forbidden"
}
}
}
},
"404": {
"description": "PtyNotFoundError",
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PtyNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -4387,11 +4348,11 @@
}
},
"404": {
"description": "QuestionNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/QuestionNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -4488,11 +4449,11 @@
}
},
"404": {
"description": "QuestionNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/QuestionNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -4627,11 +4588,11 @@
}
},
"404": {
"description": "PermissionNotFoundError",
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PermissionNotFoundError"
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -7501,18 +7462,11 @@
}
},
"404": {
"description": "NotFoundError | PermissionNotFoundError",
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/NotFoundError"
},
{
"$ref": "#/components/schemas/PermissionNotFoundError"
}
]
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -10335,16 +10289,6 @@
}
}
}
},
"404": {
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Move a session's sync history into the target workspace, or detach it to the local project.",
@@ -13812,11 +13756,6 @@
"scope": {
"type": "string"
},
"callbackPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"redirectUri": {
"type": "string"
}
@@ -15431,103 +15370,34 @@
"required": ["error"],
"additionalProperties": false
},
"McpServerNotFoundError": {
"NotFoundError": {
"type": "object",
"required": ["name", "data"],
"properties": {
"_tag": {
"type": "string",
"enum": ["McpServerNotFoundError"]
},
"name": {
"type": "string"
"type": "string",
"enum": ["NotFoundError"]
},
"message": {
"type": "string"
"data": {
"type": "object",
"required": ["message"],
"properties": {
"message": {
"type": "string"
}
}
}
},
"required": ["_tag", "name", "message"],
"additionalProperties": false
}
},
"ProjectNotFoundError": {
"effect_HttpApiError_Forbidden": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["ProjectNotFoundError"]
},
"projectID": {
"type": "string"
},
"message": {
"type": "string"
"enum": ["Forbidden"]
}
},
"required": ["_tag", "projectID", "message"],
"additionalProperties": false
},
"PtyNotFoundError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PtyNotFoundError"]
},
"ptyID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "ptyID", "message"],
"additionalProperties": false
},
"PtyForbiddenError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PtyForbiddenError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"QuestionNotFoundError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["QuestionNotFoundError"]
},
"requestID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "requestID", "message"],
"additionalProperties": false
},
"PermissionNotFoundError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PermissionNotFoundError"]
},
"requestID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "requestID", "message"],
"required": ["_tag"],
"additionalProperties": false
},
"ProviderAuthMethod": {
@@ -15693,25 +15563,6 @@
"required": ["name", "data"],
"additionalProperties": false
},
"NotFoundError": {
"type": "object",
"required": ["name", "data"],
"properties": {
"name": {
"type": "string",
"enum": ["NotFoundError"]
},
"data": {
"type": "object",
"required": ["message"],
"properties": {
"message": {
"type": "string"
}
}
}
}
},
"TextPartInput": {
"type": "object",
"properties": {
@@ -16234,17 +16085,6 @@
"required": ["name", "data"],
"additionalProperties": false
},
"effect_HttpApiError_Forbidden": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["Forbidden"]
}
},
"required": ["_tag"],
"additionalProperties": false
},
"SyncEventMessageUpdated": {
"type": "object",
"properties": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.15.9",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.15.9",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.15.9",
"version": "1.15.7",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+7
View File
@@ -15,8 +15,11 @@ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
type Issue = {
number: number
updated_at: string
author_association: string
}
const teamAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"])
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
@@ -63,6 +66,10 @@ async function main() {
for (const i of all) {
const updated = new Date(i.updated_at)
if (updated < cutoff) {
if (teamAssociations.has(i.author_association)) {
console.log(`Skipping #${i.number}: author association is ${i.author_association}`)
continue
}
stale.push(i.number)
} else {
console.log(`\nFound fresh issue #${i.number}, stopping`)

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