Compare commits

..
Author SHA1 Message Date
Kit Langton 70e23ca15e test: repro for watcher ALS context bug
The @parcel/watcher native callback fires outside the Instance
AsyncLocalStorage context. Bus.publish silently throws because
Instance.directory is unavailable, so watcher events never reach
subscribers. This breaks live branch detection in the TUI.
2026-03-15 11:24:57 -04:00
31 changed files with 214 additions and 324 deletions
+1 -9
View File
@@ -41,13 +41,5 @@ runs:
shell: bash
- name: Install dependencies
run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
run: bun install
shell: bash
+16 -16
View File
@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -77,7 +77,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -111,7 +111,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -138,7 +138,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -162,7 +162,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -186,7 +186,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -219,7 +219,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -250,7 +250,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -279,7 +279,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -295,7 +295,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.27",
"version": "1.2.26",
"bin": {
"opencode": "./bin/opencode",
},
@@ -416,7 +416,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -440,7 +440,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.27",
"version": "1.2.26",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -451,7 +451,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -486,7 +486,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -532,7 +532,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"zod": "catalog:",
},
@@ -543,7 +543,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -5,7 +5,7 @@ import {
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession } from "../actions"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import {
permissionDockSelector,
promptSelector,
@@ -13,11 +13,9 @@ import {
sessionComposerDockSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
import { createSdk } from "../utils"
type Sdk = ReturnType<typeof createSdk>
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
type Child = { id: string }
async function withDockSession<T>(
sdk: Sdk,
@@ -38,6 +36,14 @@ async function withDockSession<T>(
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
await expect(dock).toBeVisible()
@@ -73,91 +79,6 @@ async function expectPermissionOpen(page: any) {
await expect(page.locator(promptSelector)).toBeVisible()
}
async function withMockSession<T>(page: any, child: Child | undefined, fn: () => Promise<T>) {
if (!child) return await fn()
const list = async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === child.id)) list.push(child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
await page.route("**/session?*", list)
try {
return await fn()
} finally {
await page.unroute("**/session?*", list)
}
}
async function withMockQuestion<T>(
page: any,
request: {
id: string
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
child: Child | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) {
let pending = [
{
id: request.id,
sessionID: request.sessionID,
questions: request.questions,
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").at(-2)
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route("**/question", list)
await page.route("**/question/*/reply", reply)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try {
return await withMockSession(page, child, () => fn(state))
} finally {
await page.unroute("**/question", list)
await page.unroute("**/question/*/reply", reply)
}
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
@@ -262,7 +183,7 @@ async function withMockPermission<T>(
metadata?: Record<string, unknown>
always?: string[]
},
child: Child | undefined,
opts: { child?: any } | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) {
let pending = [
@@ -295,6 +216,23 @@ async function withMockPermission<T>(
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
@@ -302,10 +240,11 @@ async function withMockPermission<T>(
}
try {
return await withMockSession(page, child, () => fn(state))
return await fn(state)
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
@@ -336,12 +275,10 @@ test("auto-accept toggle works before first submit", async ({ page, gotoSession
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await gotoSession(session.id)
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await withMockQuestion(
page,
{
id: "que_e2e_question",
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
@@ -353,20 +290,16 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
],
},
],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectQuestionBlocked(page)
})
const dock = page.locator(questionDockSelector)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await state.resolved()
await page.goto(page.url())
await expectQuestionOpen(page)
},
)
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
})
})
@@ -467,10 +400,8 @@ test("child session question request blocks parent dock and unblocks after submi
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockQuestion(
page,
{
id: "que_e2e_child_question",
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
@@ -482,20 +413,16 @@ test("child session question request blocks parent dock and unblocks after submi
],
},
],
},
child,
async (state) => {
await page.goto(page.url())
await expectQuestionBlocked(page)
})
const dock = page.locator(questionDockSelector)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await state.resolved()
await page.goto(page.url())
await expectQuestionOpen(page)
},
)
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
@@ -529,7 +456,7 @@ test("child session permission request blocks parent dock and supports allow onc
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
child,
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
@@ -579,12 +506,10 @@ test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSess
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await gotoSession(session.id)
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await withMockQuestion(
page,
{
id: "que_e2e_keyboard",
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
@@ -593,16 +518,13 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
options: [{ label: "Continue", description: "Continue now" }],
},
],
},
undefined,
async () => {
await page.goto(page.url())
await expectQuestionBlocked(page)
})
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
},
)
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
})
@@ -349,25 +349,3 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
await waitFooter(page, firstState)
})
})
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available")
if (!other) return
await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.27",
"version": "1.2.26",
"description": "",
"type": "module",
"exports": {
+2 -3
View File
@@ -192,11 +192,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
model: item.model,
variant: item.variant,
} satisfies State
const session = id()
if (session) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.27",
"version": "1.2.26",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.27",
"version": "1.2.26",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.27",
"version": "1.2.26",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.27",
"version": "1.2.26",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.27"
version = "1.2.26"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.27",
"version": "1.2.26",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.27",
"version": "1.2.26",
"name": "opencode",
"type": "module",
"license": "MIT",
+4 -7
View File
@@ -11,11 +11,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
@@ -104,10 +99,11 @@ const switchEffect = Effect.fn("switch")(function* () {
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
@@ -136,10 +132,11 @@ const orgsEffect = Effect.fn("orgs")(function* () {
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
+1 -1
View File
@@ -47,7 +47,7 @@ export namespace Vcs {
log.info("initialized", { branch: current })
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
if (evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch()
if (next !== current) {
log.info("branch changed", { from: current, to: next })
+2 -6
View File
@@ -32,7 +32,6 @@ export namespace LLM {
sessionID: string
model: Provider.Model
agent: Agent.Info
permission?: PermissionNext.Ruleset
system: string[]
abort: AbortSignal
messages: ModelMessage[]
@@ -256,11 +255,8 @@ export namespace LLM {
})
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = PermissionNext.disabled(
Object.keys(input.tools),
PermissionNext.merge(input.agent.permission, input.permission ?? []),
)
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
delete input.tools[tool]
-1
View File
@@ -666,7 +666,6 @@ export namespace SessionPrompt {
const result = await processor.process({
user: lastUser,
agent,
permission: session.permission,
abort,
sessionID,
system,
@@ -0,0 +1,88 @@
/**
* Repro for: @parcel/watcher native callback loses AsyncLocalStorage context
*
* Background:
* opencode uses AsyncLocalStorage (ALS) to track which project directory
* is active. Bus.publish reads Instance.directory from ALS to route events
* to the right instance. This works for normal JS async code (setTimeout,
* Promises, etc.) because Node propagates ALS through those.
*
* But @parcel/watcher is a native C++ addon. Its callback re-enters JS
* from C++ via libuv, bypassing Node's async hooks — so ALS is empty.
* Bus.publish silently throws Context.NotFound, and the event vanishes.
*
* What this breaks:
* The git HEAD watcher (always active, no experimental flag) should detect
* branch switches and update the TUI. But because events never arrive,
* the branch indicator never live-updates.
*
* This test:
* 1. Creates a tmp git repo and boots an instance with the file watcher
* 2. Listens on GlobalBus for watcher events (Bus.publish emits to GlobalBus)
* 3. Runs `git checkout -b` to change .git/HEAD — the watcher WILL detect
* this change and fire the callback, but Bus.publish will fail silently
* 4. Times out after 5s because the event never reaches GlobalBus
*
* Fix: Instance.bind(fn) captures ALS context at subscription time and
* restores it in the callback. See #17601.
*/
import { $ } from "bun"
import { afterEach, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
async function load() {
const { FileWatcher } = await import("../../src/file/watcher")
const { GlobalBus } = await import("../../src/bus/global")
const { Instance } = await import("../../src/project/instance")
return { FileWatcher, GlobalBus, Instance }
}
afterEach(async () => {
const { Instance } = await load()
await Instance.disposeAll()
})
test("git HEAD watcher publishes events via Bus (ALS context test)", async () => {
const { FileWatcher, GlobalBus, Instance } = await load()
// 1. Create a temp git repo and start the file watcher inside an instance.
// The watcher subscribes to .git/HEAD changes via @parcel/watcher.
// At this point we're inside Instance.provide, so ALS is active.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileWatcher.init()
await Bun.sleep(200) // wait for native watcher to finish subscribing
},
})
// 2. Listen on GlobalBus and trigger a branch switch.
// When .git/HEAD changes, @parcel/watcher fires our callback from C++.
// The callback calls Bus.publish, which needs ALS to read Instance.directory.
// Without Instance.bind, ALS is empty → Bus.publish throws → event never arrives.
const got = await new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => {
GlobalBus.off("event", on)
reject(new Error("timed out — native callback likely lost ALS context"))
}, 5000)
function on(evt: any) {
if (evt.directory !== tmp.path) return
if (evt.payload?.type !== FileWatcher.Event.Updated.type) return
clearTimeout(timeout)
GlobalBus.off("event", on)
resolve(evt.payload.properties)
}
GlobalBus.on("event", on)
// This changes .git/HEAD, which the native watcher will detect
$`git checkout -b test-branch`.cwd(tmp.path).quiet().nothrow()
})
// 3. If we get here, the event arrived — ALS context was preserved.
// On the unfixed code, we never get here (the promise rejects with timeout).
expect(got).toBeDefined()
expect(got.event).toBe("change")
})
+1 -91
View File
@@ -1,7 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { tool, type ModelMessage } from "ai"
import z from "zod"
import type { ModelMessage } from "ai"
import { LLM } from "../../src/session/llm"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
@@ -326,95 +325,6 @@ describe("session.llm.stream", () => {
})
})
test("keeps tools enabled by prompt permissions", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const providerID = "alibaba"
const modelID = "qwen-plus"
const fixture = await loadFixture(providerID, modelID)
const model = fixture.model
const request = waitRequest(
"/chat/completions",
new Response(createChatStream("Hello"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
)
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-tools")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "question", pattern: "*", action: "deny" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
tools: { question: true },
} satisfies MessageV2.User
const stream = await LLM.stream({
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
description: "Ask a question",
inputSchema: z.object({}),
execute: async () => ({ output: "" }),
}),
},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
},
})
})
test("sends responses API payload for OpenAI models", async () => {
const server = state.server
if (!server) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.27",
"version": "1.2.26",
"type": "module",
"license": "MIT",
"exports": {
+12 -2
View File
@@ -1050,8 +1050,18 @@
line-height: var(--line-height-large);
color: var(--text-base);
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="question-option"][data-custom="true"] {
[data-slot="option-description"] {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
}
[data-slot="question-custom"] {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.27",
"version": "1.2.26",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.27",
"version": "1.2.26",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -49,7 +49,6 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness 16 components, one install |
| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring |
| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI |
---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.27",
"version": "1.2.26",
"publisher": "sst-dev",
"repository": {
"type": "git",