Compare commits

...
Author SHA1 Message Date
rekram1-node 7dc227b35a feat(session): allow metadata updates 2026-09-19 18:05:37 +00:00
Aiden Cline 728b2b6052 fix(core): honor session permissions in skill and MCP discovery (#50015) 2026-09-19 12:59:09 -05:00
Aiden Cline cc3ce20ab4 feat(codemode): add the Headers built-in (#49280) 2026-09-19 12:35:14 -05:00
Aiden Cline 4ad5001be2 chore(core): refresh bundled models.dev snapshot (#50009) 2026-09-19 12:24:10 -05:00
Matt SimpsonandAiden Cline 14b3c2ea4b feat(cli): support Vite+ installations (#49624)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-19 12:16:47 -05:00
opencode-agent[bot]andrekram1-node 558bd54c9b fix(tui): use muted autocomplete label text (#50008)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-19 12:16:41 -05:00
Filip 7e70f7e1ab feat(tui): label mention autocomplete options (#48551) 2026-09-19 16:05:03 +02:00
opencode cb6d95b7ef sync release versions for v2.0.10 2026-09-19 13:40:59 +00:00
opencode-agent[bot]andBrendonovich 7020944359 fix(app): restore mobile tab interactions (#49964)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 21:00:45 +08:00
Shoubhit Dash 4b00dd2713 feat(tui): session-scoped plugin toasts (#49962) 2026-09-19 18:24:38 +05:30
Chris YangandBrendan Allan b447627f5f fix(app): keep shortcuts search field focused while typing (#49779)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 20:43:58 +08:00
94 changed files with 1225 additions and 256 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.9",
"version": "2.0.10",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,7 +406,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.9",
"version": "2.0.10",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -0,0 +1,110 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
)
await page.goto("/")
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
})
test("reorders session tabs with touch", async ({ page }) => {
const tabs = page.locator('[data-slot="vertical-tabs"] a')
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
const targetBox = await target.boundingBox()
const sourceBox = await source.boundingBox()
expect(targetBox).not.toBeNull()
expect(sourceBox).not.toBeNull()
await touchDrag(page, source, {
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
})
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
})
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
const home = drawer.getByRole("button", { name: "Home", exact: true })
const homeBox = await home.boundingBox()
const box = await tab.boundingBox()
expect(homeBox).not.toBeNull()
expect(box).not.toBeNull()
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
await tab.dispatchEvent("pointerdown", {
pointerType: "touch",
pointerId: 1,
isPrimary: true,
clientX: point.x,
clientY: point.y,
})
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
await expect(rename).toBeVisible()
await expect(drawer).toBeVisible()
await tab.filter({ visible: true }).dispatchEvent("pointerup", {
pointerType: "touch",
pointerId: 1,
isPrimary: true,
clientX: point.x,
clientY: point.y,
})
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
await expect(rename).toBeHidden()
await expect(drawer).toBeVisible()
})
async function touchDrag(
page: Page,
source: Locator,
input: { from: { x: number; y: number }; to: { x: number; y: number } },
) {
const client = await page.context().newCDPSession(page)
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...input.from, id: 1 }],
})
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
})
await expect(source).toHaveCount(2)
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ ...input.to, id: 1 }],
})
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.9",
"version": "2.0.10",
"description": "",
"type": "module",
"exports": {
@@ -1,7 +1,8 @@
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
@@ -17,8 +18,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon })))
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
import { canOpenTabRename, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
@@ -24,10 +24,4 @@ describe("titlebar tab gestures", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -4,10 +4,6 @@ export function isTabCloseTarget(target: EventTarget | null) {
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
}
export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch"
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element)
}
@@ -328,6 +328,7 @@ export function TabNavItem(props: {
return (
<Menu.Context
modal={false}
onOpenChange={(open) => {
setMenu("open", open)
if (open) setPopoverOpen(false)
@@ -16,7 +16,7 @@ import { useTabs } from "@/shell/tabs/tabs"
import { createTabComposerState } from "@/composer/persistence"
import { base64Encode } from "@opencode/util/encode"
import { showToast } from "@/shell/notifications/toast"
import { canStartTabDrag, isTabCloseTarget } from "./tab-gesture"
import { isTabCloseTarget } from "./tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
import type { SessionInfo } from "@opencode/client/promise"
@@ -292,9 +292,11 @@ export function TitlebarTabStrip(props: {
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Distance({ value: 8 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!canStartTabDrag(event.pointerType) ||
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
@@ -313,6 +315,7 @@ export function TitlebarTabStrip(props: {
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
if (vertical()) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
}}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"bin": {
+20 -3
View File
@@ -1,17 +1,22 @@
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "brew"] as const
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
@@ -110,13 +115,20 @@ const make = Effect.gen(function* () {
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
{ method: "vp", command: ["vp", "list", "-g", "--json", installedPackage] },
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
return results.find((result) => {
if (result.check.method !== "vp") return result.result.stdout.includes(installedPackage)
// Vite+ repeats the filter in its successful no-match message, so substring detection would be a false positive.
return Option.exists(decodeVpPackages(result.result.stdout), (packages) =>
packages.some((item) => item.name === installedPackage),
)
})?.check.method
})
const removal = (method: Method) => {
@@ -126,6 +138,7 @@ const make = Effect.gen(function* () {
pnpm: ["pnpm", "remove", "--global", installedPackage],
bun: ["bun", "remove", "--global", installedPackage],
yarn: ["yarn", "global", "remove", installedPackage],
vp: ["vp", "uninstall", "-g", installedPackage],
}
const command = commands[method]
return {
@@ -194,6 +207,10 @@ const make = Effect.gen(function* () {
],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
vp:
installedPackage && packageName !== installedPackage
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
+46 -4
View File
@@ -19,6 +19,7 @@ function fixture(
} = () => ({}),
name = "@opencode/cli",
failCleanup = false,
releasePackage = name,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -34,7 +35,7 @@ function fixture(
yield* Effect.acquireRelease(
Effect.sync(() =>
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), {
preconnect: fetch.preconnect,
}),
),
@@ -106,6 +107,7 @@ const installs = [
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
@@ -117,6 +119,25 @@ installs.forEach(({ method, command }) => {
}),
)
})
it.live("vp force-installs a renamed V2 package that replaces the existing binary owner", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", false, "@opencode/cli-node")
yield* test.updater.upgrade("vp", "v2.3.4-beta.1")
expect(test.commands).toEqual([["vp", "install", "-g", "--force", "@opencode/cli-node@2.3.4-beta.1"]])
}),
)
it.live("vp removes the package from its managed global store", () =>
Effect.gen(function* () {
const test = yield* fixture()
const removal = test.updater.removal("vp")
if (!removal) return yield* Effect.die("Expected vp removal command")
expect(removal.command).toEqual(["vp", "uninstall", "-g", "@opencode/cli"])
yield* removal.run
expect(test.commands).toEqual([["vp", "uninstall", "-g", "@opencode/cli"]])
}),
)
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
@@ -200,11 +221,19 @@ it.live("install failures expose stderr and process errors do not report success
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
;(["npm", "pnpm", "bun", "yarn", "vp", undefined] as const).forEach((method) => {
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === method ? "@opencode/cli@2.3.4" : "opencode-ai@1.0.0"),
stdout: Buffer.from(
command.command === method
? method === "vp"
? JSON.stringify([{ name: "@opencode/cli", version: "2.3.4" }])
: "@opencode/cli@2.3.4"
: command.command === "vp"
? "[]"
: "opencode-ai@1.0.0",
),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
@@ -212,6 +241,7 @@ it.live("install failures expose stderr and process errors do not report success
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli"],
])
}),
)
@@ -225,7 +255,16 @@ it.live("method detection tolerates unavailable package managers", () =>
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(4)
expect(test.commands).toHaveLength(5)
}),
)
it.live("vp detection ignores no-match output that repeats the package name", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "vp" ? "No global packages matching '@opencode/cli'." : ""),
}))
expect(yield* test.updater.method()).toBeUndefined()
}),
)
@@ -265,13 +304,16 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
yield* test.updater.upgrade("vp", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli-node"],
["npm", "install", "--global", "@opencode/cli-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
])
}),
)
+6
View File
@@ -35,6 +35,12 @@ describe("upgrade command", () => {
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
})
test("accepts vp as an explicit installation method", async () => {
const result = await cli(["2.0.0", "--method", "vp"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "vp", version: "2.0.0" }])
})
test("skips the already installed version", async () => {
const result = await cli(["v0.0.0-beta-old"])
expect(result.exitCode).toBe(0)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+15
View File
@@ -241,6 +241,7 @@ export type SessionSwitchModelOperation<E = never> = (
export type SessionUpdateInput = {
readonly sessionID: Session.ID
readonly title?: string | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionUpdateOutput = void
@@ -516,6 +517,20 @@ export type SessionLogOutput =
| undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.metadata.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?:
| {
readonly directory: AbsolutePath
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
}
| undefined
readonly data: { readonly sessionID: Session.ID; readonly metadata: Session.Metadata }
}
| {
readonly id: Event.ID
readonly created: number
@@ -453,7 +453,7 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
preserveEffect<SessionUpdateOutput>()(
raw["session.update"]({
params: { sessionID: input["sessionID"] },
payload: { title: input["title"], permissions: input["permissions"] },
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -662,7 +662,7 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
body: { title: input["title"], permissions: input["permissions"] },
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -1221,6 +1221,16 @@ export type SessionMoved = {
export type SessionInboxMovePayload1 = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMetadataUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.metadata.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; metadata: SessionMetadata }
}
export type SessionShellStarted = {
id: string
created: number
@@ -2308,6 +2318,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionMetadataUpdated
| SessionPermissions
| SessionViewed
| SessionDeleted
@@ -2370,6 +2381,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionMetadataUpdated
| SessionPermissions
| SessionViewed
| SessionUsageUpdated
@@ -3946,12 +3958,21 @@ export type SessionUpdateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
}["title"]
readonly metadata?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
}["metadata"]
readonly permissions?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
+14 -6
View File
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
@@ -94,11 +94,19 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Values`
### `Extension.make`
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
```ts
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })
```
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
extension throws at `CodeMode.make`.
### OpenAPI tools
+19 -13
View File
@@ -28,7 +28,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
@@ -44,8 +44,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
synchronous iterators, and synchronous generators.
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
@@ -92,8 +92,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
@@ -124,7 +124,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
@@ -176,10 +176,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors. Error constructors take the ES2022 options object, so
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
@@ -448,7 +448,13 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
## Extensions
@@ -458,8 +464,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.9",
"version": "2.0.10",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+2
View File
@@ -17,6 +17,7 @@ import {
record,
SetObj,
URLSearchParamsObj,
HeadersObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
@@ -69,6 +70,7 @@ const walk = <R>(
)
}
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
stack.add(target)
@@ -27,6 +27,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
import { describeValue } from "./references.js"
@@ -52,6 +53,7 @@ export const extensionGlobals = <R>(
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
@@ -125,6 +127,7 @@ export const extensionGlobals = <R>(
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -11,6 +11,7 @@ import { objectGlobal } from "../stdlib/object.js"
import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -80,6 +81,7 @@ const table: Record<string, Factory> = {
Set: (ctx) => setGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
TextEncoder: (ctx) => textEncoderGlobal(ctx),
TextDecoder: (ctx) => textDecoderGlobal(ctx),
@@ -84,6 +84,7 @@ import {
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
@@ -653,7 +654,7 @@ class Frame<R> {
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
node,
)
}
@@ -756,9 +757,11 @@ class Frame<R> {
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
@@ -1848,6 +1851,7 @@ class Frame<R> {
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
@@ -29,6 +29,7 @@ const builtins = [
"Set",
"URL",
"URLSearchParams",
"Headers",
"Uint8Array",
"TextEncoder",
"TextDecoder",
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
Set: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
Uint8Array: plain(),
TextEncoder: plain(),
TextDecoder: plain(),
+11 -1
View File
@@ -156,6 +156,15 @@ export class URLSearchParamsObj extends Obj {
}
}
export class HeadersObj extends Obj {
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
}
export class URLObj extends Obj {
readonly searchParams: URLSearchParamsObj
constructor(
@@ -181,13 +190,14 @@ export class Bytes extends Obj {
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -16,6 +16,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Values that cannot cross the data boundary. */
@@ -85,6 +86,7 @@ export const describeValue = (value: unknown): string => {
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (isRuntimeReference(value)) return "a function"
+2
View File
@@ -12,6 +12,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -66,6 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof RegExpObj) return coerceToString(value)
if (value instanceof URLObj) return coerceToString(value)
if (value instanceof URLSearchParamsObj) return coerceToString(value)
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
+119
View File
@@ -0,0 +1,119 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
import { readPairs } from "./url.js"
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
const attempt = <T>(run: () => T): T => {
try {
return run()
} catch (error) {
throw typeError(error instanceof Error ? error.message : String(error))
}
}
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
if (init === undefined) return Effect.succeed(wrap(new Headers()))
return Effect.gen(function* () {
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
if (!(init instanceof Obj) || isRuntimeReference(init)) {
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
}
return wrap(
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
)
})
}
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.Headers
const headers = constructor<R>(builtins, proto, {
name: "Headers",
call: requiresNew("Headers"),
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
methods(builtins, proto, [
[
"append",
2,
(thisValue, args) => {
requireArgs("append", args, 2)
const target = self(thisValue, "append").headers
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
},
],
[
"delete",
1,
(thisValue, args) => {
requireArgs("delete", args, 1)
const target = self(thisValue, "delete").headers
return attempt(() => target.delete(arg(args, 0)))
},
],
[
"get",
1,
(thisValue, args) => {
requireArgs("get", args, 1)
const target = self(thisValue, "get").headers
return attempt(() => target.get(arg(args, 0)))
},
],
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
[
"has",
1,
(thisValue, args) => {
requireArgs("has", args, 1)
const target = self(thisValue, "has").headers
return attempt(() => target.has(arg(args, 0)))
},
],
[
"set",
2,
(thisValue, args) => {
requireArgs("set", args, 2)
const target = self(thisValue, "set").headers
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
],
[
"forEach",
1,
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
return undefined
})
},
],
])
return headers
}
+27 -18
View File
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
@@ -126,6 +124,29 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
}
})
/**
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
* WebIDL, the whole sequence is converted before any pair's length is checked.
*/
export const readPairs = <R>(
ctx: Interpreter<R>,
init: unknown,
label: string,
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor === undefined) return undefined
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
return pairs as Array<[string, string]>
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
}
})
const constructURLSearchParams = <R>(
ctx: Interpreter<R>,
init: unknown,
@@ -139,20 +160,8 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
}
}
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
+2
View File
@@ -13,6 +13,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof SetObj) return "[object Set]"
if (value instanceof URLObj) return value.url.href
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return "[object Headers]"
if (value instanceof Bytes) return value.bytes.join(",")
if (value instanceof ErrorObj) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
+28
View File
@@ -128,6 +128,34 @@ describe("values are converted at the boundary, never shared", () => {
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
})
test("Headers cross as copies in both directions", async () => {
const stored = new Headers({ "X-A": "1" })
const target = CodeMode.make({
extensions: [
Extension.make({
name: "http",
globals: {
headers: () => stored,
keep: (value: Headers) => {
held.push(value)
return value
},
},
}),
],
})
held.length = 0
expect(
await value(
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
target,
),
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
expect(stored.get("x-a")).toBe("1")
expect(held[0]).toBeInstanceOf(Headers)
expect((held[0] as Headers).get("x-a")).toBe("2")
})
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
const stored = new Uint8Array([1, 2, 3])
const target = CodeMode.make({
+148
View File
@@ -635,6 +635,154 @@ describe("URL and URI helpers", () => {
})
})
describe("Headers", () => {
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
expect(
await value(`
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
headers.append("Accept", "text/html")
headers.append("accept", "application/json")
headers.set("x-count", "2")
headers.delete("x-null")
const copy = new Headers(headers)
copy.set("content-type", "text/html")
return {
get: headers.get("content-type"),
missing: headers.get("x-missing"),
combined: headers.get("ACCEPT"),
has: [headers.has("Accept"), headers.has("x-null")],
count: headers.get("x-count"),
copied: [headers.get("content-type"), copy.get("content-type")],
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
map: [...new Headers(new Map([["k", "v"]]))],
keys: headers.keys(),
values: headers.values(),
entries: headers.entries(),
}
`),
).toEqual({
get: "text/plain",
missing: null,
combined: "text/html, application/json",
has: [true, false],
count: "2",
copied: ["text/plain", "text/html"],
pairs: [
["a", "1"],
["b", "2"],
],
map: [["k", "v"]],
keys: ["accept", "content-type", "x-count"],
values: ["text/html, application/json", "text/plain", "2"],
entries: [
["accept", "text/html, application/json"],
["content-type", "text/plain"],
["x-count", "2"],
],
})
})
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
expect(
await value(`
const headers = new Headers({ b: "2", a: "1" })
headers.append("Set-Cookie", "x=1")
headers.append("set-cookie", "y=2")
const seen = []
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
const [first] = headers
function* pairs() { yield* headers }
return {
seen,
first,
spread: [...headers],
from: Array.from(headers).length,
generator: [...pairs()].length,
object: Object.fromEntries(headers),
cookies: headers.getSetCookie(),
}
`),
).toEqual({
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
first: ["a", "1"],
spread: [
["a", "1"],
["b", "2"],
["set-cookie", "x=1"],
["set-cookie", "y=2"],
],
from: 4,
generator: 4,
object: { a: "1", b: "2", "set-cookie": "y=2" },
cookies: ["x=1", "y=2"],
})
})
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
const result = await run(`
const headers = new Headers({ "X-A": "1", b: "2" })
console.log(headers)
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
`)
expect(result.ok && result.value).toEqual({
headers: { b: "2", "x-a": "1" },
json: '{"headers":{"b":"2","x-a":"1"}}',
text: "[object Headers]",
type: "object",
is: true,
})
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
})
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
expect(
await value(`
function message(run) {
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
}
const headers = new Headers()
return [
message(() => Headers()),
message(() => new Headers(null)),
message(() => new Headers(1)),
message(() => new Headers("a=1")),
message(() => new Headers(new Date())),
message(() => new Headers(() => 1)),
message(() => new Headers([["name"]])),
message(() => new Headers([["a", "b", "c"]])),
message(() => new Headers({ "bad name": "x" })),
message(() => new Headers({ name: "bad\u0000value" })),
message(() => headers.get("invalid\u0100")),
message(() => headers.has({})),
message(() => headers.set("a", "invalid\u0100")),
message(() => headers.append("a")),
message(() => headers.forEach()),
message(() => headers.forEach(1)),
message(() => { const get = headers.get; return get("a") }),
]
`),
).toEqual([
"Constructor Headers requires 'new'.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects iterable [name, value] pairs.",
"new Headers(...) expects iterable [name, value] pairs.",
expect.stringContaining("bad name"),
expect.stringContaining("invalid value"),
expect.stringContaining("Invalid header name"),
expect.stringContaining("[object Object]"),
expect.stringContaining("invalid value"),
"Headers.append requires 2 arguments.",
"Headers.forEach requires 1 argument.",
"Headers.forEach expects a function callback.",
"Headers.prototype.get called on incompatible receiver undefined.",
])
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
+225
View File
@@ -3,10 +3,13 @@
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
* - WebCryptoAPI/randomUUID.https.any.js
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
* arrays), or a custom iterator on a Headers instance are left out.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -166,3 +169,225 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
).toEqual([true, true, true, 768])
})
})
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
const testharness = `
const failures = []
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
function assert_true(actual, message) { assert_equals(actual, true, message) }
function assert_false(actual, message) { assert_equals(actual, false, message) }
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
function assert_unreached() { throw new Error("unreachable") }
`
describe("Headers WPT parity (fetch/api/headers)", () => {
test("headers-basic.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { new Headers() }, "Create headers from no parameter")
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
test(function() { new Headers({}) }, "Create headers from empty object")
var parameters = [null, 1]
parameters.forEach(function(parameter) {
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
})
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
var headerSeq = []
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
test(function() {
var headers = new Headers(headerSeq)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
}, "Create headers with sequence")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with record")
test(function() {
var headers = new Headers(headerDict)
var headers2 = new Headers(headers)
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with existing headers")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.append(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check append method")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.set(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check set method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
}, "Check has method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) {
assert_true(headers.has(name), "headers have a header: " + name)
headers.delete(name)
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
}
}, "Check delete method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
}, "Check get method")
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
var sortedHeaderDict = {}
var headerValues = []
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
headerValues.push(headerEntriesDict[value])
return value.toLowerCase()
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.keys(), sortedHeaderKeys)
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
}, "Check keys method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
}, "Check values method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
}, "Check entries method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
}, "Check Symbol.iterator method")
test(function() {
var headers = new Headers(headerEntriesDict)
var index = 0
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
assert_equals(key, sortedHeaderKeys[index])
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
index++
})
assert_equals(index, sortedHeaderKeys.length)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
headers.delete("foo")
}
assert_array_equals(actualKeys, ["bar", "baz"])
assert_array_equals(actualValues, ["0", "1"])
}, "Iteration skips elements removed while iterating")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.delete("bar")
}
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
assert_array_equals(actualValues, ["0", "1", "3"])
}, "Removing elements already iterated over causes an element to be skipped during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("X-yZ", "4")
}
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
}, "Appending a value pair during iteration causes it to be reached during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("abc", "-1")
}
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
return failures
`),
).toEqual([])
})
test("headers-errors.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
var badNames = ["invalid\u0100", {}]
var badValues = ["invalid\u0100"]
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
})
test(function() {
var headers = new Headers([["name", "value"]])
assert_throws_js(TypeError, function() { headers.forEach() })
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
assert_throws_js(TypeError, function() { headers.forEach(1) })
}, "Headers forEach throws if argument is not callable")
test(function() {
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
var counter = 0
try {
headers.forEach(function(value, name) {
counter++
if (name == "name2") throw "error"
})
} catch (e) {
assert_equals(counter, 2)
assert_equals(e, "error")
return
}
assert_unreached()
}, "Headers forEach loop should stop if callback is throwing exception")
return failures
`),
).toEqual([])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.9",
"version": "2.0.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.9",
"version": "2.0.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.9",
"version": "2.0.10",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+6 -8
View File
@@ -2,7 +2,6 @@ export * as McpInstructions from "./instructions.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { McpTool } from "../tool/mcp.js"
import { Mcp } from "./index.js"
@@ -55,7 +54,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
}
export interface Interface {
readonly load: (agent: Agent.Selection) => Effect.Effect<Instructions.List>
/** Lists server instructions reachable under the given ruleset; callers pass the merged agent and Session permissions. */
readonly load: (permissions: Permission.Ruleset) => Effect.Effect<Instructions.List>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
@@ -66,9 +66,7 @@ export const layer = Layer.effect(
const mcp = yield* Mcp.Service
return Service.of({
load: Effect.fn("McpInstructions.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
load: Effect.fn("McpInstructions.load")(function* (permissions) {
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
Instructions.make<ReadonlyArray<Summary>>({
key: Instructions.Key.make("core/mcp-guidance"),
@@ -83,8 +81,8 @@ export const layer = Layer.effect(
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
const canExecute = Permission.evaluate("execute", "*", agent.permissions).effect !== "deny"
// Instructions are useful only when this agent can reach at least one server tool.
const canExecute = Permission.evaluate("execute", "*", permissions).effect !== "deny"
// Instructions are useful only when this Session can reach at least one server tool.
const visible = instructions
.flatMap((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
@@ -93,7 +91,7 @@ export const layer = Layer.effect(
if (
!owned.some(
(tool) =>
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", permissions).effect !== "deny",
)
)
return []
File diff suppressed because one or more lines are too long
+5
View File
@@ -170,6 +170,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setMetadata: (input: {
sessionID: SessionSchema.ID
metadata: SessionSchema.Metadata
}) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
@@ -417,6 +421,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setMetadata: (input) => sessions.forSession(input.sessionID).setMetadata(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
+5 -3
View File
@@ -127,14 +127,16 @@ const layer = Layer.effect(
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
// Session permissions narrow discovery the same way they narrow the tool snapshot.
const permissions = Permission.merge(agent.info.permissions, session.permissions ?? [])
const loaded = yield* Effect.all(
{
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
tools: registry.snapshot(permissions),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
skills: skillInstructions.load(permissions),
references: referenceInstructions.load(),
mcp: mcpInstructions.load(agent),
mcp: mcpInstructions.load(permissions),
entries: entries.load(sessionID),
},
{ concurrency: "unbounded" },
@@ -131,6 +131,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.metadata.updated": () => Effect.void,
"session.permissions": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
+8
View File
@@ -573,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.MetadataUpdated, (event) =>
db
.update(SessionTable)
.set({ metadata: event.data.metadata, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Permissions, (event) =>
db
.update(SessionTable)
+10
View File
@@ -73,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setMetadata = Effect.fn("Session.setMetadata")(function* (
sessionID: SessionSchema.ID,
input: { metadata: SessionSchema.Metadata },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.MetadataUpdated, { sessionID, metadata: input.metadata })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
@@ -342,6 +349,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setMetadata,
setPermissions,
switchAgent,
switchModel,
@@ -365,6 +373,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setMetadata = operations.setMetadata.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
@@ -391,6 +400,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setMetadata,
setPermissions,
switchAgent,
switchModel,
+2 -3
View File
@@ -4,7 +4,6 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
import type { FSUtil } from "@opencode/util/fs-util"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import type { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Bus } from "./bus.js"
import { Permission } from "./permission.js"
@@ -31,8 +30,8 @@ export type Name = Skill.Name
export { Event } from "@opencode/schema/skill"
export const available = (skills: ReadonlyArray<Info>, agent: Agent.Info) =>
skills.filter((skill) => Permission.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
export const available = (skills: ReadonlyArray<Info>, permissions: Permission.Ruleset) =>
skills.filter((skill) => Permission.evaluate("skill", skill.id, permissions).effect !== "deny")
export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
const directory = path.dirname(skill.path)
+5 -6
View File
@@ -2,7 +2,7 @@ export * as SkillInstructions from "./instructions.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { Skill } from "../skill.js"
import { Instructions } from "../instructions/index.js"
@@ -58,7 +58,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
}
export interface Interface {
readonly load: (agent: Agent.Selection) => Effect.Effect<Instructions.List>
/** Lists skills the given ruleset does not deny; callers pass the merged agent and Session permissions. */
readonly load: (permissions: Permission.Ruleset) => Effect.Effect<Instructions.List>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillInstructions") {}
@@ -69,10 +70,8 @@ const layer = Layer.effect(
const skills = yield* Skill.Service
return Service.of({
load: Effect.fn("SkillInstructions.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const available = Skill.available(yield* skills.list(), agent)
load: Effect.fn("SkillInstructions.load")(function* (permissions) {
const available = Skill.available(yield* skills.list(), permissions)
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
? []
+22 -26
View File
@@ -1,23 +1,14 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@opencode/core/agent"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Mcp } from "@opencode/core/mcp/index"
import { McpInstructions } from "@opencode/core/mcp/instructions"
import { Permission } from "@opencode/core/permission"
import { McpTool } from "@opencode/core/tool/mcp"
import { it } from "./lib/effect"
import { readInitial, readUpdate } from "./lib/instructions"
const build = Agent.ID.make("build")
const selection = (permissions: Permission.Ruleset = []) => {
const info = Agent.Info.make({ ...Agent.Info.default(build), permissions })
return { id: info.id, info }
}
const instructions = (server: string, text: string) =>
({ server: Mcp.ServerName.make(server), instructions: text } satisfies Mcp.ServerInstructions)
({ server: Mcp.ServerName.make(server), instructions: text }) satisfies Mcp.ServerInstructions
const schema = { type: "object" as const }
const tool = (server: string, name = "search") =>
@@ -38,12 +29,10 @@ describe("McpInstructions", () => {
Effect.gen(function* () {
const service = yield* McpInstructions.Service
const generation = yield* service
.load(
selection([
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
]),
)
.load([
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
])
.pipe(Effect.flatMap(readInitial))
expect(generation.text).toBe(
@@ -80,7 +69,7 @@ describe("McpInstructions", () => {
Effect.gen(function* () {
const service = yield* McpInstructions.Service
const generation = yield* service
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
.load([{ action: "execute", resource: "*", effect: "deny" }])
.pipe(Effect.flatMap(readInitial))
expect(generation.text).toBe("")
@@ -98,7 +87,7 @@ describe("McpInstructions", () => {
Effect.gen(function* () {
const service = yield* McpInstructions.Service
const generation = yield* service
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
.load([{ action: "execute", resource: "*", effect: "deny" }])
.pipe(Effect.flatMap(readInitial))
expect(generation.text).toBe(
@@ -114,7 +103,14 @@ describe("McpInstructions", () => {
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [({ server: Mcp.ServerName.make("alpha"), name: "search", inputSchema: schema, codemode: false }) satisfies Mcp.Tool],
() => [
{
server: Mcp.ServerName.make("alpha"),
name: "search",
inputSchema: schema,
codemode: false,
} satisfies Mcp.Tool,
],
),
),
),
@@ -124,10 +120,10 @@ describe("McpInstructions", () => {
let tools: Mcp.Tool[] = [tool("alpha")]
return Effect.gen(function* () {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
tools = [{ ...tool("alpha"), codemode: false }]
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
const changed = yield* readUpdate(yield* service.load([]), initialized)
expect(changed.text).toBe(
[
"The available MCP server instructions have changed. This list supersedes the previous one.",
@@ -153,10 +149,10 @@ describe("McpInstructions", () => {
const tools = [tool("alpha"), tool("beta")]
return Effect.gen(function* () {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]
const added = yield* readUpdate(yield* service.load(selection()), initialized)
const added = yield* readUpdate(yield* service.load([]), initialized)
expect(added.text).toBe(
[
"New MCP server instructions are available in addition to those previously listed:",
@@ -168,7 +164,7 @@ describe("McpInstructions", () => {
)
catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]
const changed = yield* readUpdate(yield* service.load(selection()), added)
const changed = yield* readUpdate(yield* service.load([]), added)
expect(changed.text).toBe(
[
"The available MCP server instructions have changed. This list supersedes the previous one.",
@@ -186,11 +182,11 @@ describe("McpInstructions", () => {
)
catalog = [instructions("beta", "Beta instructions")]
const removed = yield* readUpdate(yield* service.load(selection()), changed)
const removed = yield* readUpdate(yield* service.load([]), changed)
expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.")
catalog = []
expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe(
expect((yield* readUpdate(yield* service.load([]), removed)).text).toBe(
"MCP server instructions are no longer available.",
)
}).pipe(
+21 -1
View File
@@ -8,7 +8,7 @@ import { Money } from "@opencode/schema/money"
import { Shell } from "@opencode/schema/shell"
import { Skill } from "@opencode/schema/skill"
import { Agent } from "@opencode/core/agent"
import { asc, eq } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -385,6 +385,26 @@ describe("Session.create", () => {
// Absent stays absent: no empty-object normalization.
expect((yield* session.create({ location })).metadata).toBeUndefined()
const replacement = { thread: "updated", owner: "host" }
yield* session.setMetadata({ sessionID: created.id, metadata: replacement })
expect((yield* session.get(created.id)).metadata).toEqual(replacement)
expect(
yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, created.id),
eq(EventTable.type, Bus.versionedType(SessionEvent.MetadataUpdated.type, 1)),
),
)
.get()
.pipe(Effect.orDie),
).toMatchObject({ data: { metadata: replacement } })
expect(
yield* session.setMetadata({ sessionID: Session.ID.create(), metadata: replacement }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
+23 -11
View File
@@ -362,21 +362,27 @@ const layer = Layer.unwrap(
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, {
load: (agent) =>
Effect.succeed(
state.skillBaselines.has(agent.id)
? Instructions.make({
load: (permissions) => {
// `setup` tags each agent's ruleset with a `skill` rule naming that agent.
const baseline = permissions
.filter((rule) => rule.action === "skill")
.map((rule) => state.skillBaselines.get(Agent.ID.make(rule.resource)))
.findLast((text) => text !== undefined)
return Effect.succeed(
baseline === undefined
? Instructions.empty
: Instructions.make({
key: Instructions.Key.make("test/skill-guidance"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.succeed(state.skillBaselines.get(agent.id)!),
read: Effect.succeed(baseline),
render: {
initial: String,
changed: (_previous, current) => current,
removed: () => "Skill guidance removed",
},
})
: Instructions.empty,
),
}),
)
},
})
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
@@ -526,11 +532,17 @@ const setup = Effect.gen(function* () {
})
yield* IdentityPlugin.Plugin.effect(pluginHost)
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
yield* agents.transform((editor) =>
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
}),
)
})
// Skill instructions receive only a ruleset, so tag each agent with a rule naming itself for the mock.
for (const id of ["build", "reviewer"]) {
editor.update(Agent.ID.make(id), (agent) => {
agent.permissions.push({ action: "skill", resource: id, effect: "allow" })
})
}
})
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
+1 -1
View File
@@ -119,7 +119,7 @@ describe("Skill", () => {
}),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
expect(Skill.available([info("deploy", "Deploy")], agent!.permissions)).toEqual([])
}),
)
})
+11 -11
View File
@@ -53,7 +53,7 @@ describe("SkillInstructions", () => {
let skills = [hidden, denied, manual, effect]
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
expect(initialized.text).toBe(
[
@@ -74,7 +74,7 @@ describe("SkillInstructions", () => {
skills = []
expect(
yield* instructions
.load({ id: agent.id, info: agent })
.load(agent.permissions)
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." })
}).pipe(Effect.provide(layer(() => skills)))
@@ -92,11 +92,11 @@ describe("SkillInstructions", () => {
let skills = [effect]
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
skills = [effect, debugging]
const added = yield* instructions
.load({ id: agent.id, info: agent })
.load(agent.permissions)
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
expect(added.text).toBe(
[
@@ -111,7 +111,7 @@ describe("SkillInstructions", () => {
skills = [debugging]
const removed = yield* instructions
.load({ id: agent.id, info: agent })
.load(agent.permissions)
.pipe(Effect.flatMap((context) => readUpdate(context, added)))
expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.")
}).pipe(Effect.provide(layer(() => skills)))
@@ -122,12 +122,12 @@ describe("SkillInstructions", () => {
let skills = [effect]
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
skills = [Skill.Info.make({ ...effect, description: "Build applications with Effect v4" })]
expect(
yield* instructions
.load({ id: agent.id, info: agent })
.load(agent.permissions)
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({
text: expect.stringContaining(
@@ -144,7 +144,7 @@ describe("SkillInstructions", () => {
})
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
@@ -158,7 +158,7 @@ describe("SkillInstructions", () => {
})
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
@@ -173,7 +173,7 @@ describe("SkillInstructions", () => {
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
expect(
(yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text,
(yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text,
).toContain("<name>Effect</name>")
}).pipe(Effect.provide(layer(() => [effect])))
})
@@ -189,7 +189,7 @@ describe("SkillInstructions", () => {
})
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.9",
"version": "2.0.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.9",
"version": "2.0.10",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.9",
"version": "2.0.10",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+2
View File
@@ -273,6 +273,8 @@ export interface ToastOptions {
readonly message: string
readonly variant?: ToastVariant
readonly duration?: number
/** When this session's family is not open, the title defaults to the session title and the toast offers to open it. */
readonly sessionID?: string
}
export interface Toast {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+1
View File
@@ -359,6 +359,7 @@ export const makeSessionGroup = <
params: { sessionID: Session.ID },
payload: Schema.Struct({
title: Schema.String.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+11
View File
@@ -111,6 +111,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const MetadataUpdated = Event.durable({
type: "session.metadata.updated",
...options,
schema: {
...Base,
metadata: SessionMetadata,
},
})
export type MetadataUpdated = typeof MetadataUpdated.Type
export const Permissions = Event.durable({
type: "session.permissions",
...options,
@@ -648,6 +658,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
MetadataUpdated,
Permissions,
Viewed,
UsageUpdated,
+3 -3
View File
@@ -1,9 +1,9 @@
import { Schema } from "effect"
/**
* Host-supplied session annotations, durable from creation and opaque to
* core. Keys are arbitrary; values must be JSON-serializable. Children and
* forks inherit the parent's metadata unless the creator supplies its own.
* Host-supplied session annotations, durable and opaque to core. Keys are
* arbitrary; values must be JSON-serializable. Children and forks inherit
* the parent's current metadata unless the creator supplies its own.
*/
export const SessionMetadata = Schema.Record(Schema.String, Schema.Json).annotate({
identifier: "Session.Metadata",
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.metadata.updated.1",
"session.permissions.1",
"session.viewed.1",
"session.message.content.updated.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.9",
"version": "2.0.10",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+4
View File
@@ -266,6 +266,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
yield* title.generate(ctx.params.sessionID)
}
}
if (ctx.payload.metadata !== undefined)
yield* session
.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
if (ctx.payload.permissions !== undefined)
yield* session
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
@@ -0,0 +1,38 @@
import { expect } from "bun:test"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) })
it.live("updates session metadata through PATCH", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make({
app: { version: "test" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
})
const request = (path: string, method: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
return response.status === 204 ? undefined : response.json()
})
const created = Schema.decodeUnknownSync(SessionResponse)(
yield* request("/api/session", "POST", { metadata: { source: "create", stale: true } }),
)
yield* request(`/api/session/${created.data.id}`, "PATCH", { metadata: { source: "patch" } })
const updated = Schema.decodeUnknownSync(SessionResponse)(yield* request(`/api/session/${created.data.id}`, "GET"))
expect(updated.data.metadata).toEqual({ source: "patch" })
}).pipe(Effect.scoped),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
@@ -44,7 +44,7 @@ export type AutocompleteOption = {
path?: string
absolute?: string
destructive?: { id: string; confirm: string; run: () => void }
kind?: "skill"
kind?: "skill" | "agent" | "reference"
queueable?: boolean
}
@@ -404,41 +404,13 @@ export function Autocomplete(props: {
return { options: [], failed: false, query: "", resolved: false }
})
const mcpResources = createMemo(() => {
if (store.visible !== "reference") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
for (const res of data.location.mcp.resource.list(location.current) ?? []) {
options.push({
display: Locale.truncateMiddle(res.name, width),
// Match the name only; matching the URI caused unrelated fuzzy hits.
value: res.name,
description: res.description,
onSelect: () => {
insertPart(res.name, {
type: "file",
value: {
uri: res.uri,
name: res.name,
description: res.description,
mention: { start: 0, end: 0, text: "" },
},
})
},
})
}
return options
})
const agents = createMemo(() => {
return (data.location.agent.list() ?? [])
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.id,
kind: "agent",
onSelect: () => {
insertPart(agent.id, {
type: "agent",
@@ -474,6 +446,7 @@ export function Autocomplete(props: {
.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
kind: "reference",
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
onSelect: () => {
insertPart(reference.name, {
@@ -567,7 +540,7 @@ export function Autocomplete(props: {
const fileOptions: AutocompleteOption[] = store.visible === "reference" ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "reference"
? [...skillOptions(), ...referenceAliasesValue, ...agentsValue, ...mcpResources()]
? [...skillOptions(), ...referenceAliasesValue, ...agentsValue]
: store.index === 0
? [...commandsValue]
: []
@@ -869,6 +842,11 @@ export function Autocomplete(props: {
return "No matching files, agents, or references"
})
const emptyError = createMemo(() => store.visible === "reference" && !files.loading && visibleFiles().failed)
const labels = {
skill: "skill",
agent: "agent",
reference: "reference",
}
return (
<box
@@ -902,6 +880,14 @@ export function Autocomplete(props: {
>
{(option, index) => {
const destructive = () => option().destructive
const label = () => {
const kind = option().kind
return kind ? labels[kind] : undefined
}
const contentWidth = () => {
const text = label()
return Math.max(1, position().width - 4 - (text ? stringWidth(text) + 2 : 0))
}
const confirmingAction = () => {
const action = destructive()
return action !== undefined && action.id === confirming()
@@ -931,17 +917,32 @@ export function Autocomplete(props: {
: theme.text.base
}
flexShrink={0}
wrapMode="none"
>
{confirmingAction() ? destructive()?.confirm : option().display}
{Locale.truncateMiddle(
confirmingAction() ? (destructive()?.confirm ?? "") : option().display,
contentWidth(),
)}
</text>
<Show when={!confirmingAction() && option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.muted}
wrapMode="none"
flexShrink={1}
minWidth={0}
>
{" " + option().description?.replace(/\s+/g, " ").trim()}
</text>
</Show>
<Show when={!confirmingAction() && label()}>
<box flexGrow={1} minWidth={2} />
<text
flexShrink={0}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.muted}
>
{label()}
</text>
</Show>
</box>
)
}}
@@ -67,9 +67,7 @@ export default Plugin.define({
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
const route = context.ui.router.current()
if (route.type === "session" && route.sessionID === sessionID)
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" })
ended(sessionID)
}),
]
+21 -1
View File
@@ -95,7 +95,27 @@ export function createPluginContext(input: {
const dialogApi = createDialogApi(host.dialog, provide)
const toastApi: Toast = {
show(options) {
host.toast.show({ ...options, variant: options.variant ?? "info" })
const toast = {
title: options.title,
message: options.message,
variant: options.variant ?? "info",
duration: options.duration,
}
const sessionID = options.sessionID
if (sessionID === undefined) {
host.toast.show(toast)
return
}
const route = host.route.data
if (route.type === "session" && host.data.session.root(route.sessionID) === host.data.session.root(sessionID)) {
host.toast.show(toast)
return
}
host.toast.show({
...toast,
title: toast.title ?? host.data.session.get(sessionID)?.title,
action: { label: "Open", run: () => host.route.navigate({ type: "session", sessionID }) },
})
},
}
// Unregistering after deactivation is a no-op: deactivate already resets
@@ -146,23 +146,27 @@ const permissionNotification: AttentionNotifyOptions = {
}
describe("internal notifications TUI plugin", () => {
test("shows execution failures in the viewed session without needing an assistant message", async () => {
test("shows execution failures as session-scoped toasts without needing an assistant message", async () => {
const harness = await setup()
harness.emit(executionStarted("started"))
harness.emit(executionFailed("failed"))
harness.emit(executionFailed("duplicate"))
expect(harness.toasts).toEqual([{ title: "Session failed", message: "boom", variant: "error" }])
expect(harness.toasts).toEqual([
{ sessionID: "session", title: "Session failed", message: "boom", variant: "error" },
])
harness.emit(executionStarted("retry"))
harness.emit(executionFailed("failed-again"))
expect(harness.toasts).toHaveLength(2)
})
test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
"keeps other sessions' failures out of the current composer (%j)",
"leaves routing of other sessions' failures to the session-scoped toast (%j)",
async (route) => {
const harness = await setup(route)
harness.emit(executionFailed("failed"))
expect(harness.toasts).toEqual([])
expect(harness.toasts).toEqual([
{ sessionID: "session", title: "Session failed", message: "boom", variant: "error" },
])
expect(harness.notifications).toHaveLength(1)
},
)
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test"
import type { Route } from "@opencode/plugin/tui/context"
import { createPluginContext, type Registry, type usePluginHost } from "../src/plugin/api"
type Host = ReturnType<typeof usePluginHost>
type Shown = Parameters<Host["toast"]["show"]>[0]
const sessions: Record<string, { id: string; title?: string; parentID?: string }> = {
parent: { id: "parent", title: "Parent session" },
child: { id: "child", title: "Child session", parentID: "parent" },
other: { id: "other", title: "Other session" },
}
const root = (sessionID: string): string => {
const parentID = sessions[sessionID]?.parentID
return parentID ? root(parentID) : sessionID
}
function setup(route: Route) {
const shown: Shown[] = []
const navigated: Route[] = []
const host = {
app: { version: "test", channel: "test" },
client: { api: {} },
keymap: {},
shortcuts: {},
keymapState: {},
sessionTabs: {},
toast: { show: (toast: Shown) => shown.push(toast) },
route: {
get data() {
return route
},
navigate: (destination: Route) => navigated.push(destination),
},
data: { session: { root, get: (sessionID: string) => sessions[sessionID] } },
} as unknown as Host
const registry: Registry = { has: () => false, set() {}, remove() {}, active: () => true }
const context = createPluginContext({ host, id: "test", options: undefined, owned: [], registry })
return { shown, navigated, toast: context.ui.toast }
}
test.each([
["parent", "parent"],
["parent", "child"],
["child", "parent"],
])("toast for %s's family shows as-is while %s is open", (target, routed) => {
const harness = setup({ type: "session", sessionID: routed })
harness.toast.show({ sessionID: target, message: "done" })
expect(harness.shown).toEqual([{ title: undefined, message: "done", variant: "info", duration: undefined }])
})
test.each<Route>([{ type: "home" }, { type: "session", sessionID: "parent" }])(
"toast for a session that is not open gains its title and an Open action (%j)",
(route) => {
const harness = setup(route)
harness.toast.show({ sessionID: "other", message: "done" })
expect(harness.shown[0]).toMatchObject({ title: "Other session", action: { label: "Open" } })
harness.shown[0].action?.run()
expect(harness.navigated).toEqual([{ type: "session", sessionID: "other" }])
},
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.9",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.9",
"version": "2.0.10",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.9",
"version": "2.0.10",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"type": "module",
"scripts": {
+3 -2
View File
@@ -17,12 +17,13 @@ OpenCode is an open source AI coding agent. Its available as a terminal-based
{ label: "bun", code: "bun install -g --trust @opencode/cli" },
{ label: "pnpm", code: "pnpm add -g --allow-build=@opencode/cli @opencode/cli" },
{ label: "yarn", code: "yarn global add @opencode/cli" },
{ label: "vite+", code: "vp install -g @opencode/cli" },
{ label: "aur", code: "paru -S opencode-beta" },
]}
/>
The npm package uses a trusted postinstall script to select the native `opencode` binary for your platform. The Bun and pnpm
commands above explicitly allow that script to run.
The npm package uses a postinstall script to select the native `opencode` binary for your platform. The Bun and pnpm commands
explicitly allow the required script; Vite+ requires no additional flag.
Windows package managers are not supported.