Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 227598c504 fix(core): fence runtime MCP tool reconciliation 2026-07-31 11:14:56 -04:00
14 changed files with 152 additions and 269 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ const layer = Layer.effect(
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return yield* Effect.filter(rows.map(fromRow), (project) => fs.existsSafe(project.canonical))
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
+11 -3
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { Context, Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus"
@@ -16,7 +16,14 @@ import { Tool } from "../tool"
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
export interface Interface {
readonly reconcile: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const tools = yield* Tool.Service
@@ -118,11 +125,12 @@ export const layer = Layer.effectDiscard(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ reconcile })
}),
)
export const node = makeLocationNode({
name: "mcp-tools",
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
})
@@ -0,0 +1,75 @@
import { expect, test } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Image } from "@opencode-ai/core/image"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Permission } from "@opencode-ai/core/permission"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
import { imagePassthrough } from "./lib/image"
test("explicitly fences asynchronous MCP tool reconciliation", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const initialRead = yield* Deferred.make<void>()
const reconcileStarted = yield* Deferred.make<void>()
const releaseReconcile = yield* Deferred.make<void>()
const updates = yield* PubSub.unbounded<void>()
let reads = 0
let catalog: Array<MCP.Tool> = []
const layer = AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () =>
Effect.gen(function* () {
reads += 1
if (reads === 1) {
const current = catalog
yield* Deferred.succeed(initialRead, undefined)
return current
}
yield* Deferred.succeed(reconcileStarted, undefined)
yield* Deferred.await(releaseReconcile)
return catalog
}),
}),
],
[Bus.node, Layer.mock(Bus.Service, { subscribe: () => Stream.fromPubSub(updates) as never })],
[Permission.node, Layer.mock(Permission.Service, {})],
[Image.node, imagePassthrough],
])
yield* Effect.gen(function* () {
const registry = yield* Tool.Service
const adapter = yield* McpTool.Service
yield* Deferred.await(initialRead)
catalog = [
new MCP.Tool({
server: MCP.ServerName.make("voice"),
name: "list_open_tabs",
inputSchema: { type: "object", properties: {} },
}),
]
yield* PubSub.publish(updates, undefined)
yield* Deferred.await(reconcileStarted)
const stale = yield* registry.snapshot()
expect(stale.codeModeCatalog?.some((entry) => entry.path === "voice.list_open_tabs")).toBe(false)
const fence = yield* Effect.forkChild(adapter.reconcile, { startImmediately: true })
expect(fence.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseReconcile, undefined)
yield* Fiber.join(fence)
const current = yield* registry.snapshot()
expect(current.codeModeCatalog?.some((entry) => entry.path === "voice.list_open_tabs")).toBe(true)
}).pipe(Effect.provide(layer))
}),
),
)
})
+7 -22
View File
@@ -15,16 +15,8 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node)))
describe("Project.list", () => {
it.live("returns existing projects ordered by recent update", () =>
it.effect("returns complete projects ordered by recent update", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const older = abs(path.join(tmp.path, "older"))
const newer = abs(path.join(tmp.path, "newer"))
const deleted = abs(path.join(tmp.path, "deleted"))
yield* Effect.promise(() => Promise.all([fs.mkdir(older), fs.mkdir(newer)]))
const db = (yield* Database.Service).db
const project = yield* Project.Service
yield* db
@@ -32,49 +24,42 @@ describe("Project.list", () => {
.values([
{
id: Project.ID.make("older"),
worktree: older,
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon_color: "#000000",
commands: { start: "bun dev" },
sandboxes: [abs(path.join(older, "sandbox"))],
sandboxes: [abs("/older/sandbox")],
time_created: 1,
time_updated: 1,
},
{
id: Project.ID.make("newer"),
worktree: newer,
worktree: abs("/newer"),
sandboxes: [],
time_created: 2,
time_updated: 2,
time_initialized: 3,
},
{
id: Project.ID.make("deleted"),
worktree: deleted,
sandboxes: [],
time_created: 3,
time_updated: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
canonical: newer,
canonical: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
canonical: older,
canonical: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
sandboxes: [abs(path.join(older, "sandbox"))],
sandboxes: [abs("/older/sandbox")],
},
])
}),
+9
View File
@@ -1,4 +1,5 @@
import { MCP } from "@opencode-ai/core/mcp/index"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -30,7 +31,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
"mcp.add",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const tools = yield* McpTool.Service
yield* service.add(ctx.params.server, ctx.payload.config)
yield* tools.reconcile
return HttpApiSchema.NoContent.make()
}),
)
@@ -38,7 +41,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
"mcp.remove",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const tools = yield* McpTool.Service
yield* notFound(service.remove(ctx.params.server))
yield* tools.reconcile
return HttpApiSchema.NoContent.make()
}),
)
@@ -46,7 +51,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
"mcp.connect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const tools = yield* McpTool.Service
yield* notFound(service.connect(ctx.params.server))
yield* tools.reconcile
return HttpApiSchema.NoContent.make()
}),
)
@@ -54,7 +61,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
"mcp.disconnect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const tools = yield* McpTool.Service
yield* notFound(service.disconnect(ctx.params.server))
yield* tools.reconcile
return HttpApiSchema.NoContent.make()
}),
)
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
@@ -159,10 +159,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (b.location === b.root.directory) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(
1,
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
)
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
+4 -4
View File
@@ -2,8 +2,8 @@ import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
@@ -105,8 +105,8 @@ export function DialogOpen() {
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
// Dialog padding, the gutter column, title padding, and the separating space use nine columns.
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
return {
title,
footer: truncateFilePath(footer, width),
+2 -5
View File
@@ -375,11 +375,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.catch((error) => console.error("Failed to load projected model switch message", error))
break
case "session.renamed":
// Preserve the live title when it races the session's initial read.
void result.session.sync(event.data.sessionID).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
+21 -29
View File
@@ -78,21 +78,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const root = (sessionID: string) => data.session.root(sessionID)
const title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
@@ -130,29 +115,36 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
const session = data.session.get(sessionID)
const title =
session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? withTimestampedFallback(session) : undefined)
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
delete draft.unread[sessionID]
})
})
createEffect(() => {
if (!enabled()) return
const next = normalize(state())
if (isDeepEqual(next, state())) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
const session = data.session.get(sessionID)
return openSessionTab(tabs, {
sessionID,
title: session ? withTimestampedFallback(session) : tab.title,
})
}, [])
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
draft.unread = next.unread
draft.tabs = next
draft.unread = unread
})
})
-5
View File
@@ -83,11 +83,6 @@ export interface DialogSelectOption<T = any> {
onSelect?: (ctx: DialogContext) => void
}
export function dialogSelectContentWidth(dialogWidth: number) {
// Scroll padding, row padding, the gutter, title padding, and the separating gap.
return dialogWidth - 12
}
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
+9 -11
View File
@@ -8,17 +8,9 @@ import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config"
export type DialogSize = "medium" | "large" | "xlarge"
export function dialogWidth(size: DialogSize) {
if (size === "xlarge") return 116
if (size === "large") return 88
return 60
}
export function Dialog(
props: ParentProps<{
size?: DialogSize
size?: "medium" | "large" | "xlarge"
centered?: boolean
onClose: () => void
}>,
@@ -28,6 +20,12 @@ export function Dialog(
const renderer = useRenderer()
let dismiss = false
const width = () => {
if (props.size === "xlarge") return 116
if (props.size === "large") return 88
return 60
}
return (
<box
onMouseDown={() => {
@@ -59,7 +57,7 @@ export function Dialog(
dismiss = false
e.stopPropagation()
}}
width={dialogWidth(props.size ?? "medium")}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={theme.background.default}
paddingTop={1}
@@ -76,7 +74,7 @@ function init() {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as DialogSize,
size: "medium" as "medium" | "large" | "xlarge",
centered: false,
})
-88
View File
@@ -138,94 +138,6 @@ test("session lifecycle updates the terminal title and prints the epilogue after
}
})
test("session title generated while an untitled session is loading remains visible", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
const generatedTitle = Promise.withResolvers<void>()
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OC | Generated title") generatedTitle.resolve()
setTitle(title)
}
const sessionRequested = Promise.withResolvers<void>()
const renameSyncRequested = Promise.withResolvers<void>()
const releaseSession = Promise.withResolvers<void>()
let sessionRequests = 0
const session = {
id: "dummy",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const events = createEventStream()
const calls = createFetch(async (url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy") {
sessionRequests++
sessionRequested.resolve()
if (sessionRequests === 2) renameSyncRequested.resolve()
await releaseSession.promise
return json({ data: session })
}
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await sessionRequested.promise
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Generated title" },
})
await Promise.race([
renameSyncRequested.promise,
Bun.sleep(2_000).then(() => {
throw new Error("rename sync did not start")
}),
])
releaseSession.resolve()
await Promise.race([
generatedTitle.promise,
Bun.sleep(2_000).then(() => {
throw new Error("generated title was not shown")
}),
])
await Bun.sleep(20)
const generated = titles.lastIndexOf("OC | Generated title")
expect(generated).toBeGreaterThan(-1)
expect(titles.slice(generated + 1)).not.toContain("OpenCode")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
@@ -5,10 +5,7 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js"
import { dialogWidth } from "../../../src/ui/dialog"
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
import { truncateFilePath } from "../../../src/ui/file-path"
import { stringWidth } from "../../../src/util/string-width"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -150,28 +147,6 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
return { app, moved, replaceOptions, selected }
}
test("budgets option content for constrained and full-width large dialogs", () => {
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41)
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
})
test("renders the complete truncated footer within the option row", async () => {
await using tmp = await tmpdir()
const title = "Project"
const footer = truncateFilePath(
"/tmp/opencode/projects/a-very-long-project-directory/distinctive-tail.tsx",
dialogSelectContentWidth(dialogWidth("medium")) - stringWidth(title),
)
const select = await mountSelect(tmp.path, [{ title, footer, value: "project" }])
try {
await select.app.waitForFrame((frame) => frame.includes(footer))
expect(select.app.captureCharFrame()).toContain(footer)
} finally {
select.app.renderer.destroy()
}
})
test("renders actions with a current selection", async () => {
await using tmp = await tmpdir()
const app = await renderSelect(
@@ -2,56 +2,41 @@
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync, watch } from "fs"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { DataProvider } from "../../src/context/data"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
import { StorageProvider } from "../../src/context/storage"
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function wait(fn: () => boolean, timeout = 2_000) {
const start = Date.now()
while (!(await fn())) {
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
async function renderSessionTabs(initialSessionID: string) {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return
return json({
data: {
id: initialSessionID,
title: options?.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
}, events)
const calls = createFetch(undefined, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
function Probe() {
tabs = useSessionTabs()
route = useRoute()
client = useClient()
data = useData()
return <box />
}
@@ -79,12 +64,11 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
return {
tabs,
route,
data,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
app.renderer.destroy()
if (!options?.state) rmSync(state, { recursive: true, force: true })
rmSync(state, { recursive: true, force: true })
},
}
}
@@ -104,50 +88,6 @@ test("stores session tabs globally by default", async () => {
}
})
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
try {
titled = await renderSessionTabs("shared", { state, title: "Generated title" })
untitled = await renderSessionTabs("shared", { state })
const file = path.join(state, "test", "tui", "tabs.json")
await titled.data.session.sync("shared")
await wait(async () => {
if (!(await Bun.file(file).exists())) return false
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
})
const observed = ["Generated title"]
const pending = new Set<Promise<void>>()
const watcher = watch(path.dirname(file), (_, name) => {
if (name !== path.basename(file)) return
const read = Bun.file(file)
.json()
.then((value) => {
const title = value.global.tabs[0]?.title
if (title && observed.at(-1) !== title) observed.push(title)
})
.catch(() => undefined)
.finally(() => pending.delete(read))
pending.add(read)
})
try {
await untitled.data.session.sync("shared")
await Bun.sleep(500)
} finally {
watcher.close()
await Promise.allSettled(pending)
}
expect(observed).toEqual(["Generated title"])
} finally {
titled?.destroy()
untitled?.destroy()
rmSync(state, { recursive: true, force: true })
}
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({