From 854c53553637882eb77da853d6e0097903140ce9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 22 May 2026 09:50:30 -0400 Subject: [PATCH 01/39] fix(tui): enable diff viewer by default --- packages/opencode/src/cli/cmd/tui/plugin/internal.ts | 4 ++-- packages/opencode/src/effect/runtime-flags.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 2ba8e745b59e..7fe348ad0824 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -21,7 +21,7 @@ export type InternalTuiPlugin = Omit & { } export function internalTuiPlugins( - flags: Pick, + flags: Pick, ): InternalTuiPlugin[] { return [ HomeFooter, @@ -35,7 +35,7 @@ export function internalTuiPlugins( Notifications, PluginManager, WhichKey, - ...(flags.diffViewer ? [DiffViewer] : []), + DiffViewer, ...(flags.experimentalEventSystem ? [SessionV2Debug] : []), ] } diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 55b453c011f4..8d9d1fd97a8c 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -15,7 +15,6 @@ export class Service extends ConfigService.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"), From d92b8d8009daf884c66b14d915167733fc600bbf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 13:52:40 +0000 Subject: [PATCH 02/39] chore: generate --- packages/opencode/src/cli/cmd/tui/plugin/internal.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 7fe348ad0824..d85b38c569fa 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -20,9 +20,7 @@ export type InternalTuiPlugin = Omit & { enabled?: boolean } -export function internalTuiPlugins( - flags: Pick, -): InternalTuiPlugin[] { +export function internalTuiPlugins(flags: Pick): InternalTuiPlugin[] { return [ HomeFooter, HomeTips, From 5cf597d583c89505aff5da16fe271265ef1984e8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 21:00:20 +0530 Subject: [PATCH 03/39] fix(httpapi): return pty error bodies (#28838) --- .../server/routes/instance/httpapi/errors.ts | 17 ++++++ .../routes/instance/httpapi/groups/pty.ts | 10 ++-- .../routes/instance/httpapi/handlers/pty.ts | 27 +++++++-- .../server/routes/instance/httpapi/public.ts | 1 - .../test/server/httpapi-exercise/index.ts | 4 +- .../opencode/test/server/httpapi-pty.test.ts | 56 +++++++++++++++++++ .../server/httpapi-public-openapi.test.ts | 18 ++++++ 7 files changed, 119 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts index d4fc232e3d85..c1a0691c7f70 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/errors.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -149,6 +149,23 @@ export class McpServerNotFoundError extends Schema.TaggedErrorClass()( + "PtyNotFoundError", + { + ptyID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class PtyForbiddenError extends Schema.TaggedErrorClass()( + "PtyForbiddenError", + { + message: Schema.String, + }, + { httpApiStatus: 403 }, +) {} + export class ApiNotFoundError extends Schema.ErrorClass("NotFoundError")( { name: Schema.Literal("NotFoundError"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 1391d2a919ae..3adc4e5c3605 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -10,7 +10,7 @@ import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields, } from "../middleware/workspace-routing" -import { ApiNotFoundError } from "../errors" +import { PtyForbiddenError, PtyNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" @@ -76,7 +76,7 @@ export const PtyApi = HttpApi.make("pty") params: { ptyID: PtyID }, query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), - error: ApiNotFoundError, + error: PtyNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "pty.get", @@ -89,7 +89,7 @@ export const PtyApi = HttpApi.make("pty") query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [PtyNotFoundError, HttpApiError.BadRequest], }).annotateMerge( OpenApi.annotations({ identifier: "pty.update", @@ -101,7 +101,7 @@ export const PtyApi = HttpApi.make("pty") params: { ptyID: PtyID }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), - error: ApiNotFoundError, + error: PtyNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "pty.remove", @@ -113,7 +113,7 @@ export const PtyApi = HttpApi.make("pty") params: { ptyID: PtyID }, query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), - error: [HttpApiError.Forbidden, ApiNotFoundError], + error: [PtyForbiddenError, PtyNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connectToken", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index f4d6adb93516..bcf6aef80460 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -12,7 +12,7 @@ import { } from "@/server/shared/pty-ticket" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" import * as ApiError from "../errors" @@ -47,7 +47,11 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { const info = yield* pty.get(ctx.params.ptyID) - if (!info) return yield* ApiError.notFound("Session not found") + if (!info) + return yield* new ApiError.PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }) return info }) @@ -59,11 +63,20 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler ...ctx.payload, size: ctx.payload.size ? { ...ctx.payload.size } : undefined, }) - if (!info) return yield* ApiError.notFound("Session not found") + if (!info) + return yield* new ApiError.PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }) return info }) const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { + if (!(yield* pty.get(ctx.params.ptyID))) + return yield* new ApiError.PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }) yield* pty.remove(ctx.params.ptyID) return true }) @@ -71,8 +84,12 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) { const request = yield* HttpServerRequest.HttpServerRequest if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) - return yield* new HttpApiError.Forbidden({}) - if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found") + return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" }) + if (!(yield* pty.get(ctx.params.ptyID))) + return yield* new ApiError.PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }) return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 7eb449716163..91a50c263a66 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -371,7 +371,6 @@ function referencesComponent(input: unknown, name: string): boolean { function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) { if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"] - if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"] if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return const response = operation.responses?.["200"]?.content?.["application/json"] if (!response) return diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 0c834e91b776..5c822c110928 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -404,9 +404,7 @@ const scenarios: Scenario[] = [ .delete("/pty/{ptyID}", "pty.remove") .mutating() .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "PTY remove should return true") - }), + .json(404, object, "status"), http.protected .get("/pty/{ptyID}/connect", "pty.connect") .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 0f10dbd3a7d6..d1e4660f2e31 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -112,6 +112,31 @@ describe("pty HttpApi bridge", () => { const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) expect(missing.status).toBe(404) + expect(await missing.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: info.id, + message: `PTY session not found: ${info.id}`, + }) + + const missingUpdate = await app().request(PtyPaths.update.replace(":ptyID", info.id), { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ title: "missing" }), + }) + expect(missingUpdate.status).toBe(404) + expect(await missingUpdate.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: info.id, + message: `PTY session not found: ${info.id}`, + }) + + const missingRemove = await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers }) + expect(missingRemove.status).toBe(404) + expect(await missingRemove.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: info.id, + message: `PTY session not found: ${info.id}`, + }) }) test("returns 404 for missing PTY websocket before upgrade", async () => { @@ -121,6 +146,37 @@ describe("pty HttpApi bridge", () => { }) expect(response.status).toBe(404) }) + + 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", () => diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index afe3dd3f0731..c8069bd312e4 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -190,4 +190,22 @@ describe("PublicApi OpenAPI v2 errors", () => { ) } }) + + 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", + ) + }) }) From 00038027c825f1e837a89db9134d0cabed781828 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 15:31:46 +0000 Subject: [PATCH 04/39] chore: generate --- .../opencode/test/server/httpapi-pty.test.ts | 1 - packages/sdk/js/src/v2/gen/types.gen.ts | 45 ++++++---- packages/sdk/openapi.json | 89 ++++++++++++++----- 3 files changed, 95 insertions(+), 40 deletions(-) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index d1e4660f2e31..e28f38082ae3 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -176,7 +176,6 @@ describe("pty HttpApi bridge", () => { message: `PTY session not found: ${missingID}`, }) }) - ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( "serves PTY websocket output and input through Effect routes", () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ecc7f3aede28..3c3668d35e3a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1700,15 +1700,15 @@ export type McpServerNotFoundError = { message: string } -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } +export type PtyNotFoundError = { + _tag: "PtyNotFoundError" + ptyID: string + message: string } -export type EffectHttpApiErrorForbidden = { - _tag: "Forbidden" +export type PtyForbiddenError = { + _tag: "PtyForbiddenError" + message: string } export type QuestionNotFoundError = { @@ -1777,6 +1777,13 @@ export type ProviderAuthError1 = { } } +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + export type TextPartInput = { id?: string type: "text" @@ -1950,6 +1957,10 @@ export type WorkspaceWarpError = { } } +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -5566,9 +5577,9 @@ export type PtyRemoveErrors = { */ 400: BadRequestError /** - * NotFoundError + * PtyNotFoundError */ - 404: NotFoundError + 404: PtyNotFoundError } export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] @@ -5600,9 +5611,9 @@ export type PtyGetErrors = { */ 400: BadRequestError /** - * NotFoundError + * PtyNotFoundError */ - 404: NotFoundError + 404: PtyNotFoundError } export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] @@ -5639,6 +5650,10 @@ export type PtyUpdateErrors = { * BadRequest | InvalidRequestError */ 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError } export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] @@ -5670,13 +5685,13 @@ export type PtyConnectTokenErrors = { */ 400: BadRequestError /** - * Forbidden + * PtyForbiddenError */ - 403: EffectHttpApiErrorForbidden + 403: PtyForbiddenError /** - * NotFoundError + * PtyNotFoundError */ - 404: NotFoundError + 404: PtyNotFoundError } export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d61a5caca37a..20650cbd1c20 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3964,11 +3964,11 @@ } }, "404": { - "description": "NotFoundError", + "description": "PtyNotFoundError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/PtyNotFoundError" } } } @@ -4040,6 +4040,16 @@ } } } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } } }, "description": "Update properties of an existing pseudo-terminal (PTY) session.", @@ -4134,11 +4144,11 @@ } }, "404": { - "description": "NotFoundError", + "description": "PtyNotFoundError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/PtyNotFoundError" } } } @@ -4219,21 +4229,21 @@ } }, "403": { - "description": "Forbidden", + "description": "PtyForbiddenError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + "$ref": "#/components/schemas/PtyForbiddenError" } } } }, "404": { - "description": "NotFoundError", + "description": "PtyNotFoundError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/PtyNotFoundError" } } } @@ -15419,34 +15429,35 @@ "required": ["_tag", "name", "message"], "additionalProperties": false }, - "NotFoundError": { + "PtyNotFoundError": { "type": "object", - "required": ["name", "data"], "properties": { - "name": { + "_tag": { "type": "string", - "enum": ["NotFoundError"] + "enum": ["PtyNotFoundError"] }, - "data": { - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string" - } - } + "ptyID": { + "type": "string" + }, + "message": { + "type": "string" } - } + }, + "required": ["_tag", "ptyID", "message"], + "additionalProperties": false }, - "effect_HttpApiError_Forbidden": { + "PtyForbiddenError": { "type": "object", "properties": { "_tag": { "type": "string", - "enum": ["Forbidden"] + "enum": ["PtyForbiddenError"] + }, + "message": { + "type": "string" } }, - "required": ["_tag"], + "required": ["_tag", "message"], "additionalProperties": false }, "QuestionNotFoundError": { @@ -15646,6 +15657,25 @@ "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": { @@ -16168,6 +16198,17 @@ "required": ["name", "data"], "additionalProperties": false }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, "SyncEventMessageUpdated": { "type": "object", "properties": { From 8596967415ecc406a772aab26607592f1b3df3c7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 22 May 2026 11:14:52 -0500 Subject: [PATCH 05/39] ci: "fix: exempt team members from compliance cleanup" (#28865) --- .github/workflows/compliance-close.yml | 14 -------------- .github/workflows/duplicate-issues.yml | 4 ++-- .github/workflows/pr-management.yml | 15 ++++++--------- .github/workflows/pr-standards.yml | 24 ++++++++++++++++++------ script/github/close-issues.ts | 7 ------- script/github/close-prs.ts | 23 +++++------------------ 6 files changed, 31 insertions(+), 56 deletions(-) diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index 297fe786af65..14e68701e57d 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -34,25 +34,11 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; - const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; - if (teamAssociations.includes(item.author_association)) { - core.info(`Skipping ${kind} #${item.number}: author association is ${item.author_association}`); - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - name: 'needs:compliance', - }); - } catch (e) {} - continue; - } - const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 3f0ce976e1ce..4648a2d0c3d3 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -6,7 +6,7 @@ on: jobs: check-duplicates: - if: github.event.action == 'opened' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) + if: github.event.action == 'opened' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read @@ -118,7 +118,7 @@ jobs: Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." recheck-compliance: - if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) + if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index 5d526ceaf218..b6aa4e589d89 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -11,25 +11,22 @@ jobs: contents: read pull-requests: write steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + - name: Check team membership id: team-check run: | LOGIN="${{ github.event.pull_request.user.login }}" - ASSOCIATION="${{ github.event.pull_request.author_association }}" - if [ "$LOGIN" = "opencode-agent[bot]" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then + if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then echo "is_team=true" >> "$GITHUB_OUTPUT" echo "Skipping: $LOGIN is a team member or bot" else echo "is_team=false" >> "$GITHUB_OUTPUT" fi - - name: Checkout repository - if: steps.team-check.outputs.is_team != 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 1 - ref: ${{ github.event.pull_request.base.sha }} - - name: Setup Bun if: steps.team-check.outputs.is_team != 'true' uses: ./.github/actions/setup-bun diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 6e8bfe25c6ad..06838089d354 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -28,9 +28,15 @@ jobs: // Check if author is a team member or bot if (login === 'opencode-agent[bot]') return; - const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; - if (teamAssociations.includes(pr.author_association)) { - console.log(`Skipping: ${login} has author association ${pr.author_association}`); + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev' + }); + const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); + if (members.includes(login)) { + console.log(`Skipping: ${login} is a team member`); return; } @@ -169,9 +175,15 @@ jobs: // Check if author is a team member or bot if (login === 'opencode-agent[bot]') return; - const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; - if (teamAssociations.includes(pr.author_association)) { - console.log(`Skipping: ${login} has author association ${pr.author_association}`); + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev' + }); + const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); + if (members.includes(login)) { + console.log(`Skipping: ${login} is a team member`); return; } diff --git a/script/github/close-issues.ts b/script/github/close-issues.ts index 2dbf349eb95d..9e1f597951f1 100755 --- a/script/github/close-issues.ts +++ b/script/github/close-issues.ts @@ -15,11 +15,8 @@ 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", @@ -66,10 +63,6 @@ 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`) diff --git a/script/github/close-prs.ts b/script/github/close-prs.ts index d74cfa0cc1ce..0dd8953d90fb 100755 --- a/script/github/close-prs.ts +++ b/script/github/close-prs.ts @@ -69,7 +69,6 @@ const maxClose = const sleepMs = requireNonNegativeInteger("sleep-ms", values["sleep-ms"]) const printLimit = requireNonNegativeInteger("print-limit", values["print-limit"]) const cutoff = subtractMonths(new Date(), ageMonths) -const teamAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]) const headers = { Authorization: `Bearer ${token}`, @@ -83,7 +82,6 @@ type PullRequest = { title: string url: string createdAt: string - authorAssociation: string reactionGroups: Array<{ content: string users: { @@ -147,25 +145,19 @@ async function main() { console.log(`Threshold: fewer than ${threshold} positive reactions`) const prs = await fetchOpenPullRequests() - const scored = prs.map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) })) - const recentCount = scored.filter((pr) => new Date(pr.createdAt) >= cutoff).length - const teamCount = scored.filter((pr) => isTeamMember(pr)).length - const matching = scored.filter( - (pr) => !isTeamMember(pr) && new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold, - ) + const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length + const matching = prs + .map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) })) + .filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold) const candidates = matching.filter((pr) => !hasPriorCleanup(pr)) const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose) console.log(`Fetched ${prs.length} open PRs`) console.log(`Matching cleanup criteria: ${matching.length}`) console.log(`Skipped previously cleaned PRs: ${matching.length - candidates.length}`) - console.log(`Team member PRs untouched: ${teamCount}`) console.log(`Recent PRs untouched: ${recentCount}`) console.log( - `Older PRs with at least ${threshold} positive reactions untouched: ${ - scored.filter((pr) => !isTeamMember(pr) && new Date(pr.createdAt) < cutoff && pr.positiveReactions >= threshold) - .length - }`, + `Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - matching.length - recentCount}`, ) if (selected.length === 0) return @@ -213,7 +205,6 @@ async function fetchOpenPullRequests() { title url createdAt - authorAssociation reactionGroups { content users { @@ -344,10 +335,6 @@ function hasPriorCleanup(pr: PullRequest) { return pr.labels.nodes.some((label) => label.name === cleanupLabel) } -function isTeamMember(pr: PullRequest) { - return teamAssociations.has(pr.authorAssociation) -} - function requireRepo(value: string | undefined) { if (!value) throw new Error("repo is required") const [owner, name] = value.split("/") From 59e486a91772fcc79223c9d1eae7b7c0b3d04898 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 21:47:13 +0530 Subject: [PATCH 06/39] fix(tui): restore question prompt key handling (#28835) --- .../src/cli/cmd/tui/routes/session/question.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index c484c62f7548..4d7b520430a3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,5 +1,5 @@ import { createStore } from "solid-js/store" -import { createMemo, createSignal, For, Show } from "solid-js" +import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { useRenderer } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { selectedForeground, tint, useTheme } from "../../context/theme" @@ -7,13 +7,16 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useTuiConfig } from "../../context/tui-config" -import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" +import { useBindings, useOpencodeModeStack } from "../../keymap" + +const QUESTION_MODE = "question" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() const tuiConfig = useTuiConfig() + const modeStack = useOpencodeModeStack() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -119,8 +122,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { pick(opt.label) } + onMount(() => { + const popMode = modeStack.push(QUESTION_MODE) + onCleanup(popMode) + }) + useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + mode: QUESTION_MODE, enabled: store.editing && !confirm(), commands: [ { @@ -201,7 +209,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const max = Math.min(total, 9) return { - mode: OPENCODE_BASE_MODE, + mode: QUESTION_MODE, enabled: !store.editing, commands: [ { From 700d012025bfed2c49d5c90c9370c7fdbea894d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 22 May 2026 12:23:23 -0400 Subject: [PATCH 07/39] fix(llm): emit structured input_image content for tool-result media in OpenAI Responses (#28754) --- .../llm/src/protocols/openai-responses.ts | 44 ++++++++- ...i-responses-gpt-5-5-image-tool-result.json | 42 +++++++++ .../llm/test/provider/golden.recorded.test.ts | 1 + .../test/provider/openai-responses.test.ts | 91 +++++++++++++++++++ packages/llm/test/recorded-scenarios.ts | 48 ++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e2eac85ec2cf..31d4a471f41b 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -14,6 +14,8 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, + type ToolResultContentPart, + type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" @@ -55,6 +57,19 @@ const OpenAIResponsesReasoningItem = Schema.Struct({ encrypted_content: optionalNull(Schema.String), }) +// `function_call_output.output` accepts either a plain string or an ordered +// array of content items so tools can return images in addition to text. +// https://platform.openai.com/docs/api-reference/responses/object +const OpenAIResponsesFunctionCallOutputContent = Schema.Union([ + OpenAIResponsesInputText, + OpenAIResponsesInputImage, +]) + +const OpenAIResponsesFunctionCallOutput = Schema.Union([ + Schema.String, + Schema.Array(OpenAIResponsesFunctionCallOutputContent), +]) + const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), @@ -69,7 +84,7 @@ const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ type: Schema.tag("function_call_output"), call_id: Schema.String, - output: Schema.String, + output: OpenAIResponsesFunctionCallOutput, }), ]) type OpenAIResponsesInputItem = Schema.Schema.Type @@ -250,6 +265,27 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) }) +// Tool results may carry structured text/images. Keep media as provider-native +// content instead of JSON-stringifying base64 into a prompt string. +const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* ( + item: ToolResultContentPart, +) { + if (item.type === "text") return { type: "input_text" as const, text: item.text } + if (item.mediaType.startsWith("image/")) + return { + type: "input_image" as const, + image_url: ProviderShared.mediaDataUrl(item), + } + return yield* invalid(`OpenAI Responses tool-result media content only supports images, got ${item.mediaType}`) +}) + +const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) { + // Text/json/error results are encoded as a plain string for backward + // compatibility with existing cassettes and provider expectations. + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + return yield* Effect.forEach(part.result.value, lowerToolResultContentItem) +}) + const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { const system: OpenAIResponsesInputItem[] = request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] @@ -298,7 +334,11 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["tool-result"])) return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"]) - input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) }) + input.push({ + type: "function_call_output", + call_id: part.id, + output: yield* lowerToolResultOutput(part), + }) } } diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json new file mode 100644 index 000000000000..1891d0c7e079 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-image-tool-result", + "recordedAt": "2026-05-22T01:56:45.892Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "media", + "image", + "vision", + "tool", + "tool-result", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,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\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_043c0deed393f65a016a0fb7dc846881a2b11e4f8691b2a9cd\",\"object\":\"response\",\"created_at\":1779415004,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_043c0deed393f65a016a0fb7dc846881a2b11e4f8691b2a9cd\",\"object\":\"response\",\"created_at\":1779415004,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"j\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"E7rxHk2uXGfQ1ld\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"igg\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"wHEj3PrFtMkLC\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ling\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"z3tGrLKAfhd7\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" restroom\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"bSkCu75\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" prison\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"RGOU3OGVC\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":9,\"text\":\"jiggling restroom prison\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"},\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_043c0deed393f65a016a0fb7dc846881a2b11e4f8691b2a9cd\",\"object\":\"response\",\"created_at\":1779415004,\"status\":\"completed\",\"background\":false,\"completed_at\":1779415005,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":227,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":233},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\n" + } + } + ] +} diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 331a393fe225..59f12b032f17 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -87,6 +87,7 @@ describeRecordedGoldenScenarios([ { id: "reasoning-continuation", temperature: false }, { id: "tool-call", temperature: false }, { id: "tool-loop", temperature: false }, + { id: "image-tool-result", temperature: false, maxTokens: 40 }, ], }, { diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 845a634128b7..57cc7789a60c 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -26,6 +26,19 @@ const request = LLM.request({ const configEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) +type OpenAIToolOutput = Extract< + OpenAIResponses.OpenAIResponsesBody["input"][number], + { readonly type: "function_call_output" } +> + +const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => { + const output = body.input.find( + (item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output", + ) + expect(output).toBeDefined() + return output! +} + describe("OpenAI Responses route", () => { it.effect("prepares OpenAI Responses target", () => Effect.gen(function* () { @@ -248,6 +261,84 @@ describe("OpenAI Responses route", () => { }), ) + // Regression: screenshot/read tool results must stay structured so base64 + // image data is not JSON-stringified into `function_call_output.output`. + it.effect("lowers image tool-result content as structured input_image items", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + 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( + 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( diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 042ddec3c3c7..294bd822a85d 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -317,6 +317,49 @@ const runImageScenario = (context: GoldenScenarioContext) => ]) }) +// Reproduces a tool-result image round trip: a tool returns image bytes, and +// the next model turn must receive provider-native image content instead of a +// JSON-stringified base64 blob. +const screenshotToolName = "read_screenshot" +const runImageToolResultScenario = (context: GoldenScenarioContext) => + Effect.gen(function* () { + const image = yield* restroomImage() + const response = yield* generate( + LLM.request({ + id: `${context.id}_image_tool_result`, + model: context.model, + system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.", + cache: "none", + generation: generation(context, context.maxTokens ?? 40), + messages: [ + Message.user("Use the read_screenshot tool, then reply with the words shown."), + Message.assistant([ + { type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }, + ]), + Message.tool({ + id: "call_screenshot_1", + name: screenshotToolName, + resultType: "content", + result: [ + { type: "text", text: "Image read successfully" }, + { type: "media", mediaType: "image/png", data: image }, + ], + }), + ], + tools: [ + ToolDefinition.make({ + name: screenshotToolName, + description: "Capture a screenshot of the current screen.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }), + ], + }), + ) + + expectFinish(response.events, "stop") + expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT) + }) + const runReasoningScenario = (context: GoldenScenarioContext) => runGeneratedConversation(context, [ user("Think briefly, then reply exactly with: Hello!"), @@ -359,6 +402,11 @@ const goldenScenarios = { "tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario }, "tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario }, image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario }, + "image-tool-result": { + title: "reads image returned from tool result", + tags: ["media", "image", "vision", "tool", "tool-result", "golden"], + run: runImageToolResultScenario, + }, reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario }, "reasoning-continuation": { title: "continues encrypted reasoning", From 9db90a0b76263862aa831a0ed9080276b0986f06 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 22 May 2026 12:23:41 -0400 Subject: [PATCH 08/39] fix(llm): emit structured image blocks for tool-result media in Anthropic Messages (#28755) --- .../llm/src/protocols/anthropic-messages.ts | 38 +++++++- .../anthropic-opus-4-7-image-tool-result.json | 43 +++++++++ .../test/provider/anthropic-messages.test.ts | 94 +++++++++++++++++++ .../llm/test/provider/golden.recorded.test.ts | 5 +- 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index e24758e89c89..740441f7b4f3 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -14,6 +14,7 @@ import { type ProviderMetadata, type ToolCallPart, type ToolDefinition, + type ToolResultContentPart, type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" @@ -96,10 +97,18 @@ const AnthropicServerToolResultBlock = Schema.Struct({ }) type AnthropicServerToolResultBlock = Schema.Schema.Type +// Anthropic accepts either a plain string or an ordered array of text/image +// blocks inside `tool_result.content`. The array form is required when a tool +// returns image bytes (screenshot, image search, etc.) so they can be passed +// to the model as proper image inputs instead of being JSON-stringified into +// the prompt — which silently inflates context by megabytes and can push the +// conversation over the model's token limit. +const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock]) + const AnthropicToolResultBlock = Schema.Struct({ type: Schema.tag("tool_result"), tool_use_id: Schema.String, - content: Schema.String, + content: Schema.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]), is_error: Schema.optional(Schema.Boolean), cache_control: Schema.optional(AnthropicCacheControl), }) @@ -298,6 +307,31 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me } satisfies AnthropicImageBlock }) +// Tool results may carry structured text/images. Keep media as provider-native +// content instead of JSON-stringifying base64 into a prompt string. +const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( + item: ToolResultContentPart, +) { + if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock + if (item.mediaType.startsWith("image/")) + return { + type: "image" as const, + source: { + type: "base64" as const, + media_type: item.mediaType, + data: ProviderShared.mediaBase64(item), + }, + } satisfies AnthropicImageBlock + return yield* invalid(`Anthropic Messages tool-result media content only supports images, got ${item.mediaType}`) +}) + +const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) { + // Text / json / error results stay as a string for backward compatibility + // with existing cassettes and provider expectations. + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + return yield* Effect.forEach(part.result.value, lowerToolResultContentItem) +}) + const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( request: LLMRequest, breakpoints: Cache.Breakpoints, @@ -360,7 +394,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( content.push({ type: "tool_result", tool_use_id: part.id, - content: ProviderShared.toolResultText(part), + content: yield* lowerToolResultContent(part), is_error: part.result.type === "error" ? true : undefined, cache_control: cacheControl(breakpoints, part.cache), }) diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json new file mode 100644 index 000000000000..b1ba048d7a3b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/anthropic-opus-4-7-image-tool-result", + "recordedAt": "2026-05-22T01:57:05.693Z", + "provider": "anthropic", + "route": "anthropic-messages", + "transport": "http", + "model": "claude-opus-4-7", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "flagship", + "media", + "image", + "vision", + "tool", + "tool-result", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_screenshot_1\",\"content\":[{\"type\":\"text\",\"text\":\"Image read successfully\"},{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\",\"data\":\"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\"}}]}]}],\"tools\":[{\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_017z3Dpfd8nim5vfCmcAQMFS\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1005,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"j\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"iggling restroom prison\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1005,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":13} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 681e3414f615..4cf1c3bee982 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -24,6 +24,19 @@ const request = LLM.request({ generation: { maxTokens: 20, temperature: 0 }, }) +type AnthropicToolResult = Extract< + AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number], + { readonly type: "tool_result" } +> + +const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): AnthropicToolResult => { + const result = body.messages + .flatMap((message) => (message.role === "user" ? message.content : [])) + .find((block): block is AnthropicToolResult => block.type === "tool_result") + expect(result).toBeDefined() + return result! +} + describe("Anthropic Messages route", () => { it.effect("prepares Anthropic Messages target", () => Effect.gen(function* () { @@ -71,6 +84,87 @@ describe("Anthropic Messages route", () => { }), ) + // Regression: screenshot/read tool results must stay structured so base64 + // image data is not JSON-stringified into `tool_result.content`. + it.effect("lowers image tool-result content as structured image blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + 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( + 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( diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 59f12b032f17..79d8589cf8c9 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -113,7 +113,10 @@ describeRecordedGoldenScenarios([ requires: ["ANTHROPIC_API_KEY"], tags: ["flagship"], options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, - scenarios: [{ id: "tool-loop", temperature: false }], + scenarios: [ + { id: "tool-loop", temperature: false }, + { id: "image-tool-result", temperature: false, maxTokens: 40 }, + ], }, { name: "Gemini 2.5 Flash", From 8a5592053144aaad4d8804382644ac0312b5e6dd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 16:25:15 +0000 Subject: [PATCH 09/39] chore: generate --- packages/llm/src/protocols/openai-responses.ts | 5 +---- packages/llm/test/recorded-scenarios.ts | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 31d4a471f41b..300f3f19ee7e 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -60,10 +60,7 @@ const OpenAIResponsesReasoningItem = Schema.Struct({ // `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 OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) const OpenAIResponsesFunctionCallOutput = Schema.Union([ Schema.String, diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 294bd822a85d..eca97ee655d9 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -333,9 +333,7 @@ const runImageToolResultScenario = (context: GoldenScenarioContext) => 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.assistant([{ type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }]), Message.tool({ id: "call_screenshot_1", name: screenshotToolName, From 69e4f5227232df49211baa6ace143a1b9b86416c Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 22 May 2026 12:30:10 -0400 Subject: [PATCH 10/39] fix(tui): interaction improvements to diff viewer (#28851) --- .../system/diff-viewer-file-tree-utils.ts | 51 ++++++ .../system/diff-viewer-file-tree.tsx | 8 +- .../feature-plugins/system/diff-viewer.tsx | 159 +++++++++++++----- .../tui/diff-viewer-file-tree-utils.test.ts | 99 +++++++++++ .../cli/tui/diff-viewer-file-tree.test.tsx | 12 +- 5 files changed, 285 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts index 28aeb0fb6500..e618047f2991 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts @@ -157,6 +157,49 @@ 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 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 offset < 0 ? fileIndexes[fileIndexes.length - 1] : fileIndexes[0] + return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))] +} + +export function relativePatchFileIndexFromViewport( + entries: readonly { readonly fileIndex: number; readonly titleContentY: number }[], + scrollTop: number, + offset: number, +) { + const ordered = [...entries].sort((left, right) => left.titleContentY - right.titleContentY) + if (offset > 0) return ordered.find((entry) => entry.titleContentY > scrollTop)?.fileIndex + return ordered.findLast((entry) => entry.titleContentY < scrollTop)?.fileIndex +} + export function allExpandedFileTreeDirectories(tree: FileTree) { return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id)) } @@ -189,3 +232,11 @@ function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit() + for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) { + result.add(parent) + } + return result +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx index f95a06ec0a3c..b34e67be9bd0 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx @@ -95,10 +95,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { fg={ highlighted() ? props.theme.background - : reviewed() - ? props.theme.textMuted - : selected() - ? props.theme.primary + : selected() + ? props.theme.primary + : reviewed() + ? props.theme.textMuted : row.kind === "directory" ? tint(props.theme.text, props.theme.background, 0.35) : props.theme.text diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx index 1ea8836f01c0..3cf9028a2432 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -14,12 +14,17 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { allExpandedFileTreeDirectories, buildFileTree, + fileTreeFileSelection, flattenFileTree, moveFileTreeSelection, moveFileTreeSelectionToFirstChild, moveFileTreeSelectionToFile, moveFileTreeSelectionToParent, + movePatchFileIndex, + orderedPatchFileIndexes, + relativePatchFileIndexFromViewport, setFileTreeDirectoryExpanded, + singlePatchFileIndex, toggleFileTreeDirectory, } from "./diff-viewer-file-tree-utils" @@ -108,6 +113,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { const [selectedFileIndex, setSelectedFileIndex] = createSignal() const [reviewedFileNames, setReviewedFileNames] = createSignal>(new Set()) const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) + const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) const focusRunner = (input: Record void>) => () => input[focus()]() const switchFocusShortcut = useCommandShortcut("diff.switch_focus") const nextFileShortcut = useCommandShortcut("diff.next_file") @@ -154,52 +160,93 @@ function DiffViewer(props: { api: TuiPluginApi }) { setActivePatchFileIndex(undefined) } - const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => { - if (!scroll) return - const offset = fileIndex === 0 ? 0 : 1 - scroll.scrollBy(patchNode.y - scroll.viewport.y + offset) + const scrollPatchNodeToTop = (patchNode: BoxRenderable) => { requestAnimationFrame(() => { - if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset) + if (!scroll) return + const scrollDelta = patchNode.y - scroll.viewport.y + const contentY = scroll.scrollTop + scrollDelta + const offset = contentY === 0 ? 0 : 1 + scroll.scrollBy(scrollDelta + offset) }) } const revealFileTreeFile = (fileIndex: number) => { - const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex) - if (!node) return + const selection = fileTreeFileSelection(fileTree(), fileIndex) + if (!selection) return setExpandedFileNodes((expanded) => { const next = new Set(expanded) - for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) { - next.add(parent) - } + selection.expandedNodes.forEach((node) => next.add(node)) return next }) - setHighlighted(node.id) + setHighlighted(selection.highlightedNode) } - const scrollToFileIndex = (fileIndex: number | undefined) => { - if (fileIndex === undefined) return + const selectPatchFile = (fileIndex: number) => { + revealFileTreeFile(fileIndex) setActivePatchFileIndex(fileIndex) setSelectedFileIndex(fileIndex) + } + + const scrollToFileIndex = (fileIndex: number | undefined) => { + if (fileIndex === undefined) return + selectPatchFile(fileIndex) const patchNode = patchNodeByFileIndex.get(fileIndex) - if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex) + if (patchNode) scrollPatchNodeToTop(patchNode) } const jumpToFileIndex = (fileIndex: number | undefined) => { if (fileIndex === undefined) return - revealFileTreeFile(fileIndex) scrollToFileIndex(fileIndex) } const currentPatchFileIndex = () => { if (!scroll) return undefined - const entries = files() - .map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) })) + const viewportContentY = scroll.scrollTop + 1 + const entries = patchFileIndexes() + .map((fileIndex) => ({ + fileIndex, + node: patchNodeByFileIndex.get(fileIndex), + })) .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node)) - .sort((left, right) => left.node.y - right.node.y) - return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex + .map((entry) => ({ + ...entry, + contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y, + })) + .sort((left, right) => left.contentY - right.contentY) + return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex + } + + const nextPatchFileIndexFromViewport = (offset: number) => { + if (!scroll) return undefined + return relativePatchFileIndexFromViewport( + patchFileIndexes() + .map((fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) })) + .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node)) + .map((entry) => { + const contentY = scroll!.scrollTop + entry.node.y - scroll!.viewport.y + return { + fileIndex: entry.fileIndex, + titleContentY: contentY + (contentY === 0 ? 0 : 1), + } + }), + scroll.scrollTop, + offset, + ) } const jumpRelativePatchFile = (offset: number) => { + if (singlePatch()) { + const next = movePatchFileIndex( + patchFileIndexes(), + visiblePatchFiles()[0]?.fileIndex ?? selectedFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex(), + offset, + ) + if (next === undefined) return + selectPatchFile(next) + scrollSinglePatchToTop() + return + } + const current = focus() === "files" ? highlightedFileNode() : undefined const nextFromSelection = current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset) @@ -207,41 +254,64 @@ function DiffViewer(props: { api: TuiPluginApi }) { jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex) return } - const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex() - const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex) scrollToFileIndex( - fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex, + nextPatchFileIndexFromViewport(offset) ?? + movePatchFileIndex(patchFileIndexes(), currentPatchFileIndex() ?? activePatchFileIndex(), offset), ) } const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex const visiblePatchFiles = createMemo(() => { - if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex })) - const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex() + if (!singlePatch()) { + return patchFileIndexes().flatMap((fileIndex) => { + const file = files()[fileIndex] + return file ? [{ file, fileIndex }] : [] + }) + } + const fileIndex = singlePatchFileIndex( + selectedFileIndex(), + activePatchFileIndex(), + currentPatchFileIndex(), + firstPatchFileIndex(), + ) const file = fileIndex === undefined ? undefined : files()[fileIndex] return file && fileIndex !== undefined ? [{ file, fileIndex }] : [] }) const ensureHighlightedPatchFile = () => { - if (activePatchFileIndex() !== undefined) return - const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex() - if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex) + const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex() + if (fileIndex === undefined) return + selectPatchFile(fileIndex) } - const scrollToHighlightedPatchFile = () => { - const fileIndex = activePatchFileIndex() - if (fileIndex === undefined) return + 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 registerPatchNode = (fileIndex: number, element: BoxRenderable) => { patchNodeByFileIndex.set(fileIndex, element) if (pendingPatchScrollFileIndex() !== fileIndex) return requestAnimationFrame(() => { - scrollPatchNodeToTop(element, fileIndex) + scrollPatchNodeToTop(element) requestAnimationFrame(() => { - scrollPatchNodeToTop(element, fileIndex) + scrollPatchNodeToTop(element) setPendingPatchScrollFileIndex(undefined) }) }) @@ -437,12 +507,23 @@ function DiffViewer(props: { api: TuiPluginApi }) { title: "Toggle single patch view", category: "VCS", run() { - setSinglePatch((value) => { - const next = !value - if (next) ensureHighlightedPatchFile() - else scrollToHighlightedPatchFile() - return next - }) + if (!singlePatch()) { + ensureHighlightedPatchFile() + setSinglePatch(true) + scrollSinglePatchToTop() + return + } + const fileIndex = + visiblePatchFiles()[0]?.fileIndex ?? + singlePatchFileIndex( + selectedFileIndex(), + activePatchFileIndex(), + currentPatchFileIndex(), + firstPatchFileIndex(), + ) + if (fileIndex !== undefined) selectPatchFile(fileIndex) + setSinglePatch(false) + if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex) }, }, { @@ -581,7 +662,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { flexDirection="row" gap={1} flexShrink={0} - paddingLeft={2} + paddingLeft={1} paddingRight={1} border={["left"]} borderColor={theme().border} diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts index 61d656a43a32..d42e4b3bddb6 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts @@ -2,12 +2,17 @@ import { describe, expect, test } from "bun:test" import { allExpandedFileTreeDirectories, buildFileTree, + fileTreeFileSelection, flattenFileTree, moveFileTreeSelection, moveFileTreeSelectionToFirstChild, moveFileTreeSelectionToFile, moveFileTreeSelectionToParent, + movePatchFileIndex, + orderedPatchFileIndexes, + relativePatchFileIndexFromViewport, setFileTreeDirectoryExpanded, + singlePatchFileIndex, toggleFileTreeDirectory, } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils" @@ -233,6 +238,100 @@ 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("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(0) + 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([], undefined, 1)).toBeUndefined() + }) + + test("moves to the next visible patch title below the viewport", () => { + expect( + relativePatchFileIndexFromViewport( + [ + { fileIndex: 0, titleContentY: 0 }, + { fileIndex: 1, titleContentY: 30 }, + { fileIndex: 2, titleContentY: 60 }, + ], + 10, + 1, + ), + ).toBe(1) + expect( + relativePatchFileIndexFromViewport( + [ + { fileIndex: 0, titleContentY: 0 }, + { fileIndex: 1, titleContentY: 30 }, + { fileIndex: 2, titleContentY: 60 }, + ], + 30, + 1, + ), + ).toBe(2) + }) + + test("moves to the previous visible patch title above the viewport", () => { + expect( + relativePatchFileIndexFromViewport( + [ + { fileIndex: 0, titleContentY: 0 }, + { fileIndex: 1, titleContentY: 30 }, + { fileIndex: 2, titleContentY: 60 }, + ], + 50, + -1, + ), + ).toBe(1) + expect( + relativePatchFileIndexFromViewport( + [ + { fileIndex: 0, titleContentY: 0 }, + { fileIndex: 1, titleContentY: 30 }, + { fileIndex: 2, titleContentY: 60 }, + ], + 30, + -1, + ), + ).toBe(0) + }) + 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")! diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx index 4e86a7405326..57a1aae4168e 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -155,7 +155,7 @@ async function renderFrame(component: () => JSX.Element) { const app = await testRender(() => withTheme(component), { width: 40, height: 10 }) try { await renderOnceSettled(app) - return app.captureCharFrame() + return await captureSettledFrame(app) } finally { app.renderer.destroy() } @@ -167,6 +167,16 @@ async function renderOnceSettled(app: Awaited>) { await app.renderOnce() } +async function captureSettledFrame(app: Awaited>) { + 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 ( From b368e5adbe8d23e7e5b48529606a84f40f463edd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 16:31:30 +0000 Subject: [PATCH 11/39] chore: generate --- .../system/diff-viewer-file-tree-utils.ts | 6 +----- .../test/cli/tui/diff-viewer-file-tree-utils.test.ts | 10 ++++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts index e618047f2991..39db669727be 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts @@ -179,11 +179,7 @@ export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) { return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex])) } -export function movePatchFileIndex( - fileIndexes: readonly number[], - current: number | undefined, - offset: number, -) { +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 offset < 0 ? fileIndexes[fileIndexes.length - 1] : fileIndexes[0] diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts index d42e4b3bddb6..87f84dd40321 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts @@ -239,14 +239,12 @@ describe("diff viewer file tree utilities", () => { }) 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 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?.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() }) From 3e1972fd9291b0c63c66f02df6f8adfa3b5d75ca Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 22:06:20 +0530 Subject: [PATCH 12/39] fix(httpapi): return project not found errors (#28856) --- packages/opencode/src/project/project.ts | 12 ++++++--- .../server/routes/instance/httpapi/errors.ts | 9 +++++++ .../routes/instance/httpapi/groups/project.ts | 3 ++- .../instance/httpapi/handlers/project.ts | 12 ++++++++- .../opencode/test/project/project.test.ts | 8 +++--- .../test/server/httpapi-exercise/index.ts | 9 +++++++ .../test/server/httpapi-instance.test.ts | 25 +++++++++++++++++++ .../server/httpapi-public-openapi.test.ts | 8 ++++++ 8 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 5107fde3e434..8e668bca41b1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -101,6 +101,10 @@ export const UpdatePayload = Schema.Struct({ }).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> +export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { + projectID: ProjectID, +}) {} + // --------------------------------------------------------------------------- // Effect service // --------------------------------------------------------------------------- @@ -116,7 +120,7 @@ export interface Interface { readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect readonly get: (id: ProjectID) => Effect.Effect - readonly update: (input: UpdateInput) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect readonly setInitialized: (id: ProjectID) => Effect.Effect readonly sandboxes: (id: ProjectID) => Effect.Effect @@ -372,7 +376,9 @@ export const layer: Layer.Layer< const base64 = Buffer.from(buffer).toString("base64") const mime = AppFileSystem.mimeType(shortest) const url = `data:${mime};base64,${base64}` - yield* update({ projectID: input.id, icon: { url } }) + yield* update({ projectID: input.id, icon: { url } }).pipe( + Effect.catchTag("Project.NotFoundError", () => Effect.void), + ) }) const list = Effect.fn("Project.list")(function* () { @@ -400,7 +406,7 @@ export const layer: Layer.Layer< .returning() .get(), ) - if (!result) throw new Error(`Project not found: ${input.projectID}`) + if (!result) return yield* new NotFoundError({ projectID: input.projectID }) const data = fromRow(result) yield* emitUpdated(data) return data diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts index c1a0691c7f70..5e35d6a79a3c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/errors.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -166,6 +166,15 @@ export class PtyForbiddenError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", + { + projectID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class ApiNotFoundError extends Schema.ErrorClass("NotFoundError")( { name: Schema.Literal("NotFoundError"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index f95199eb0119..b7be4044fc0e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -2,6 +2,7 @@ import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ProjectNotFoundError } from "../errors" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" @@ -53,7 +54,7 @@ export const ProjectApi = HttpApi.make("project") query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ProjectNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "project.update", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index 9e8ca4cfa31a..1b61204c4ca0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -4,6 +4,7 @@ import { ProjectID } from "@/project/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { ProjectNotFoundError } from "../errors" import { markInstanceForReload } from "../lifecycle" export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => @@ -35,7 +36,16 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", params: { projectID: ProjectID } payload: Project.UpdatePayload }) { - return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) + return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe( + Effect.catchTag("Project.NotFoundError", (error) => + Effect.fail( + new ProjectNotFoundError({ + projectID: error.projectID, + message: `Project not found: ${error.projectID}`, + }), + ), + ), + ) }) return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 5688d13d1ad1..56f4ae3f61e7 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -22,7 +22,7 @@ const encoder = new TextEncoder() const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) const it = testEffect(layer) -function run(fn: (svc: Project.Interface) => Effect.Effect) { +function run(fn: (svc: Project.Interface) => Effect.Effect) { return Effect.gen(function* () { const svc = yield* Project.Service return yield* fn(svc) @@ -481,7 +481,7 @@ describe("Project.update", () => { }), ) - it.live("should throw error when project not found", () => + it.live("should fail when project not found", () => Effect.gen(function* () { const exit = yield* run((svc) => svc.update({ @@ -492,9 +492,7 @@ describe("Project.update", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) - expect(error instanceof Error ? error.message : String(error)).toContain( - "Project not found: nonexistent-project-id", - ) + expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" }) } }), ) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 5c822c110928..f2b132cb7320 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -177,6 +177,15 @@ const scenarios: Scenario[] = [ }, "status", ), + http.protected + .patch("/project/{projectID}", "project.update.missing") + .mutating() + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: "project_httpapi_missing" }), + headers: ctx.headers(), + body: { name: "Missing Project" }, + })) + .json(404, object, "status"), http.protected .post("/project/git/init", "project.initGit") .mutating() diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 48244b5abd22..2087ad830f2b 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -9,6 +9,7 @@ import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/co 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" @@ -205,6 +206,30 @@ describe("instance HttpApi", () => { }), ) + 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 }) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index c8069bd312e4..74a3aa68c12f 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -208,4 +208,12 @@ describe("PublicApi OpenAPI v2 errors", () => { "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", + ) + }) }) From a3430db73a88b72000cd529bca868f0767b2cd92 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 16:37:46 +0000 Subject: [PATCH 13/39] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 10 ++++++++-- packages/sdk/openapi.json | 21 +++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3c3668d35e3a..15f4f7cb74eb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1700,6 +1700,12 @@ export type McpServerNotFoundError = { message: string } +export type ProjectNotFoundError = { + _tag: "ProjectNotFoundError" + projectID: string + message: string +} + export type PtyNotFoundError = { _tag: "PtyNotFoundError" ptyID: string @@ -5447,9 +5453,9 @@ export type ProjectUpdateErrors = { */ 400: EffectHttpApiErrorBadRequest | InvalidRequestError /** - * Not found + * ProjectNotFoundError */ - 404: NotFoundError + 404: ProjectNotFoundError } export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 20650cbd1c20..c73ffe59632a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3630,11 +3630,11 @@ } }, "404": { - "description": "Not found", + "description": "ProjectNotFoundError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/ProjectNotFoundError" } } } @@ -15429,6 +15429,23 @@ "required": ["_tag", "name", "message"], "additionalProperties": false }, + "ProjectNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ProjectNotFoundError"] + }, + "projectID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "projectID", "message"], + "additionalProperties": false + }, "PtyNotFoundError": { "type": "object", "properties": { From d0cb58782f3775c2c58cff6ab691e064b89566c9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 22 May 2026 12:37:55 -0400 Subject: [PATCH 14/39] fix(llm): surface code, type, and nested fields on provider stream errors (#28757) --- .../llm/src/protocols/anthropic-messages.ts | 19 +++- .../llm/src/protocols/openai-responses.ts | 30 ++++- .../test/provider/anthropic-messages.test.ts | 24 +++- .../test/provider/openai-responses.test.ts | 103 +++++++++++++++++- 4 files changed, 170 insertions(+), 6 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 740441f7b4f3..060e2e091af9 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -206,7 +206,13 @@ const AnthropicEvent = Schema.Struct({ content_block: Schema.optional(AnthropicStreamBlock), delta: Schema.optional(AnthropicStreamDelta), usage: Schema.optional(AnthropicUsage), - error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })), + // `type` and `message` are both required per Anthropic's spec, but + // OpenAI-compatible proxies and gateway translations occasionally drop one + // or the other; mark them optional so a partial payload still parses and + // the parser can fall back to whichever field is populated. + error: Schema.optional( + Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }), + ), }) type AnthropicEvent = Schema.Schema.Type @@ -701,9 +707,18 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = return [{ ...state, lifecycle, usage }, events] } +// Prefix `error.type` so overloads, rate limits, and quota errors are visible +// even when the provider message is generic or empty. +const providerErrorMessage = (event: AnthropicEvent): string => { + const type = event.error?.type + const message = event.error?.message + if (type && message) return `${type}: ${message}` + return message || type || "Anthropic Messages stream error" +} + const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ state, - [LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })], + [LLMEvent.providerError({ message: providerErrorMessage(event) })], ] const step = (state: ParserState, event: AnthropicEvent) => { diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 300f3f19ee7e..88c65d187c13 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -178,6 +178,17 @@ const OpenAIResponsesStreamItem = Schema.Struct({ }) type OpenAIResponsesStreamItem = Schema.Schema.Type +// 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), @@ -190,12 +201,14 @@ const OpenAIResponsesEvent = Schema.Struct({ service_tier: optionalNull(Schema.String), incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), usage: optionalNull(OpenAIResponsesUsage), + error: optionalNull(OpenAIResponsesErrorPayload), }), [Schema.Record(Schema.String, Schema.Unknown)], ), ), code: Schema.optional(Schema.String), message: Schema.optional(Schema.String), + param: Schema.optional(Schema.String), }) type OpenAIResponsesEvent = Schema.Schema.Type @@ -633,14 +646,27 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step return [{ ...state, lifecycle }, events] } +// Build a single human-readable message from whatever the provider supplied. +// When both code and message are present, prefix the code so consumers see +// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just +// the bare message — production rate limits and context-length failures used +// to be indistinguishable from generic stream drops. +const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => { + const nested = event.response?.error ?? undefined + const message = event.message || nested?.message || undefined + const code = event.code || nested?.code || undefined + if (message && code) return `${code}: ${message}` + return message || code || fallback +} + const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })], + [LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })], ] const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })], + [LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })], ] const step = (state: ParserState, event: OpenAIResponsesEvent) => { diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 4cf1c3bee982..5198af9ab768 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -337,7 +337,29 @@ describe("Anthropic Messages route", () => { ), ) - expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }]) + // Prefix the error type so consumers can distinguish overloads, rate + // limits, and quota errors without parsing the message string. + expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }]) + }), + ) + + it.effect("falls back to error type when no message is present", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }]) + }), + ) + + it.effect("falls back to a stable default when error payload is absent", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }]) }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 57cc7789a60c..ec30d3e54473 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -877,7 +877,11 @@ describe("OpenAI Responses route", () => { Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), ) - expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }]) + // Prefix the code so consumers see the failure mode, not just the + // sometimes-generic provider message. The bare message alone meant + // production errors like rate limits were indistinguishable from + // unrelated stream failures. + expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }]) }), ) @@ -891,6 +895,103 @@ 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( From 4f6eaf859bee6fef2bdedccfbe272380cd871f10 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 16:39:58 +0000 Subject: [PATCH 15/39] chore: generate --- packages/llm/test/provider/openai-responses.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index ec30d3e54473..3f291728abe6 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -925,9 +925,7 @@ describe("OpenAI Responses route", () => { ), ) - expect(response.events).toEqual([ - { type: "provider-error", message: "server_error: Upstream model unavailable" }, - ]) + expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }]) }), ) @@ -966,9 +964,7 @@ describe("OpenAI Responses route", () => { ), ) - expect(response.events).toEqual([ - { type: "provider-error", message: "context_length_exceeded: prompt too long" }, - ]) + expect(response.events).toEqual([{ type: "provider-error", message: "context_length_exceeded: prompt too long" }]) }), ) From aee552c043e0e8aa31d3ef912bc034d7acaeb303 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 23:13:14 +0530 Subject: [PATCH 16/39] fix(repository): type expected reference failures (#28880) --- .../src/reference/repository-cache.ts | 22 ++++--- packages/opencode/src/util/repository.ts | 59 +++++++++++++++++-- .../opencode/test/util/repository.test.ts | 12 ++++ 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts index 5b6d6572e81e..f266cadabae7 100644 --- a/packages/opencode/src/reference/repository-cache.ts +++ b/packages/opencode/src/reference/repository-cache.ts @@ -7,8 +7,11 @@ import { repositoryCachePath, sameRepositoryReference, parseRepositoryReference, + parseRemoteRepositoryReference, validateRepositoryBranch, - isRemoteRepositoryReference, + InvalidRepositoryBranchError, + InvalidRepositoryReferenceError, + UnsupportedLocalRepositoryError, type RemoteReference, } from "@/util/repository" @@ -138,23 +141,26 @@ export function isError(error: unknown): error is Error { } export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) { - const reference = parseRepositoryReference(repository) - if (!reference) { + try { + return parseRemoteRepositoryReference(repository) + } catch (error) { + if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) { + return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message }) + } return yield* new InvalidRepositoryError({ repository, - message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + message: errorMessage(error), }) } - if (!isRemoteRepositoryReference(reference)) { - return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" }) - } - return reference }) export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) { try { validateRepositoryBranch(branch) } catch (error) { + if (error instanceof InvalidRepositoryBranchError) { + return yield* new InvalidBranchError({ branch: error.branch, message: error.message }) + } return yield* new InvalidBranchError({ branch, message: errorMessage(error) }) } }) diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 1754a7bf871e..1646f04aef42 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -1,5 +1,6 @@ import path from "path" import { fileURLToPath } from "url" +import { Schema } from "effect" import { Global } from "@opencode-ai/core/global" type BaseReference = { @@ -23,6 +24,43 @@ export type FileReference = BaseReference & { export type Reference = RemoteReference | FileReference +export class InvalidRepositoryReferenceError extends Schema.TaggedErrorClass()( + "RepositoryInvalidReferenceError", + { + repository: Schema.String, + message: Schema.String, + }, +) {} + +export class UnsupportedLocalRepositoryError extends Schema.TaggedErrorClass()( + "RepositoryUnsupportedLocalRepositoryError", + { + repository: Schema.String, + message: Schema.String, + }, +) {} + +export class InvalidRepositoryBranchError extends Schema.TaggedErrorClass()( + "RepositoryInvalidBranchError", + { + branch: Schema.String, + message: Schema.String, + }, +) {} + +export type RepositoryError = + | InvalidRepositoryReferenceError + | UnsupportedLocalRepositoryError + | InvalidRepositoryBranchError + +export function isRepositoryError(error: unknown): error is RepositoryError { + return ( + error instanceof InvalidRepositoryReferenceError || + error instanceof UnsupportedLocalRepositoryError || + error instanceof InvalidRepositoryBranchError + ) +} + function normalizeRepositoryInput(input: string) { return input .trim() @@ -147,16 +185,27 @@ export function isRemoteRepositoryReference(reference: Reference): reference is export function parseRemoteRepositoryReference(input: string) { const reference = parseRepositoryReference(input) - if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (!isRemoteRepositoryReference(reference)) throw new Error("Local file repositories are not supported") + if (!reference) { + throw new InvalidRepositoryReferenceError({ + repository: input, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + }) + } + if (!isRemoteRepositoryReference(reference)) { + throw new UnsupportedLocalRepositoryError({ + repository: input, + message: "Local file repositories are not supported", + }) + } return reference } export function validateRepositoryBranch(branch: string) { if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { - throw new Error( - "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", - ) + throw new InvalidRepositoryBranchError({ + branch, + message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + }) } } diff --git a/packages/opencode/test/util/repository.test.ts b/packages/opencode/test/util/repository.test.ts index ad9fce94bec3..5c619f2aaf20 100644 --- a/packages/opencode/test/util/repository.test.ts +++ b/packages/opencode/test/util/repository.test.ts @@ -3,6 +3,9 @@ import path from "path" import { pathToFileURL } from "url" import { Global } from "@opencode-ai/core/global" import { + InvalidRepositoryBranchError, + InvalidRepositoryReferenceError, + UnsupportedLocalRepositoryError, isFileRepositoryReference, isRemoteRepositoryReference, parseRemoteRepositoryReference, @@ -61,6 +64,14 @@ describe("util.repository", () => { expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow( "Local file repositories are not supported", ) + expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(UnsupportedLocalRepositoryError) + }) + + test("rejects invalid remote repository references with typed errors", () => { + expect(() => parseRemoteRepositoryReference("not-a-repo")).toThrow(InvalidRepositoryReferenceError) + expect(() => parseRemoteRepositoryReference("git@github.com:../../../etc/passwd")).toThrow( + InvalidRepositoryReferenceError, + ) }) test("compares cache identity independent of input spelling", () => { @@ -77,5 +88,6 @@ describe("util.repository", () => { expect(() => validateRepositoryBranch("-bad")).toThrow("Branch must contain only alphanumeric characters") expect(() => validateRepositoryBranch("bad..branch")).toThrow("Branch must contain only alphanumeric characters") expect(() => validateRepositoryBranch("bad branch")).toThrow("Branch must contain only alphanumeric characters") + expect(() => validateRepositoryBranch("bad branch")).toThrow(InvalidRepositoryBranchError) }) }) From 05f51bfe45ad56c67e4cc8e5700fbe0ce6cd61bb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 17:44:34 +0000 Subject: [PATCH 17/39] chore: generate --- packages/opencode/src/util/repository.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 1646f04aef42..dfeee4322ad9 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -204,7 +204,8 @@ 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 ..", + message: + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", }) } } From 0e14404e5f15303ab0b4165397198530a70528bc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 23:15:53 +0530 Subject: [PATCH 18/39] fix(sync): map workspace warp not found (#28882) --- .../instance/httpapi/groups/workspace.ts | 3 ++- .../instance/httpapi/handlers/workspace.ts | 2 ++ .../test/server/httpapi-workspace.test.ts | 21 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1c40ae3cb8d1..6a5101dc4219 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -3,6 +3,7 @@ import { WorkspaceAdapterEntry } from "@/control-plane/types" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ApiVcsApplyError } from "./instance" +import { ApiNotFoundError } from "../errors" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" @@ -107,7 +108,7 @@ export const WorkspaceApi = HttpApi.make("workspace") query: WorkspaceRoutingQuery, payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), - error: [ApiWorkspaceWarpError, ApiVcsApplyError], + error: [ApiWorkspaceWarpError, ApiVcsApplyError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "experimental.workspace.warp", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index c22b82ddebcd..2699c8659040 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -5,6 +5,7 @@ import { Vcs } from "@/project/vcs" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { notFound } from "../errors" import { ApiVcsApplyError } from "../groups/instance" import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" @@ -54,6 +55,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac }) .pipe( Effect.mapError((error) => { + if (error instanceof Workspace.WorkspaceNotFoundError) return notFound(error.message) if (error instanceof Vcs.PatchApplyError) { return new ApiVcsApplyError({ name: "VcsApplyError", diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index a3cca6e47598..2e10d325f6ee 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -5,6 +5,7 @@ import path from "node:path" import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" @@ -250,6 +251,26 @@ describe("workspace HttpApi", () => { }), ) + it.live("returns a declared not found error when warping into a missing workspace", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const session = yield* Session.use.create({}).pipe(provideInstance(dir)) + const workspaceID = WorkspaceID.ascending("wrk_missing_warp") + + const response = yield* request(WorkspacePaths.warp, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: workspaceID, sessionID: session.id }), + }) + + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ + name: "NotFoundError", + data: { message: `Workspace not found: ${workspaceID}` }, + }) + }), + ) + it.live("creates workspace with the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 15f4f7cb74eb..ca9eaab71db6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8169,6 +8169,10 @@ export type ExperimentalWorkspaceWarpErrors = { * WorkspaceWarpError | VcsApplyError | InvalidRequestError */ 400: WorkspaceWarpError | VcsApplyError | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError } export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] From dda69d77e853ce76754fbdd91445e8faa324702f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 17:47:18 +0000 Subject: [PATCH 19/39] chore: generate --- packages/sdk/openapi.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c73ffe59632a..5d4139139a17 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10326,6 +10326,16 @@ } } } + }, + "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.", From 536ee857c6f923a7838f331e36dee1b63d3ff725 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 23:18:17 +0530 Subject: [PATCH 20/39] fix(installation): type upgrade failures (#28883) --- packages/opencode/src/installation/index.ts | 19 +++++-- .../test/installation/installation.test.ts | 56 +++++++++++++++++-- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 079bc30ffa81..6240cee886c5 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -67,7 +67,11 @@ export function isLocal() { export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { stderr: Schema.String, -}) {} +}) { + override get message() { + return this.stderr + } +} // Response schemas for external version APIs const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) @@ -139,6 +143,12 @@ export const layer: Layer.Layer { + 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 @@ -155,7 +165,7 @@ export const layer: Layer.Layer new UpgradeFailedError({ stderr: upgradeFailure("curl") }))) const result: Interface = { info: Effect.fn("Installation.info")(function* () { @@ -299,11 +309,10 @@ export const layer: Layer.Layer string = () => "") { +function mockSpawner( + handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () => + "", +) { const spawner = ChildProcessSpawner.make((command) => { const std = ChildProcess.isStandardCommand(command) ? command : undefined - const output = handler(std?.command ?? "", std?.args ?? []) + const result = handler(std?.command ?? "", std?.args ?? []) + const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(0), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)), isRunning: Effect.succeed(false), kill: () => Effect.void, stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, - stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, - stderr: Stream.empty, + stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty, + stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty, all: Stream.empty, getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, getOutputFd: () => Stream.empty, @@ -46,7 +50,7 @@ function jsonResponse(body: unknown) { function testLayer( httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, - spawnHandler?: (cmd: string, args: readonly string[]) => string, + spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, ) { const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler))) return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess)) @@ -166,4 +170,44 @@ describe("installation", () => { }), ) }) + + describe("upgrade", () => { + testEffect( + testLayer( + () => jsonResponse({}), + (cmd) => { + if (cmd === "npm") return { code: 1, stderr: "token=secret command output" } + return "" + }, + ), + ).effect("returns sanitized typed errors for failed package upgrades", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("npm", "9.9.9")) + expect(error).toBeInstanceOf(Installation.UpgradeFailedError) + expect(error.stderr).toBe("Upgrade failed for npm (exit code 1).") + expect(error.message).toBe(error.stderr) + expect(error.stderr).not.toContain("secret") + expect(error.stderr).not.toContain("command output") + }), + ) + + testEffect( + testLayer( + () => new Response("install script with token=secret", { status: 200 }), + (cmd) => { + if (cmd === "bash") return { code: 1, stderr: "script output with token=secret" } + return "" + }, + ), + ).effect("returns sanitized typed errors when the curl install script fails", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("curl", "9.9.9")) + expect(error).toBeInstanceOf(Installation.UpgradeFailedError) + expect(error.stderr).toBe("Upgrade failed for curl (exit code 1).") + expect(error.message).toBe(error.stderr) + expect(error.stderr).not.toContain("secret") + expect(error.stderr).not.toContain("script output") + }), + ) + }) }) From 7265c46af688eea56ed1c2b538c93b19cf0d7f83 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 23:18:52 +0530 Subject: [PATCH 21/39] fix(skill): type expected skill failures (#28885) --- packages/opencode/src/skill/index.ts | 27 +++++++++--- packages/opencode/src/tool/skill.ts | 9 ++-- packages/opencode/test/session/system.test.ts | 5 +++ packages/opencode/test/skill/skill.test.ts | 31 ++++++++++++++ packages/opencode/test/tool/skill.test.ts | 42 ++++++++++++++++++- 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c2830ee06d17..c1c6d0d6f28a 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -57,17 +57,26 @@ function isSkillFrontmatter(data: unknown): data is { name: string; description? ) } -export const InvalidError = NamedError.create("SkillInvalidError", { +export class InvalidError extends Schema.TaggedErrorClass()("SkillInvalidError", { path: Schema.String, message: Schema.optional(Schema.String), issues: Schema.optional(Schema.Array(Issue)), -}) +}) {} -export const NameMismatchError = NamedError.create("SkillNameMismatchError", { +export class NameMismatchError extends Schema.TaggedErrorClass()("SkillNameMismatchError", { path: Schema.String, expected: Schema.String, actual: Schema.String, -}) +}) {} + +export class NotFoundError extends Schema.TaggedErrorClass()("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 @@ -86,6 +95,7 @@ type ScanState = { export interface Interface { readonly get: (name: string) => Effect.Effect + readonly require: (name: string) => Effect.Effect readonly all: () => Effect.Effect readonly dirs: () => Effect.Effect readonly available: (agent?: Agent.Info) => Effect.Effect @@ -277,6 +287,13 @@ export const layer = Layer.effect( return s.skills[name] }) + const require = Effect.fn("Skill.require")(function* (name: string) { + const s = yield* InstanceState.get(state) + const info = s.skills[name] + if (info) return info + return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() }) + }) + const all = Effect.fn("Skill.all")(function* () { const s = yield* InstanceState.get(state) return Object.values(s.skills) @@ -293,7 +310,7 @@ export const layer = Layer.effect( return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") }) - return Service.of({ get, all, dirs, available }) + return Service.of({ get, require, all, dirs, available }) }), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 8c41077be5ec..8730f0278920 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -22,12 +22,9 @@ export const SkillTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const info = yield* skill.get(params.name) - if (!info) { - const all = yield* skill.all() - const available = all.map((item) => item.name).join(", ") - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } + const info = yield* skill + .require(params.name) + .pipe(Effect.catchTag("Skill.NotFoundError", (error) => Effect.die(new Error(error.message)))) yield* ctx.ask({ permission: "skill", diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 1cf9026725ea..28b1bcac82e3 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -47,6 +47,11 @@ const it = testEffect( Skill.Service, Skill.Service.of({ get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)), + require: (name) => { + const info = skills.find((skill) => skill.name === name) + if (info) return Effect.succeed(info) + return Effect.fail(new Skill.NotFoundError({ name, available: skills.map((skill) => skill.name) })) + }, all: () => Effect.succeed(skills), dirs: () => Effect.succeed([]), available: () => Effect.succeed(skills), diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 149cad1f2db3..fc1f6bff6ae7 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -289,6 +289,37 @@ description: A skill in the .claude/skills directory. ), ) + it.live("fails with typed error when requiring a missing skill", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const skill = yield* Skill.Service + const error = yield* Effect.flip(skill.require("missing-skill")) + expect(error).toBeInstanceOf(Skill.NotFoundError) + expect(error._tag).toBe("Skill.NotFoundError") + expect(error.name).toBe("missing-skill") + expect(error.message).toContain('Skill "missing-skill" not found.') + }), + { git: true }, + ), + ) + + it.effect("exposes tagged expected skill failure classes", () => + Effect.sync(() => { + const invalid = new Skill.InvalidError({ path: "/tmp/SKILL.md", message: "Invalid skill frontmatter" }) + const mismatch = new Skill.NameMismatchError({ + path: "/tmp/SKILL.md", + expected: "expected-skill", + actual: "actual-skill", + }) + + expect(invalid).toBeInstanceOf(Skill.InvalidError) + expect(invalid._tag).toBe("SkillInvalidError") + expect(mismatch).toBeInstanceOf(Skill.NameMismatchError) + expect(mismatch._tag).toBe("SkillNameMismatchError") + }), + ) + it.live("discovers skills from .agents/skills/ directory", () => provideTmpdirInstance( (dir) => diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index bf05fc4ab19e..6732b42bbe2c 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,5 +1,5 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Effect, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" @@ -90,4 +90,44 @@ Use this skill. }), ), ) + + it.live("execute preserves not found message", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = dir + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) + + const registry = yield* ToolRegistry.Service + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const tool = (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((tool) => tool.id === SkillTool.id) + if (!tool) throw new Error("Skill tool not found") + + const exit = yield* tool + .execute( + { name: "missing-skill" }, + { + ...baseCtx, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.') + } + }), + ), + ) }) From d5068ba28eebe2bdbe9a00e2004403bebe2f1706 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 17:50:23 +0000 Subject: [PATCH 22/39] chore: generate --- packages/opencode/src/installation/index.ts | 37 +++++++++++---------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 6240cee886c5..d96924a2a0b1 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -149,23 +149,26 @@ export const layer: Layer.Layer 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.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })), + ) const result: Interface = { info: Effect.fn("Installation.info")(function* () { From ba746e36d8e8112e91b7352f7fe6b6165d144c8b Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 22 May 2026 14:21:22 -0400 Subject: [PATCH 23/39] fix(tui): empty states, context, and minor improvements to diff viewer (#28878) --- .../src/cli/cmd/tui/config/keybind.ts | 8 +- .../system/diff-viewer-file-tree-utils.ts | 16 +- .../system/diff-viewer-file-tree.tsx | 26 +- .../feature-plugins/system/diff-viewer-ui.tsx | 34 +- .../feature-plugins/system/diff-viewer.tsx | 426 +++++++++++------- packages/opencode/src/project/vcs.ts | 59 ++- .../instance/httpapi/groups/instance.ts | 1 + .../instance/httpapi/handlers/instance.ts | 6 +- .../server/routes/instance/httpapi/public.ts | 1 + .../tui/diff-viewer-file-tree-utils.test.ts | 62 +-- .../cli/tui/diff-viewer-file-tree.test.tsx | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 13 files changed, 377 insertions(+), 267 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 9e87f67ac4c7..c03123aed1c0 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -62,14 +62,16 @@ export const Definitions = { diff_close: keybind("escape,q", "Close diff viewer"), diff_toggle: keybind("enter,space", "Toggle diff viewer item"), diff_expand: keybind("right", "Expand diff viewer item"), + diff_expand_all: keybind("E", "Expand all diff viewer folders"), diff_collapse: keybind("left", "Collapse diff viewer item"), diff_switch_focus: keybind("tab", "Switch diff viewer focus"), diff_next_file: keybind("n", "Jump to next diff file"), diff_previous_file: keybind("p", "Jump to previous diff file"), diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"), diff_single_patch: keybind("s", "Toggle single patch view"), - diff_switch_diff: keybind("d", "Switch diff viewer source"), + diff_switch_source: keybind("d", "Switch diff viewer source"), diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"), + diff_help: keybind("?", "Show more diff viewer shortcuts"), editor_open: keybind("e", "Open external editor"), theme_list: keybind("t", "List available themes"), @@ -259,14 +261,16 @@ export const CommandMap = { diff_close: "diff.close", diff_toggle: "diff.toggle", diff_expand: "diff.expand", + diff_expand_all: "diff.expand_all", diff_collapse: "diff.collapse", diff_switch_focus: "diff.switch_focus", diff_next_file: "diff.next_file", diff_previous_file: "diff.previous_file", diff_toggle_file_tree: "diff.toggle_file_tree", diff_single_patch: "diff.single_patch", - diff_switch_diff: "diff.switch_diff", + diff_switch_source: "diff.switch_source", diff_toggle_view: "diff.toggle_view", + diff_help: "diff.help", editor_open: "prompt.editor", theme_list: "theme.switch", theme_switch_mode: "theme.switch_mode", diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts index 39db669727be..a41c09846182 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts @@ -179,23 +179,17 @@ 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 offset < 0 ? fileIndexes[fileIndexes.length - 1] : fileIndexes[0] + if (index === -1) return fileIndexes[0] return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))] } -export function relativePatchFileIndexFromViewport( - entries: readonly { readonly fileIndex: number; readonly titleContentY: number }[], - scrollTop: number, - offset: number, -) { - const ordered = [...entries].sort((left, right) => left.titleContentY - right.titleContentY) - if (offset > 0) return ordered.find((entry) => entry.titleContentY > scrollTop)?.fileIndex - return ordered.findLast((entry) => entry.titleContentY < scrollTop)?.fileIndex -} - export function allExpandedFileTreeDirectories(tree: FileTree) { return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id)) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx index b34e67be9bd0..89dcc0093746 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx @@ -6,7 +6,6 @@ import { createEffect, createMemo, For, Match, Switch } from "solid-js" import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils" import { Panel } from "./diff-viewer-ui" -const FILE_TREE_HORIZONTAL_PADDING = 2 const FILE_TREE_STATUS_WIDTH = 2 export type DiffViewerFileTreeTheme = { @@ -32,6 +31,7 @@ export type DiffViewerFileTreeProps = { readonly selectedFileIndex?: number readonly reviewedFileNames?: ReadonlySet readonly expandedNodes?: ReadonlySet + readonly onRowClick?: (row: FileTreeRow) => void } export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { @@ -72,20 +72,18 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex const reviewed = () => { const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file - return file !== undefined && props.reviewedFileNames?.has(file) + return file !== undefined && (props.reviewedFileNames?.has(file) ?? false) } const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes) - const status = () => fileTreeRowStatus(row, props.files) + const status = () => fileTreeRowStatus(row, props.files, reviewed()) const name = () => - Locale.truncate( - row.name, - Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length), - ) + Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length)) return ( props.onRowClick?.(row)} > {prefix()} @@ -97,11 +95,9 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { ? props.theme.background : selected() ? props.theme.primary - : reviewed() + : reviewed() || row.kind === "directory" ? props.theme.textMuted - : row.kind === "directory" - ? tint(props.theme.text, props.theme.background, 0.35) - : props.theme.text + : props.theme.text } wrapMode="none" > @@ -158,11 +154,9 @@ function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: num return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth } -function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) { +function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) { if (row.fileIndex === undefined) return "" const status = files[row.fileIndex]?.status - if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH) - if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH) - if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH) - return "?".padStart(FILE_TREE_STATUS_WIDTH) + const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?" + return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx index f106bd7e2241..30d5f3038987 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx @@ -1,7 +1,7 @@ import type { BorderSides, ColorInput } from "@opentui/core" import type { JSX } from "@opentui/solid" import { useTheme } from "@tui/context/theme" -import { createContext, splitProps, useContext } from "solid-js" +import { createContext, Show, splitProps, useContext } from "solid-js" export type Axis = "x" | "y" export type SeparatorEdge = "edge" | "edge-in" | "edge-out" @@ -63,22 +63,30 @@ export function Separator(props: { axis?: Axis; color?: ColorInput; start?: Sepa const color = () => props.color ?? theme.border const axis = () => props.axis ?? crossAxis(group?.axis ?? "y") if (axis() === "y") { - if (!props.start && !props.end) return return ( - - {props.start && {verticalEdge(props.start, "start")}} - - {props.end && {verticalEdge(props.end, "end")}} - + } + > + + {(edge) => {verticalEdge(edge(), "start")}} + + {(edge) => {verticalEdge(edge(), "end")}} + + ) } - if (!props.start && !props.end) return return ( - - {props.start && {horizontalEdge(props.start, "start")}} - - {props.end && {horizontalEdge(props.end, "end")}} - + } + > + + {(edge) => {horizontalEdge(edge(), "start")}} + + {(edge) => {horizontalEdge(edge(), "end")}} + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx index 3cf9028a2432..94c72b84ed65 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core" +import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { useBindings, useCommandShortcut } from "@tui/keymap" import { useTheme } from "@tui/context/theme" @@ -15,15 +15,15 @@ import { allExpandedFileTreeDirectories, buildFileTree, fileTreeFileSelection, + type FileTreeRow, flattenFileTree, moveFileTreeSelection, moveFileTreeSelectionToFirstChild, - moveFileTreeSelectionToFile, moveFileTreeSelectionToParent, movePatchFileIndex, orderedPatchFileIndexes, - relativePatchFileIndexFromViewport, setFileTreeDirectoryExpanded, + showDiffViewerFileTree, singlePatchFileIndex, toggleFileTreeDirectory, } from "./diff-viewer-file-tree-utils" @@ -32,8 +32,13 @@ const ROUTE = "diff" const MIN_SPLIT_WIDTH = 100 const FILE_TREE_WIDTH = 32 const PLAIN_TEXT_FILETYPE = "opencode-plain-text" +const WORKING_TREE_DIFF_CONTEXT_LINES = 12 +const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree" +const KV_SINGLE_PATCH = "diff_viewer_single_patch" +const KV_VIEW = "diff_viewer_view" type DiffMode = "git" | "last-turn" type DiffViewerFocus = "patches" | "files" +type DiffView = "split" | "unified" type DiffFile = { readonly file: string @@ -65,6 +70,10 @@ 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() @@ -90,20 +99,25 @@ function DiffViewer(props: { api: TuiPluginApi }) { return normalizeDiffs(result.data ?? []) } - const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true }) + const result = await props.api.client.vcs.diff( + { mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES }, + { throwOnError: true }, + ) return normalizeDiffs(result.data ?? []) }) const files = createMemo(() => diff() ?? []) const [focus, setFocus] = createSignal("patches") - const [showFileTree, setShowFileTree] = createSignal(true) - const [singlePatch, setSinglePatch] = createSignal(false) + const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.api.kv.get(KV_SHOW_FILE_TREE, true) !== false) + const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) + const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get(KV_SINGLE_PATCH, false) === true) const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) + const patchLeftBorder = createMemo(() => (showFileTree() ? ["left"] : [])) const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) const defaultView = createMemo(() => { if (props.api.tuiConfig.diff_style === "stacked") return "unified" return splitAvailable() ? "split" : "unified" }) - const [viewOverride, setViewOverride] = createSignal<"split" | "unified">() + const [viewOverride, setViewOverride] = createSignal(storedView(props.api.kv.get(KV_VIEW))) const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified")) const fileTree = createMemo(() => buildFileTree(files())) const [expandedFileNodes, setExpandedFileNodes] = createSignal>(new Set()) @@ -120,12 +134,14 @@ function DiffViewer(props: { api: TuiPluginApi }) { const previousFileShortcut = useCommandShortcut("diff.previous_file") const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree") const singlePatchShortcut = useCommandShortcut("diff.single_patch") - const switchDiffShortcut = useCommandShortcut("diff.switch_diff") + const switchSourceShortcut = useCommandShortcut("diff.switch_source") const toggleViewShortcut = useCommandShortcut("diff.toggle_view") const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed") + const helpShortcut = useCommandShortcut("diff.help") let scroll: ScrollBoxRenderable | undefined const patchNodeByFileIndex = new Map() const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() + const [patchFillerHeight, setPatchFillerHeight] = createSignal(0) createEffect(() => { setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) @@ -216,48 +232,15 @@ function DiffViewer(props: { api: TuiPluginApi }) { return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex } - const nextPatchFileIndexFromViewport = (offset: number) => { - if (!scroll) return undefined - return relativePatchFileIndexFromViewport( - patchFileIndexes() - .map((fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) })) - .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node)) - .map((entry) => { - const contentY = scroll!.scrollTop + entry.node.y - scroll!.viewport.y - return { - fileIndex: entry.fileIndex, - titleContentY: contentY + (contentY === 0 ? 0 : 1), - } - }), - scroll.scrollTop, - offset, - ) - } - const jumpRelativePatchFile = (offset: number) => { + const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset) if (singlePatch()) { - const next = movePatchFileIndex( - patchFileIndexes(), - visiblePatchFiles()[0]?.fileIndex ?? selectedFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex(), - offset, - ) if (next === undefined) return selectPatchFile(next) scrollSinglePatchToTop() return } - - 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( - nextPatchFileIndexFromViewport(offset) ?? - movePatchFileIndex(patchFileIndexes(), currentPatchFileIndex() ?? activePatchFileIndex(), offset), - ) + scrollToFileIndex(next) } const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex @@ -305,8 +288,26 @@ function DiffViewer(props: { api: TuiPluginApi }) { }) } + 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) @@ -317,6 +318,13 @@ function DiffViewer(props: { api: TuiPluginApi }) { }) } + createEffect(() => { + visiblePatchFiles() + dimensions() + view() + measurePatchFiller() + }) + const toggleSelectedFileTreeRow = () => { const highlighted = fileRows().find((row) => row.id === highlightedFileNode()) if (highlighted?.fileIndex !== undefined) { @@ -326,6 +334,16 @@ function DiffViewer(props: { api: TuiPluginApi }) { setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode())) } + const clickFileTreeRow = (row: FileTreeRow) => { + setFocus("files") + setHighlighted(row.id) + if (row.fileIndex !== undefined) { + jumpToFileIndex(row.fileIndex) + return + } + setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, row.id)) + } + const toggleSelectedFileReviewed = () => { const fileIndex = focus() === "files" @@ -435,6 +453,17 @@ function DiffViewer(props: { api: TuiPluginApi }) { patches() {}, }), }, + { + name: "diff.expand_all", + title: "Expand all diff viewer folders", + category: "VCS", + run: focusRunner({ + files() { + setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) + }, + patches() {}, + }), + }, { name: "diff.collapse", title: "Collapse diff viewer item", @@ -496,10 +525,10 @@ function DiffViewer(props: { api: TuiPluginApi }) { title: "Toggle diff viewer file tree", category: "VCS", run() { - setShowFileTree((value) => { - if (value) setFocus("patches") - return !value - }) + const next = !fileTreeEnabled() + if (!next) setFocus("patches") + setFileTreeEnabled(next) + props.api.kv.set(KV_SHOW_FILE_TREE, next) }, }, { @@ -510,6 +539,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { if (!singlePatch()) { ensureHighlightedPatchFile() setSinglePatch(true) + props.api.kv.set(KV_SINGLE_PATCH, true) scrollSinglePatchToTop() return } @@ -523,11 +553,12 @@ function DiffViewer(props: { api: TuiPluginApi }) { ) if (fileIndex !== undefined) selectPatchFile(fileIndex) setSinglePatch(false) + props.api.kv.set(KV_SINGLE_PATCH, false) if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex) }, }, { - name: "diff.switch_diff", + name: "diff.switch_source", title: "Switch diff viewer source", category: "VCS", run() { @@ -540,7 +571,17 @@ function DiffViewer(props: { api: TuiPluginApi }) { category: "VCS", run() { if (!splitAvailable()) return - setViewOverride(view() === "split" ? "unified" : "split") + const next = view() === "split" ? "unified" : "split" + setViewOverride(next) + props.api.kv.set(KV_VIEW, next) + }, + }, + { + name: "diff.help", + title: "Show more diff viewer shortcuts", + category: "VCS", + run() { + openHelpDialog() }, }, ] @@ -580,6 +621,11 @@ function DiffViewer(props: { api: TuiPluginApi }) { )) } + const openHelpDialog = () => { + props.api.ui.dialog.replace(() => ) + props.api.ui.dialog.setSize("large") + } + useBindings(() => ({ commands, bindings: [ @@ -610,10 +656,23 @@ function DiffViewer(props: { api: TuiPluginApi }) { - + + Loading diff... + + + + No diff! + + + + + + Failed to load diff + + @@ -628,93 +687,83 @@ function DiffViewer(props: { api: TuiPluginApi }) { selectedFileIndex={selectedFileIndex()} reviewedFileNames={reviewedFileNames()} expandedNodes={expandedFileNodes()} + onRowClick={clickFileTreeRow} /> - - - - - Failed to load diff - - - - - No diff to show - - - 0}> - (scroll = element)} - flexGrow={1} - minHeight={0} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} - > - - {(entry, index) => { - const reviewed = () => reviewedFileNames().has(entry.file.file) - return ( - registerPatchNode(entry.fileIndex, element)}> - {index() !== 0 ? : null} - - {entry.file.file} - - - +{entry.file.additions} - - - -{entry.file.deletions} - + + (scroll = element)} + flexGrow={1} + minHeight={0} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + {(entry, index) => { + const reviewed = () => reviewedFileNames().has(entry.file.file) + return ( + registerPatchNode(entry.fileIndex, element)}> + {index() !== 0 ? : null} + + {entry.file.file} + + + +{entry.file.additions} + + + -{entry.file.deletions} + + + + No patch available for this file.} + > + {(patch) => ( + + - - No patch available for this file.} - > - {(patch) => ( - - - - )} - - - ) - }} - - - - - + )} + + + ) + }} + + 0}> + + + + @@ -743,41 +792,24 @@ function DiffViewer(props: { api: TuiPluginApi }) { )} - + {(shortcut) => ( - {shortcut()}{" "} - {showFileTree() ? "hide file tree" : "show file tree"} + {shortcut()} switch source )} - - {(shortcut) => ( - - {shortcut()}{" "} - {singlePatch() ? "all patches" : "single patch"} - - )} - - - {(shortcut) => ( - - {shortcut()} switch diff - - )} - - + {(shortcut) => ( - {shortcut()}{" "} - {view() === "split" ? "unified view" : "split view"} + {shortcut()} mark reviewed )} - + {(shortcut) => ( - {shortcut()} mark reviewed + {shortcut()} all )} @@ -787,6 +819,90 @@ function DiffViewer(props: { api: TuiPluginApi }) { ) } +function DiffViewerHelpDialog() { + const { theme } = useTheme() + const rows = [ + { + 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 ( + + + + Diff shortcuts + + esc + + + + Key + + + Action + + Description + + + {(row) => ( + + + {row.shortcut() || "-"} + + + {row.action} + + {row.description} + + )} + + + ) +} + const tui: TuiPlugin = async (api) => { api.route.register([ { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index a454cddbbb39..d2b5729dd4da 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -11,6 +11,9 @@ const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 const MAX_PATCH_BYTES = 10_000_000 const MAX_TOTAL_PATCH_BYTES = 10_000_000 +type DiffOptions = { + readonly context?: number +} const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) @@ -91,11 +94,17 @@ const splitGitPatch = (patch: Git.Patch) => { return chunks.slice(0, -1) } -const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { +const batchPatches = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string, + list: Git.Item[], + options?: DiffOptions, +) { if (list.length === 0) return { patches: new Map(), capped: false } const result = yield* git.patchAll(cwd, ref, { - context: PATCH_CONTEXT_LINES, + context: options?.context ?? PATCH_CONTEXT_LINES, maxOutputBytes: MAX_TOTAL_PATCH_BYTES, }) if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES }) @@ -116,11 +125,18 @@ const nativePatch = Effect.fnUntraced(function* ( cwd: string, ref: string | undefined, item: Git.Item, + options?: DiffOptions, ) { const result = item.code === "??" || !ref - ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) - : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + ? yield* git.patchUntracked(cwd, item.file, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_PATCH_BYTES, + }) + : yield* git.patch(cwd, ref, item.file, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_PATCH_BYTES, + }) if (!result.truncated && result.text) return result.text if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) @@ -140,13 +156,14 @@ const patchForItem = Effect.fnUntraced(function* ( item: Git.Item, batch: { patches: Map; capped: boolean }, capped: boolean, + options?: DiffOptions, ) { if (capped) return emptyPatch(item.file) const batched = batch.patches.get(item.file) if (batched !== undefined) return batched if (item.code !== "??" && batch.capped) return emptyPatch(item.file) - return yield* nativePatch(git, cwd, ref, item) + return yield* nativePatch(git, cwd, ref, item, options) }) const files = Effect.fnUntraced(function* ( @@ -156,6 +173,7 @@ const files = Effect.fnUntraced(function* ( list: Git.Item[], map: Map, batch: { patches: Map; capped: boolean }, + options?: DiffOptions, ) { const next: FileDiff[] = [] let total = 0 @@ -163,7 +181,7 @@ const files = Effect.fnUntraced(function* ( for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) - const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped, options) const result: { patch: string; capped: boolean } = capped ? { patch, capped: true } : totalPatch(item.file, patch, total) @@ -184,7 +202,12 @@ const files = Effect.fnUntraced(function* ( return next }) -const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { +const diffAgainstRef = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string, + options?: DiffOptions, +) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) @@ -197,13 +220,19 @@ const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: str extra.filter((item) => item.code === "??"), ), nums(stats), - yield* batchPatches(git, cwd, ref, list), + yield* batchPatches(git, cwd, ref, list, options), + options, ) }) -const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { - if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) - return yield* diffAgainstRef(git, cwd, ref) +const track = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + options?: DiffOptions, +) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch(), options) + return yield* diffAgainstRef(git, cwd, ref, options) }) export const Mode = Schema.Literals(["git", "branch"]) @@ -264,7 +293,7 @@ export interface Interface { readonly branch: () => Effect.Effect readonly defaultBranch: () => Effect.Effect readonly status: () => Effect.Effect - readonly diff: (mode: Mode) => Effect.Effect + readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect readonly diffRaw: () => Effect.Effect readonly apply: (input: ApplyInput) => Effect.Effect } @@ -352,19 +381,19 @@ export const layer: Layer.Layer = Lay }), ) }), - diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { + diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) { const value = yield* InstanceState.get(state) const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") return [] if (mode === "git") { - return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined) + return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options) } if (!value.root) return [] if (value.current && value.current === value.root.name) return [] const ref = yield* git.mergeBase(ctx.directory, value.root.ref) if (!ref) return [] - return yield* diffAgainstRef(git, ctx.directory, ref) + return yield* diffAgainstRef(git, ctx.directory, ref, options) }), diffRaw: Effect.fn("Vcs.diffRaw")(function* () { const ctx = yield* InstanceState.context diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index ea8db35035da..6fd18c8624c7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -26,6 +26,7 @@ const PathInfo = Schema.Struct({ export const VcsDiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, + context: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), }) export class ApiVcsApplyError extends Schema.ErrorClass("VcsApplyError")( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index 4ae318ef21b4..f851b3a31005 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -48,8 +48,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" return yield* vcs.status() }) - const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { - return yield* vcs.diff(ctx.query.mode) + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { + query: { mode: Vcs.Mode; context?: number } + }) { + return yield* vcs.diff(ctx.query.mode, { context: ctx.query.context }) }) const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 91a50c263a66..751d42d00e3d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -66,6 +66,7 @@ const QueryParameterSchemas: Record = { "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, diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts index 87f84dd40321..4748fa82b73c 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts @@ -10,8 +10,8 @@ import { moveFileTreeSelectionToParent, movePatchFileIndex, orderedPatchFileIndexes, - relativePatchFileIndexFromViewport, setFileTreeDirectoryExpanded, + showDiffViewerFileTree, singlePatchFileIndex, toggleFileTreeDirectory, } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils" @@ -268,68 +268,26 @@ describe("diff viewer file tree utilities", () => { 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(0) + 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("moves to the next visible patch title below the viewport", () => { - expect( - relativePatchFileIndexFromViewport( - [ - { fileIndex: 0, titleContentY: 0 }, - { fileIndex: 1, titleContentY: 30 }, - { fileIndex: 2, titleContentY: 60 }, - ], - 10, - 1, - ), - ).toBe(1) - expect( - relativePatchFileIndexFromViewport( - [ - { fileIndex: 0, titleContentY: 0 }, - { fileIndex: 1, titleContentY: 30 }, - { fileIndex: 2, titleContentY: 60 }, - ], - 30, - 1, - ), - ).toBe(2) - }) - - test("moves to the previous visible patch title above the viewport", () => { - expect( - relativePatchFileIndexFromViewport( - [ - { fileIndex: 0, titleContentY: 0 }, - { fileIndex: 1, titleContentY: 30 }, - { fileIndex: 2, titleContentY: 60 }, - ], - 50, - -1, - ), - ).toBe(1) - expect( - relativePatchFileIndexFromViewport( - [ - { fileIndex: 0, titleContentY: 0 }, - { fileIndex: 1, titleContentY: 30 }, - { fileIndex: 2, titleContentY: 60 }, - ], - 30, - -1, - ), - ).toBe(0) - }) - 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")! diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx index 57a1aae4168e..a9b3455f3e73 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -26,7 +26,7 @@ const theme = { } describe("DiffViewerFileTree", () => { - test("renders sorted hierarchical file rows", async () => { + test.skip("renders sorted hierarchical file rows", async () => { const app = await testRender( () => withTheme(() => ( diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f86a6d1050c5..cd17e70fdf0e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1793,6 +1793,7 @@ export class Vcs extends HeyApiClient { directory?: string workspace?: string mode: "git" | "branch" + context?: number }, options?: Options, ) { @@ -1804,6 +1805,7 @@ export class Vcs extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "query", key: "mode" }, + { in: "query", key: "context" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ca9eaab71db6..aae1b06ad320 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4837,6 +4837,7 @@ export type VcsDiffData = { directory?: string workspace?: string mode: "git" | "branch" + context?: number } url: "/vcs/diff" } From b8266e581964d449367f75c100cb6fe67de8752c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 18:22:48 +0000 Subject: [PATCH 24/39] chore: generate --- .../cli/cmd/tui/feature-plugins/system/diff-viewer.tsx | 4 +++- packages/sdk/openapi.json | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx index 94c72b84ed65..013a032afcc5 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -107,7 +107,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }) const files = createMemo(() => diff() ?? []) const [focus, setFocus] = createSignal("patches") - const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.api.kv.get(KV_SHOW_FILE_TREE, true) !== false) + const [fileTreeEnabled, setFileTreeEnabled] = createSignal( + props.api.kv.get(KV_SHOW_FILE_TREE, true) !== false, + ) const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get(KV_SINGLE_PATCH, false) === true) const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5d4139139a17..877d9ba7e6b1 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2288,6 +2288,15 @@ "enum": ["git", "branch"] }, "required": true + }, + { + "name": "context", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0 + }, + "required": false } ], "responses": { From 968aaa3cfef0523a333d61656ce2d1b0f944a775 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 23:53:03 +0530 Subject: [PATCH 25/39] fix(pty): expose missing session errors (#28884) --- packages/opencode/src/pty/index.ts | 55 ++++++------ .../routes/instance/httpapi/handlers/pty.ts | 88 ++++++++++++------- .../opencode/test/pty/pty-session.test.ts | 52 ++++++++++- .../opencode/test/server/httpapi-pty.test.ts | 27 ++++++ 4 files changed, 162 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 6f18856fde0c..5cb4d716ffaa 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -87,6 +87,10 @@ export const UpdateInput = Schema.Struct({ export type UpdateInput = Types.DeepMutable> +export class NotFoundError extends Schema.TaggedErrorClass()("Pty.NotFoundError", { + ptyID: PtyID, +}) {} + export const Event = { Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })), Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })), @@ -96,17 +100,17 @@ export const Event = { export interface Interface { readonly list: () => Effect.Effect - readonly get: (id: PtyID) => Effect.Effect + readonly get: (id: PtyID) => Effect.Effect readonly create: (input: CreateInput) => Effect.Effect - readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect - readonly remove: (id: PtyID) => Effect.Effect - readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect - readonly write: (id: PtyID, data: string) => Effect.Effect + readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect + readonly remove: (id: PtyID) => Effect.Effect + readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect + readonly write: (id: PtyID, data: string) => Effect.Effect readonly connect: ( id: PtyID, ws: Socket, cursor?: number, - ) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined> + ) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined, NotFoundError> } export class Service extends Context.Service()("@opencode/Pty") {} @@ -150,10 +154,15 @@ export const layer = Layer.effect( }), ) + const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { + const session = (yield* InstanceState.get(state)).sessions.get(id) + if (!session) return yield* new NotFoundError({ ptyID: id }) + return session + }) + const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (!session) return + const session = yield* requireSession(id) s.sessions.delete(id) log.info("removing session", { id }) teardown(session) @@ -166,8 +175,7 @@ export const layer = Layer.effect( }) const get = Effect.fn("Pty.get")(function* (id: PtyID) { - const s = yield* InstanceState.get(state) - return s.sessions.get(id)?.info + return (yield* requireSession(id)).info }) const create = Effect.fn("Pty.create")(function* (input: CreateInput) { @@ -262,9 +270,7 @@ export const layer = Layer.effect( }) const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (!session) return + const session = yield* requireSession(id) if (input.title) { session.info.title = input.title } @@ -276,28 +282,27 @@ export const layer = Layer.effect( }) const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (session && session.info.status === "running") { + const session = yield* requireSession(id) + if (session.info.status === "running") { session.process.resize(cols, rows) } }) const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (session && session.info.status === "running") { + const session = yield* requireSession(id) + if (session.info.status === "running") { session.process.write(data) } }) const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (!session) { - ws.close() - return - } + const session = yield* requireSession(id).pipe( + Effect.tapError(() => + Effect.sync(() => { + ws.close() + }), + ), + ) log.info("client connected to session", { id }) const sub = sock(ws) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index bcf6aef80460..d439902a0102 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -46,38 +46,50 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }) const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { - const info = yield* pty.get(ctx.params.ptyID) - if (!info) - return yield* new ApiError.PtyNotFoundError({ - ptyID: ctx.params.ptyID, - message: `PTY session not found: ${ctx.params.ptyID}`, - }) - return info + return yield* pty.get(ctx.params.ptyID).pipe( + Effect.catchTag("Pty.NotFoundError", (error) => + Effect.fail( + new ApiError.PtyNotFoundError({ + ptyID: error.ptyID, + message: `PTY session not found: ${error.ptyID}`, + }), + ), + ), + ) }) const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { params: { ptyID: PtyID } payload: typeof Pty.UpdateInput.Type }) { - const info = yield* pty.update(ctx.params.ptyID, { - ...ctx.payload, - size: ctx.payload.size ? { ...ctx.payload.size } : undefined, - }) - if (!info) - return yield* new ApiError.PtyNotFoundError({ - ptyID: ctx.params.ptyID, - message: `PTY session not found: ${ctx.params.ptyID}`, + return yield* pty + .update(ctx.params.ptyID, { + ...ctx.payload, + size: ctx.payload.size ? { ...ctx.payload.size } : undefined, }) - return info + .pipe( + Effect.catchTag("Pty.NotFoundError", (error) => + Effect.fail( + new ApiError.PtyNotFoundError({ + ptyID: error.ptyID, + message: `PTY session not found: ${error.ptyID}`, + }), + ), + ), + ) }) const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { - if (!(yield* pty.get(ctx.params.ptyID))) - return yield* new ApiError.PtyNotFoundError({ - ptyID: ctx.params.ptyID, - message: `PTY session not found: ${ctx.params.ptyID}`, - }) - yield* pty.remove(ctx.params.ptyID) + yield* pty.remove(ctx.params.ptyID).pipe( + Effect.catchTag("Pty.NotFoundError", (error) => + Effect.fail( + new ApiError.PtyNotFoundError({ + ptyID: error.ptyID, + message: `PTY session not found: ${error.ptyID}`, + }), + ), + ), + ) return true }) @@ -85,11 +97,16 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler 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" }) - if (!(yield* pty.get(ctx.params.ptyID))) - return yield* new ApiError.PtyNotFoundError({ - ptyID: ctx.params.ptyID, - message: `PTY session not found: ${ctx.params.ptyID}`, - }) + yield* pty.get(ctx.params.ptyID).pipe( + Effect.catchTag("Pty.NotFoundError", (error) => + Effect.fail( + new ApiError.PtyNotFoundError({ + ptyID: error.ptyID, + message: `PTY session not found: ${error.ptyID}`, + }), + ), + ), + ) return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) }) @@ -114,7 +131,11 @@ export const ptyConnectRoute = HttpRouter.use((router) => PtyPaths.connect, Effect.gen(function* () { const params = yield* HttpRouter.schemaPathParams(Params) - if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) + const exists = yield* pty.get(params.ptyID).pipe( + Effect.as(true), + Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)), + ) + if (!exists) return HttpServerResponse.empty({ status: 404 }) const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) const request = yield* HttpServerRequest.HttpServerRequest @@ -164,11 +185,12 @@ export const ptyConnectRoute = HttpRouter.use((router) => writeScoped(write(new Socket.CloseEvent(code, reason))) }, } - const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) { - yield* closeAccepted(new Socket.CloseEvent(4404, "session not found")) - return HttpServerResponse.empty() - } + const handler = yield* pty.connect(params.ptyID, adapter, cursor).pipe( + Effect.catchTag("Pty.NotFoundError", () => + closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), + ), + ) + if (!handler) return HttpServerResponse.empty() // No `pending[]`-style early-frame buffer (the legacy handler had one). // `request.upgrade` returns a Socket without running the WS handshake; the diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 12784baf3159..9fda48cc91d2 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -4,7 +4,7 @@ import { Config } from "../../src/config/config" import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" -import { Effect, Layer, Queue } from "effect" +import { Cause, Effect, Exit, Layer, Queue } from "effect" import { testEffect } from "../lib/effect" type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID } @@ -66,6 +66,54 @@ const waitForEvents = (events: Queue.Queue, id: PtyID, count: number) } describe("pty", () => { + it.instance( + "returns typed not found errors for missing sessions", + () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = "pty_missing" as PtyID + let closed = false + const socket = { + readyState: 1, + send: () => {}, + close: () => { + closed = true + }, + } + + const get = yield* pty.get(id).pipe(Effect.exit) + expect(Exit.isFailure(get)).toBe(true) + if (Exit.isFailure(get)) expect(Cause.squash(get.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) + + const update = yield* pty.update(id, { title: "missing" }).pipe(Effect.exit) + expect(Exit.isFailure(update)).toBe(true) + if (Exit.isFailure(update)) + expect(Cause.squash(update.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) + + const remove = yield* pty.remove(id).pipe(Effect.exit) + expect(Exit.isFailure(remove)).toBe(true) + if (Exit.isFailure(remove)) + expect(Cause.squash(remove.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) + + const resize = yield* pty.resize(id, 80, 24).pipe(Effect.exit) + expect(Exit.isFailure(resize)).toBe(true) + if (Exit.isFailure(resize)) + expect(Cause.squash(resize.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) + + const write = yield* pty.write(id, "input").pipe(Effect.exit) + expect(Exit.isFailure(write)).toBe(true) + if (Exit.isFailure(write)) + expect(Cause.squash(write.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) + + const connect = yield* pty.connect(id, socket).pipe(Effect.exit) + expect(Exit.isFailure(connect)).toBe(true) + if (Exit.isFailure(connect)) + expect(Cause.squash(connect.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) + expect(closed).toBe(true) + }), + { git: true }, + ) + ptyTest( "publishes created, exited, deleted in order for a short-lived process", () => @@ -93,7 +141,7 @@ describe("pty", () => { expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"]) yield* pty.write(info.id, "exit\n") expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"]) - yield* pty.remove(info.id) + yield* pty.remove(info.id).pipe(Effect.ignore) }), { git: true }, ) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index e28f38082ae3..029cdb9582c3 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -147,6 +147,33 @@ 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 } From 5f42351159365798b1144e7ee83c4ba04955f394 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 23:53:23 +0530 Subject: [PATCH 26/39] fix(provider): type default model failures (#28881) --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 32 +++++++++++++++++-- packages/opencode/src/provider/provider.ts | 23 ++++++++++--- packages/opencode/src/session/prompt.ts | 2 +- .../opencode/test/provider/provider.test.ts | 10 ++++++ 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e78304e4e758..f7e2c134a37e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -69,7 +69,7 @@ export interface Interface { whenToUse: string systemPrompt: string }, - Provider.ModelNotFoundError + Provider.DefaultModelError > } diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ac9879ff8912..1cd8b1dee363 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { basename } from "path" -import { Effect } from "effect" +import { Cause, Effect } from "effect" import { Agent } from "../../../agent/agent" import { Provider } from "@/provider/provider" import { Session } from "@/session/session" @@ -80,7 +80,21 @@ const run = Effect.fn("Cli.debug.agent.body")(function* ( const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) { const provider = yield* Provider.Service const registry = yield* ToolRegistry.Service - const model = agent.model ?? (yield* provider.defaultModel()) + const model = + agent.model ?? + (yield* provider.defaultModel().pipe( + Effect.matchCauseEffect({ + onSuccess: Effect.succeed, + onFailure: (cause) => { + const error = Cause.squash(cause) as Provider.DefaultModelError + if (error instanceof Provider.ModelNotFoundError) { + return fail(`Model not found: ${error.providerID}/${error.modelID}`) + } + if (error instanceof Provider.NoModelsError) return fail(`No models found for provider ${error.providerID}`) + return fail("No providers found") + }, + }), + )) return yield* registry.tools({ ...model, agent }) }) @@ -133,7 +147,19 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio ? agent.model : yield* Effect.gen(function* () { const provider = yield* Provider.Service - return yield* provider.defaultModel() + return yield* provider.defaultModel().pipe( + Effect.matchCauseEffect({ + onSuccess: Effect.succeed, + onFailure: (cause) => { + const error = Cause.squash(cause) as Provider.DefaultModelError + if (error instanceof Provider.ModelNotFoundError) { + return fail(`Model not found: ${error.providerID}/${error.modelID}`) + } + if (error instanceof Provider.NoModelsError) return fail(`No models found for provider ${error.providerID}`) + return fail("No providers found") + }, + }), + ) }) const now = Date.now() const message: MessageV2.Assistant = { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2fe2b7a4a549..2140bea4df98 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -994,7 +994,22 @@ export class InitError extends Schema.TaggedErrorClass()("ProviderIni } } -export type Error = ModelNotFoundError | InitError +export class NoProvidersError extends Schema.TaggedErrorClass()("ProviderNoProvidersError", {}) { + static isInstance(input: unknown): input is NoProvidersError { + return input instanceof NoProvidersError + } +} + +export class NoModelsError extends Schema.TaggedErrorClass()("ProviderNoModelsError", { + providerID: ProviderID, +}) { + static isInstance(input: unknown): input is NoModelsError { + return input instanceof NoModelsError + } +} + +export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModelsError +export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError export interface Interface { readonly list: () => Effect.Effect> @@ -1006,7 +1021,7 @@ export interface Interface { query: string[], ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> readonly getSmallModel: (providerID: ProviderID) => Effect.Effect - readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }> + readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }, DefaultModelError> } interface State { @@ -1821,9 +1836,9 @@ export const layer = Layer.effect( } const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) - if (!provider) throw new Error("no providers found") + if (!provider) return yield* new NoProvidersError() const [model] = sort(Object.values(provider.models)) - if (!model) throw new Error("no models found") + if (!model) return yield* new NoModelsError({ providerID: provider.id }) return { providerID: provider.id, modelID: model.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fc9fa0b96a8c..2fc93c482521 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -682,7 +682,7 @@ export const layer = Layer.effect( .findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) .pipe(Effect.orDie) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model - return yield* provider.defaultModel() + return yield* provider.defaultModel().pipe(Effect.orDie) }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 8e3276dbabf3..8cf93e22d6f1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -351,6 +351,16 @@ it.instance( { config: { model: "anthropic/claude-sonnet-4-20250514" } }, ) +it.instance( + "defaultModel returns a typed error when config excludes every provider", + Effect.gen(function* () { + const error = yield* Provider.use.defaultModel().pipe(Effect.flip) + expect(error).toBeInstanceOf(Provider.NoProvidersError) + expect(error._tag).toBe("ProviderNoProvidersError") + }), + { config: { enabled_providers: [] } }, +) + it.instance( "provider with baseURL from config", Effect.gen(function* () { From 1857c73565f89901d0bb009a4e12029bd52c831a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 18:25:01 +0000 Subject: [PATCH 27/39] chore: generate --- packages/opencode/src/cli/cmd/debug/agent.ts | 3 ++- packages/opencode/src/pty/index.ts | 5 ++++- .../server/routes/instance/httpapi/handlers/pty.ts | 12 +++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 1cd8b1dee363..c74c1c907943 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -155,7 +155,8 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio 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}`) + if (error instanceof Provider.NoModelsError) + return fail(`No models found for provider ${error.providerID}`) return fail("No providers found") }, }), diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 5cb4d716ffaa..b5eab9ce36aa 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -110,7 +110,10 @@ export interface Interface { 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, + NotFoundError + > } export class Service extends Context.Service()("@opencode/Pty") {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index d439902a0102..4644b02934dc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -185,11 +185,13 @@ 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)), - ), - ) + const handler = yield* pty + .connect(params.ptyID, adapter, cursor) + .pipe( + Effect.catchTag("Pty.NotFoundError", () => + closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), + ), + ) if (!handler) return HttpServerResponse.empty() // No `pending[]`-style early-frame buffer (the legacy handler had one). From 8f7a6c4a0076cf69fb57d8e1add3e47194ee6b45 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 22 May 2026 14:40:14 -0400 Subject: [PATCH 28/39] fix(tui): refine diff view keyboard shortcuts (#28896) --- .../feature-plugins/system/diff-viewer.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx index 013a032afcc5..0c0ed9fe15ec 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -7,7 +7,7 @@ import { useBindings, useCommandShortcut } from "@tui/keymap" import { useTheme } from "@tui/context/theme" import { useTerminalDimensions } from "@opentui/solid" import path from "path" -import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { DialogSelect } from "@tui/ui/dialog-select" @@ -145,6 +145,8 @@ function DiffViewer(props: { api: TuiPluginApi }) { const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() const [patchFillerHeight, setPatchFillerHeight] = createSignal(0) + onCleanup(() => props.api.ui.dialog.clear()) + createEffect(() => { setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) setHighlightedFileNode(undefined) @@ -367,6 +369,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { title: "Close diff viewer", category: "VCS", run() { + props.api.ui.dialog.clear() props.api.route.navigate("home") }, }, @@ -604,7 +607,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { const openSwitchDiffDialog = () => { props.api.ui.dialog.replace(() => ( "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.", + 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.", + 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.", + 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.", + 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.", + 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.", + description: "Choose working tree or last-turn changes", }, { shortcut: useCommandShortcut("diff.toggle_view"), action: "Toggle view", - description: "Switch between split and unified diff layout.", + 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.", + description: "Open every folder in the file tree", }, { shortcut: useCommandShortcut("diff.mark_reviewed"), action: "Mark reviewed", - description: "Toggle reviewed state for the selected file.", + description: "Toggle reviewed state for the selected file", }, ] From bfb2d8dc7660344ccc2058b4fdb0f22027453349 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 22 May 2026 15:30:31 -0400 Subject: [PATCH 29/39] fix(tui): when diff viewer closes always return to last route (#28903) --- .../feature-plugins/system/diff-viewer.tsx | 18 ++- .../test/cli/tui/diff-viewer.test.tsx | 106 ++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/cli/tui/diff-viewer.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx index 0c0ed9fe15ec..924bbd7eb312 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" @@ -80,7 +80,12 @@ function DiffViewer(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current const params = () => ("params" in props.api.route.current ? props.api.route.current.params : undefined) as - | { mode?: DiffMode; sessionID?: string; messageID?: string } + | { + mode?: DiffMode + sessionID?: string + messageID?: string + returnRoute?: TuiRouteCurrent + } | undefined const mode = () => params()?.mode ?? "git" const diffInput = createMemo(() => ({ @@ -369,8 +374,13 @@ 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("home") + + props.api.route.navigate( + returnRoute?.name ?? "home", + returnRoute && "params" in returnRoute ? returnRoute.params : undefined, + ) }, }, { @@ -619,6 +629,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { mode: option.value, sessionID: params()?.sessionID, messageID: params()?.messageID, + returnRoute: params()?.returnRoute, }) }, }))} @@ -933,6 +944,7 @@ const tui: TuiPlugin = async (api) => { api.route.navigate(ROUTE, { mode: "git", sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, + returnRoute: api.route.current, }) api.ui.dialog.clear() }, diff --git a/packages/opencode/test/cli/tui/diff-viewer.test.tsx b/packages/opencode/test/cli/tui/diff-viewer.test.tsx new file mode 100644 index 000000000000..ec6a3cf9558c --- /dev/null +++ b/packages/opencode/test/cli/tui/diff-viewer.test.tsx @@ -0,0 +1,106 @@ +/** @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[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 ( + + + + {renderDiff?.({ params: "params" in current ? current.params : undefined })} + + + + ) + } + + const app = await testRender(() => , { 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>, + commands: Map, + 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 From 1a329e4e67d6f8d4b1205f8467d129f69bede577 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 19:31:59 +0000 Subject: [PATCH 30/39] chore: generate --- packages/opencode/test/cli/tui/diff-viewer.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/cli/tui/diff-viewer.test.tsx b/packages/opencode/test/cli/tui/diff-viewer.test.tsx index ec6a3cf9558c..e4649a448a10 100644 --- a/packages/opencode/test/cli/tui/diff-viewer.test.tsx +++ b/packages/opencode/test/cli/tui/diff-viewer.test.tsx @@ -16,7 +16,10 @@ 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[0]["commands"]>[number]>() + const commands = new Map< + string, + NonNullable[0]["commands"]>[number] + >() let current = startRoute let renderDiff: TuiRouteDefinition["render"] | undefined await mkdir(Global.Path.state, { recursive: true }) @@ -60,7 +63,9 @@ test("closing the diff viewer returns to the route it opened from", async () => - {renderDiff?.({ params: "params" in current ? current.params : undefined })} + + {renderDiff?.({ params: "params" in current ? current.params : undefined })} + From 871d38cbd7c77217916fffb0b74ecf8ce45376d6 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 22 May 2026 21:18:49 +0000 Subject: [PATCH 31/39] sync release versions for v1.15.9 --- bun.lock | 36 ++++++++++----------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++---- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 21 files changed, 43 insertions(+), 43 deletions(-) diff --git a/bun.lock b/bun.lock index fe206af0e6f8..1e6aff8ee2b1 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -169,7 +169,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -193,7 +193,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.15.7", + "version": "1.15.9", "bin": { "opencode": "./bin/opencode", }, @@ -254,7 +254,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -309,7 +309,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -323,7 +323,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -353,7 +353,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -369,7 +369,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -382,7 +382,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -400,7 +400,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.15.7", + "version": "1.15.9", "bin": { "opencode": "./bin/opencode", }, @@ -538,7 +538,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -576,7 +576,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "cross-spawn": "catalog:", }, @@ -591,7 +591,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -626,7 +626,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -675,7 +675,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index d25ac573aad1..9736538a949d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.15.7", + "version": "1.15.9", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index bab3e8a491fe..d173ec2553c0 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.15.7", + "version": "1.15.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 859aab988055..2a1747ba3850 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.15.7", + "version": "1.15.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 2df7055e8b7e..94445faca48a 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.15.7", + "version": "1.15.9", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index feb02765f2cb..8b23a541d8f5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.15.7", + "version": "1.15.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 60bb3bfd0711..85a3a6677706 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.7", + "version": "1.15.9", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 81c0c32cb5d7..07f1509bcd1d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.15.7", + "version": "1.15.9", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index bfce4425b0b3..366e800a2025 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.7", + "version": "1.15.9", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d2a30b761458..051c85f88ed4 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.15.7", + "version": "1.15.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ee61a48ddad8..d3e820fa836b 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.15.7" +version = "1.15.9" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 0e31e09bab31..48eb21318c6f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.15.7", + "version": "1.15.9", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 78d313d76c82..a46260134f97 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.7", + "version": "1.15.9", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 2b0f81fb0ee2..6df93ae0bfc3 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.7", + "version": "1.15.9", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 30ae2405ccdd..4e5557b19043 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.7", + "version": "1.15.9", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 70ac88dc606c..841fefbc7f9a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.15.7", + "version": "1.15.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 59841e6acaf7..7cbd514718ef 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.15.7", + "version": "1.15.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 1813e4ffaf49..79212cbd2d56 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.15.7", + "version": "1.15.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index ecc6fb395699..b6dd87914759 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.15.7", + "version": "1.15.9", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 12439cc6bff2..7672d8b2a599 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.15.7", + "version": "1.15.9", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 12f2752b00bc..d75e5bdf6c5a 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.15.7", + "version": "1.15.9", "publisher": "sst-dev", "repository": { "type": "git", From 5ee0238c4a112636bba7d309e853dec2338758b4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 22 May 2026 21:33:19 +0000 Subject: [PATCH 32/39] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 592b0a9dc171..b4538fe1feb8 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-1RiaZQHzIhdtcOJUMsLagpP+nBBL/Qu6zQgrAXMHDCI=", - "aarch64-linux": "sha256-5QZhtkWuNpY/qUxlKRHcGbILOAVnuyzu+h8VDIuMQcU=", - "aarch64-darwin": "sha256-9w3QA22XNc1itGLyhikYU90xGH/iLUUM+SSGXla7lhw=", - "x86_64-darwin": "sha256-cSYiyhhSqIYiTeK1uWHDkHbYstQYD5jEB7JaYjWjgi4=" + "x86_64-linux": "sha256-pbVW7cOLT76Q7f++xaYYrwuN7eS6FRen80xoaVog3M4=", + "aarch64-linux": "sha256-nk/q4PezhQSf/UHhJuL/188q6e+Gr+PFgrlhEEG5ouo=", + "aarch64-darwin": "sha256-IfIJwgvsonrfjG+btK/5YedzX+MJtf/5bYuLrAmnHvU=", + "x86_64-darwin": "sha256-Cwd9gEymjFn6XAgkkyX4ZmRTAE1JiurxF4VvVws9C/E=" } } From 14c511e38016178880a07b0930fe20a1e7137161 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 22 May 2026 17:14:40 -0500 Subject: [PATCH 33/39] fix(llm): stabilize anthropic tool result typecheck (#28909) --- packages/llm/src/protocols/anthropic-messages.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 060e2e091af9..234ccd5baf00 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -335,7 +335,9 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte // 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) + // Preserve the narrowed array element type when compiled through a consumer package. + const content: ReadonlyArray = part.result.value + return yield* Effect.forEach(content, lowerToolResultContentItem) }) const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( From 3bf054c1d9c30b6f294def81da735a6c15cb3d07 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sat, 23 May 2026 10:40:47 +1000 Subject: [PATCH 34/39] fix(app): restore desktop prod legacy flows (#28919) --- .../src/context/global-sync/bootstrap.test.ts | 78 ++++++ .../app/src/context/global-sync/bootstrap.ts | 3 + .../context/global-sync/child-store.test.ts | 94 ++++++- .../src/context/global-sync/child-store.ts | 2 +- packages/app/src/pages/home.tsx | 232 ++++-------------- packages/app/src/pages/session.tsx | 4 +- .../pages/session/new-session-layout.test.ts | 14 ++ .../src/pages/session/new-session-layout.ts | 3 + 8 files changed, 245 insertions(+), 185 deletions(-) create mode 100644 packages/app/src/context/global-sync/bootstrap.test.ts create mode 100644 packages/app/src/pages/session/new-session-layout.test.ts create mode 100644 packages/app/src/pages/session/new-session-layout.ts diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts new file mode 100644 index 000000000000..b75ae5cc32f3 --- /dev/null +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import { createStore } from "solid-js/store" +import { QueryClient } from "@tanstack/solid-query" +import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" +import { bootstrapDirectory } from "./bootstrap" +import type { State, VcsCache } from "./types" + +const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse + +describe("bootstrapDirectory", () => { + test("marks a loading directory partial during bootstrap and complete after success", async () => { + const [store, setStore] = createStore({ + status: "loading", + agent: [], + command: [], + project: "", + projectMeta: undefined, + icon: undefined, + provider_ready: true, + provider, + config: {}, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + session: [], + sessionTotal: 0, + session_status: {}, + session_working(id: string) { + return this.session_status[id]?.type !== "idle" + }, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp_ready: true, + mcp: {}, + lsp_ready: true, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + part: {}, + part_text_accum_delta: {}, + }) + + await bootstrapDirectory({ + directory: "/project", + global: { + config: {} satisfies Config, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + project: [{ id: "project", worktree: "/project" } as Project], + provider, + }, + sdk: { + app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, + config: { get: async () => ({ data: {} }) }, + session: { status: async () => ({ data: {} }) }, + vcs: { get: async () => ({ data: undefined }) }, + command: { list: async () => ({ data: [] }) }, + permission: { list: async () => ({ data: [] }) }, + question: { list: async () => ({ data: [] }) }, + mcp: { status: async () => ({ data: {} }) }, + provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, + } as unknown as OpencodeClient, + store, + setStore, + vcsCache: { setStore() {} } as unknown as VcsCache, + loadSessions() {}, + translate: (key) => key, + queryClient: new QueryClient(), + }) + + expect(store.status).toBe("partial") + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(store.status).toBe("complete") + }) +}) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 655f65a6768c..971932fb0a65 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -220,6 +220,7 @@ export async function bootstrapDirectory(input: { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } + if (loading) input.setStore("status", "partial") const rev = (providerRev.get(input.directory) ?? 0) + 1 providerRev.set(input.directory, rev) @@ -326,5 +327,7 @@ export async function bootstrapDirectory(input: { description: formatServerError(slowErrs[0], input.translate), }) } + + if (loading && slowErrs.length === 0) input.setStore("status", "complete") })() } diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index bb8eb7ce7ff6..89d6ee1d8e5e 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -1,10 +1,63 @@ -import { describe, expect, test } from "bun:test" -import { createRoot, getOwner } from "solid-js" +import { beforeAll, describe, expect, mock, test } from "bun:test" +import { createRoot, getOwner, type Owner } from "solid-js" import { createStore } from "solid-js/store" +import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import type { State } from "./types" -import { createChildStoreManager } from "./child-store" +import type { QueryOptionsApi } from "../global-sync" + +let createChildStoreManager: typeof import("./child-store").createChildStoreManager const child = () => createStore({} as State) +const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse + +const queryOptionsApi = { + globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }), + projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }), + providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }), + path: (directory: string | null) => ({ + queryKey: [directory, "path"], + queryFn: async () => ({ + state: "", + config: "", + worktree: "", + directory: directory ?? "", + home: "", + }), + }), + agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), + mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), + lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), + sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), +} as unknown as QueryOptionsApi + +function createOwner(callback: (owner: Owner) => void) { + return createRoot((dispose) => { + const owner = getOwner() + if (!owner) throw new Error("owner required") + callback(owner) + + return dispose + }) +} + +beforeAll(async () => { + mock.module("@/utils/persist", () => ({ + Persist: { + workspace: (...parts: string[]) => parts.join(":"), + }, + persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true], + })) + mock.module("@tanstack/solid-query", () => ({ + useQueries: () => [ + { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } }, + { isLoading: false, data: {} }, + { isLoading: false, data: [] }, + { isLoading: false, data: provider }, + ], + })) + + createChildStoreManager = (await import("./child-store")).createChildStoreManager +}) describe("createChildStoreManager", () => { test("does not evict the active directory during mark", () => { @@ -22,8 +75,8 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, - queryOptions: {} as any, - global: { provider: null! }, + queryOptions: queryOptionsApi, + global: { provider }, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { @@ -37,4 +90,35 @@ describe("createChildStoreManager", () => { expect(manager.children[directory]).toBeDefined() }) + + test("starts new child stores as loading and bootstraps them on first access", () => { + const bootstraps: string[] = [] + let manager: ReturnType | undefined + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap(directory) { + bootstraps.push(directory) + }, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + + const [store] = manager.child("/project") + + expect(store.status).toBe("loading") + expect(bootstraps).toEqual(["/project"]) + } finally { + dispose() + } + }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 56935ccc996c..40c3c3ae924e 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -202,7 +202,7 @@ export function createChildStoreManager(input: { return { state: "", config: "", worktree: "", directory: "", home: "" } return pathQuery.data }, - status: "complete" as const, + status: "loading" as const, agent: [], command: [], session: [], diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 0329568847da..00c6cd3ca678 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,5 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { createMemo, For, Match, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" @@ -18,7 +18,6 @@ import { DateTime } from "luxon" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSelectModel } from "@/components/dialog-select-model" import { useServer } from "@/context/server" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -467,11 +466,6 @@ function LegacyHome() { const navigate = useNavigate() const server = useServer() const language = useLanguage() - - const [promptText, setPromptText] = createSignal("") - const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist") - const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false) - const homedir = createMemo(() => sync.data.path.home) const recent = createMemo(() => { return sync.data.project @@ -480,8 +474,6 @@ function LegacyHome() { .slice(0, 5) }) - const currentProject = createMemo(() => recent()[0]?.worktree) - const serverDotClass = createMemo(() => { const healthy = server.healthy() if (healthy === true) return "bg-icon-success-base" @@ -520,185 +512,69 @@ function LegacyHome() { } } - function handleModelSelect() { - dialog.show(() => ) - } - - function toggleAgent() { - const agents = ["frontend-specialist", "build", "general"] - const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length - setSelectedAgent(agents[nextIndex]) - } - - function handleSubmit() { - const projectToOpen = currentProject() - if (projectToOpen) { - openProject(projectToOpen) - } else { - chooseProject() - } - } - - const activeModelName = createMemo(() => { - const model = sync.data.config.model - if (!model) return "GPT-5.7 Pro" - const parts = model.split("/") - return parts[parts.length - 1] - }) - return ( -
-
-
- -
- -
- +
+ + - 0}> -
-
{language.t("session.new.title")}
- -
-