Compare commits

..
Author SHA1 Message Date
Kit Langton 583103f2dd test(core): limit process group survival check to POSIX
Node assigns non-detached Windows children to a kill-on-parent-exit job, so the held-stdio mcp fixture cannot assert POSIX process-group survival there. Keep detached descendant, capture deadline, and success-policy coverage enabled on Windows.
2026-08-31 15:35:32 -04:00
Kit Langton acaca2cc01 fix(util): separate process exit from capture completion
Report exit and running state from the child exit signal, independently of buffered output. On scope release, discard abandoned capture and retain the existing pipe-close or capture-deadline wait before applying process-group cleanup policy.

Cover unread output after confirmed process exit, successful descendant survival, and the approved policy that a parent exiting successfully before its invocation timeout retains success while the bounded capture grace finishes.
2026-08-31 15:12:01 -04:00
Kit Langton ef8b8c1b8d fix(util): preserve process output for late readers
Buffer child stdout and stderr before Effect consumers attach, retaining stream backpressure and scoped cleanup. Detach capture buffers before the existing post-exit discard deadline drains inherited pipes.

Cover post-exit readers, output larger than the buffers, and teardown with unread stdout through the real process spawner.
2026-08-31 14:55:04 -04:00
175 changed files with 2690 additions and 11102 deletions
-1
View File
@@ -423,7 +423,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+43 -58
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,9 +397,6 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
"tool-result",
)
})
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -577,13 +571,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -650,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: adapter.restoreHostedToolItem?.(part.result.value)
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -664,11 +658,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -681,11 +677,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
@@ -737,28 +733,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
adapter.name,
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -772,7 +768,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
@@ -955,16 +951,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -977,7 +969,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1094,12 +1085,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1112,13 +1098,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1422,9 +1408,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1432,7 +1418,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -289,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -1,16 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
@@ -1,7 +1,3 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+3 -1
View File
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
-10
View File
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
-10
View File
@@ -66,7 +66,6 @@ export const dict = {
"command.terminal.toggle": "Toggle terminal",
"command.fileTree.toggle": "Toggle file tree",
"command.review.toggle": "Toggle review",
"command.browser.toggle": "Toggle browser",
"command.terminal.new": "New terminal",
"command.terminal.new.description": "Create a new terminal tab",
"command.steps.toggle": "Toggle steps",
@@ -805,10 +804,6 @@ export const dict = {
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
"session.browser.address": "Browser address",
"session.browser.address.placeholder": "Enter a URL",
"session.browser.close": "Close browser",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.updateVersion": "Update {{version}}",
@@ -976,8 +971,6 @@ export const dict = {
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
"settings.general.row.browserPane.title": "Browser pane",
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
@@ -1168,9 +1161,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Search the web",
"settings.permissions.tool.browser_read.description": "Read pages and capture screenshots in the browser",
"settings.permissions.tool.browser_navigate.description": "Navigate the browser to a URL",
"settings.permissions.tool.browser_interact.description": "Click, type, and interact with pages in the browser",
"settings.permissions.tool.external_directory.title": "External Directory",
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
@@ -1,19 +0,0 @@
import type { Browser } from "@opencode-ai/schema/browser"
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneLayout = { visible: boolean; bounds?: { x: number; y: number; width: number; height: number } }
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent = { type: "open" } | { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
close(): void
}
export type BrowserPanePlatform = {
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
}
@@ -6,7 +6,6 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { BrowserPanePlatform } from "./browser-pane"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -116,9 +115,6 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
-87
View File
@@ -1,87 +0,0 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import { useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { useLayout } from "@/shell/state/layout"
import type { SessionModel } from "../model"
export function createSessionBrowser(session: SessionModel) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const server = useServer()
const layout = useLayout()
const [state, setState] = createStore({
opened: false,
registration: undefined as BrowserPaneRegistration | undefined,
browser: null as BrowserPaneState,
error: undefined as string | undefined,
})
const available = createMemo(
() =>
!!platform.browserPane &&
settings.ready() &&
settings.general.experimentalBrowser() &&
session.isDesktop() &&
!!session.identity.sessionID() &&
!server.health?.incompatible,
)
const open = () => {
session.layout.view().reviewPanel.close()
layout.fileTree.close()
setState("opened", true)
}
createEffect(() => {
const sessionID = session.identity.sessionID()
const pane = platform.browserPane
setState({ opened: false, registration: undefined, browser: null, error: undefined })
if (!available() || !sessionID || !pane) return
const owner = session.ownership.capture()
const target = { sessionID, endpoint: server.conn.http }
let registration: BrowserPaneRegistration | undefined
const register = () => {
if (registration) return
registration = pane.register(target, (event) =>
owner.run(() => (event.type === "open" ? open() : setState({ browser: event.state, error: event.error }))),
)
setState({ registration, browser: null, error: undefined })
}
// A new session appears in the UI before its server-side creation finishes.
const unsubscribe = session.shared.data.on("session.created", (event) => {
if (event.data.sessionID === sessionID) register()
})
if (!session.shared.data.session.creating(sessionID)) register()
onCleanup(() => {
unsubscribe()
registration?.close()
})
})
createEffect(() => {
if (state.opened && (session.layout.view().reviewPanel.opened() || layout.fileTree.opened())) {
setState("opened", false)
}
})
return {
available,
opened: () => state.opened,
state: () => state.browser,
error: () => state.error,
registration: () => (state.opened ? state.registration : undefined),
close: () => setState("opened", false),
toggle: () => (state.opened ? setState("opened", false) : open()),
command(command: BrowserPaneCommand) {
setState("error", undefined)
const owner = session.ownership.capture()
void state.registration?.command(command).catch((error: unknown) => {
if (!owner.current()) return
setState("error", error instanceof Error ? error.message : language.t("common.requestFailed"))
})
},
}
}
-129
View File
@@ -1,129 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Loader } from "@opencode-ai/ui/loader"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: {
registration: BrowserPaneRegistration
browser: ReturnType<typeof createSessionBrowser>
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const state = props.browser.state
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
const measure = () => {
frame = undefined
if (!surface) return
const rect = surface.getBoundingClientRect()
const zoom = platform.webviewZoom?.() ?? 1
const left = Math.round(rect.left * zoom)
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = store.visible && !dialog.active
const next = `${visible}:${left}:${top}:${right}:${bottom}`
if (next !== layout) {
layout = next
props.registration.setLayout({
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
})
}
if (performance.now() < until) frame = requestAnimationFrame(measure)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(measure)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(on([() => platform.webviewZoom?.(), () => dialog.active, () => store.visible], () => schedule(300)))
createResizeObserver(() => surface, schedule.bind(null, 0))
createEventListener(window, "resize", () => schedule(300))
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
props.registration.setLayout()
})
return (
<aside
id="browser-panel"
class="relative size-full min-w-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] flex flex-col"
>
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => props.browser.command({ type: direction })}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => props.browser.command(state()?.loading ? { type: "stop" } : { type: "reload" })}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
}
/>
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
if (store.address.trim()) props.browser.command({ type: "navigate", url: store.address })
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onInput={(event) => setStore("address", event.currentTarget.value)}
/>
</form>
<IconButton
{...button}
aria-label={language.t("session.browser.close")}
onClick={props.browser.close}
icon={<Icon name="close-small" size="small" />}
/>
</div>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
</aside>
)
}
@@ -11,7 +11,6 @@ export type SessionHeaderActionsState = {
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
browser?: { label: string; opened: boolean; onToggle: () => void }
}
export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) {
@@ -51,24 +50,6 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
/>
</Tooltip>
</Show>
<Show when={props.state.browser}>
{(browser) => (
<Tooltip class="shrink-0" placement="bottom" value={browser().label}>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={browser().opened ? "pressed" : undefined}
onClick={browser().onToggle}
aria-label={browser().label}
aria-expanded={browser().opened}
aria-controls="browser-panel"
icon={<Icon name="window-cursor" size="small" />}
/>
</Tooltip>
)}
</Show>
</div>
)
}
@@ -7,10 +7,9 @@ import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import type { createSessionBrowser } from "../browser/model"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader(props: { browser: ReturnType<typeof createSessionBrowser> }) {
export function SessionHeader() {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
@@ -29,9 +28,6 @@ export function SessionHeader(props: { browser: ReturnType<typeof createSessionB
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
browser: props.browser.available()
? { label: language.t("command.browser.toggle"), opened: props.browser.opened(), onToggle: props.browser.toggle }
: undefined,
}))
return (
+3 -4
View File
@@ -8,7 +8,7 @@ import type { SessionModel } from "./model"
import { sessionPanelLayout } from "./session-panel-layout"
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
export function createSessionScreenLayout(session: SessionModel, browserOpen: () => boolean) {
export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
@@ -26,7 +26,7 @@ export function createSessionScreenLayout(session: SessionModel, browserOpen: ()
opened: layout.fileTree.opened(),
}),
)
const resizable = createMemo(() => reviewPanelOpen() || browserOpen() || sideTerminalOpen())
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({})
let row: HTMLDivElement | undefined
@@ -60,7 +60,6 @@ export function createSessionScreenLayout(session: SessionModel, browserOpen: ()
const panelLayout = createMemo(() =>
sessionPanelLayout({
review: reviewPanelOpen(),
browser: browserOpen(),
terminal: sideTerminalOpen(),
files: fileTreeOpen(),
}),
@@ -71,7 +70,7 @@ export function createSessionScreenLayout(session: SessionModel, browserOpen: ()
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
return stacked
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || browserOpen() || fileTreeOpen())
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
+3 -12
View File
@@ -31,8 +31,6 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createSessionBrowser } from "./browser/model"
import { SessionBrowserPane } from "./browser/pane"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -48,8 +46,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session, browser.opened)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({
@@ -252,7 +249,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
<SessionHeader browser={browser} />
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
@@ -333,13 +330,7 @@ export function SessionScreen(props: { session: SessionModel }) {
setStore("sideReviewPresent", false)
}}
>
<Show
when={browser.registration()}
keyed
fallback={<SessionDesktopReview review={review} present={store.sideReviewPresent} />}
>
{(registration) => <SessionBrowserPane registration={registration} browser={browser} />}
</Show>
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
</div>
</Show>
</div>
@@ -3,23 +3,15 @@ import { sessionPanelLayout } from "./session-panel-layout"
describe("sessionPanelLayout", () => {
test("keeps one owner while changing panel geometry", () => {
expect(sessionPanelLayout({ review: false, browser: false, terminal: false, files: false })).toEqual({
expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
visible: false,
stacked: false,
})
expect(sessionPanelLayout({ review: false, browser: false, terminal: true, files: false })).toEqual({
expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
visible: true,
stacked: false,
})
expect(sessionPanelLayout({ review: true, browser: false, terminal: true, files: false })).toEqual({
visible: true,
stacked: true,
})
expect(sessionPanelLayout({ review: false, browser: true, terminal: false, files: false })).toEqual({
visible: true,
stacked: false,
})
expect(sessionPanelLayout({ review: false, browser: true, terminal: true, files: false })).toEqual({
expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
visible: true,
stacked: true,
})
@@ -1,6 +1,6 @@
export function sessionPanelLayout(input: { review: boolean; browser: boolean; terminal: boolean; files: boolean }) {
export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
return {
visible: input.review || input.browser || input.terminal || input.files,
stacked: (input.review || input.browser) && input.terminal,
visible: input.review || input.terminal || input.files,
stacked: input.review && input.terminal,
}
}
@@ -438,20 +438,6 @@ export const SettingsGeneral: Component<{
<h3 class="settings-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
description={language.t("settings.general.row.browserPane.description")}
>
<div data-action="settings-experimental-browser">
<Switch
checked={settings.general.experimentalBrowser()}
onChange={(checked) => settings.general.setExperimentalBrowser(checked)}
/>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
-9
View File
@@ -44,7 +44,6 @@ export interface Settings {
mobileDiffWrap: boolean
terminalPlacement: TerminalPlacement
followUpBehavior: FollowUpBehavior
experimentalBrowser: boolean
}
appearance: {
fontSize: number
@@ -135,7 +134,6 @@ const defaultSettings: Settings = {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: true,
},
appearance: {
fontSize: 14,
@@ -287,13 +285,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowUpBehavior(value: FollowUpBehavior) {
setStore("general", "followUpBehavior", value)
},
experimentalBrowser: withFallback(
() => store.general?.experimentalBrowser,
defaultSettings.general.experimentalBrowser,
),
setExperimentalBrowser(value: boolean) {
setStore("general", "experimentalBrowser", value)
},
},
visibility: {
fileTree: showFileTree,
-90
View File
@@ -1,90 +0,0 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
-47
View File
@@ -1,47 +0,0 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -1,34 +0,0 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+23 -21
View File
@@ -1,8 +1,7 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
import "./status/status-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -14,29 +13,32 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<MobileDrawer
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
side="bottom"
finalFocusEl={props.returnFocus?.()}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</MobileDrawerClose>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
{language.t("common.close")}
</Drawer.Close>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
)
}
@@ -1,3 +1,109 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -7,3 +113,33 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,7 +1,6 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+82 -2
View File
@@ -14,13 +14,63 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
margin-block-start: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -29,6 +79,36 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+22 -12
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import Drawer from "@corvu/drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<MobileDrawer
<Drawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,9 +423,11 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<MobileDrawerTrigger
<Drawer.Trigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -465,11 +467,15 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -487,6 +493,7 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -497,7 +504,10 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<button
type="button"
data-action="mobile-tabs-home"
@@ -536,9 +546,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
}
>
<Show
-15
View File
@@ -1,7 +1,6 @@
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
import { Updater } from "../services/updater"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
@@ -57,20 +56,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
Argument.optional,
),
method: Flag.choice("method", Updater.methods).pipe(
Flag.withAlias("m"),
Flag.withDescription("Installation method to use"),
Flag.optional,
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -1,38 +0,0 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)
-1
View File
@@ -17,7 +17,6 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
-2
View File
@@ -4,7 +4,6 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -138,7 +137,6 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+1 -1
View File
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
return policy === "notify" ? "notify" : "upgrade"
}
export function parseReleaseVersion(input: string) {
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
+8 -12
View File
@@ -5,21 +5,19 @@ import { Context, Duration, Effect, FileSystem, Layer } 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"
import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -112,9 +110,7 @@ export const layer = Layer.effect(
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
@@ -142,7 +138,7 @@ export const layer = Layer.effect(
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
)
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
@@ -177,7 +173,7 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ check, method, latest, upgrade })
return Service.of({ check })
}),
)
+2 -16
View File
@@ -486,14 +486,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
@@ -501,14 +494,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
-36
View File
@@ -1,36 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Command } from "effect/unstable/cli"
import { Commands } from "../../src/commands/commands"
import upgrade from "../../src/commands/handlers/upgrade"
import { Updater } from "../../src/services/updater"
const record = (event: unknown) => console.log(`EVENT ${JSON.stringify(event)}`)
await Effect.runPromise(
Command.runWith(Commands.commands.upgrade.spec.pipe(Command.withHandler(upgrade)), { version: "test" })(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
method: () =>
Effect.sync(() => {
record("method")
return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm"))
}),
latest: () =>
Effect.suspend(() => {
record("latest")
return process.env.UPGRADE_TEST_LATEST_ERROR
? Effect.fail(new Error("Update check failed"))
: Effect.succeed("0.0.0-beta-new")
}),
upgrade: (method, version) =>
Effect.suspend(() => {
record({ method, version })
return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void
}),
}),
Effect.provide(NodeServices.layer),
),
)
-2
View File
@@ -9,7 +9,6 @@ import {
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
import { OPENCODE_VERSION } from "../src/version"
import { tmpdir } from "./fixture/tmpdir"
const model = { providerID: "openai", modelID: "gpt-5" }
@@ -146,7 +145,6 @@ describe("Mini CLI host", () => {
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
expect(input.paths).toEqual({ home: directory.path })
expect(input.version).toBe(OPENCODE_VERSION)
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory.path, "attachment.txt")
-227
View File
@@ -1,227 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
import { Updater } from "../src/services/updater"
import { testEffect } from "../../core/test/lib/effect"
const it = testEffect(NodeServices.layer)
declare const OPENCODE_CLI_NAME: string | undefined
function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "bin"),
log: path.join(root, "log"),
repos: path.join(root, "repos"),
})
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
...spawner,
run: (command) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command")
commands.push([command.command, ...command.args])
const result = respond(command)
if (result.error) return Effect.fail(result.error)
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
...result,
})
}),
runStream: () => Stream.die("Unexpected streaming install command"),
}),
),
)
return { updater, commands, global, fs }
})
}
const installs = [
{ method: "npm", command: ["npm", "install", "--global", "@opencode-ai/cli@2.3.4-beta.1"] },
{
method: "pnpm",
command: ["pnpm", "add", "--global", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode-ai/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
it.live(`${method} installs the explicit V2 package version without a leading v`, () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* test.updater.upgrade(method, "v2.3.4-beta.1")
expect(test.commands).toEqual([[...command]])
}),
)
})
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
expect(command.command).toBe("bun")
expect(existsSync(command.args[4])).toBe(true)
return { exitCode, stderr: Buffer.from("bun install failed") }
})
const result = yield* test.updater.upgrade("bun", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const cache = test.commands[0]?.[5]
expect(cache).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["bun", "install", "--global", "--trust", "--cache-dir", cache, "@opencode-ai/cli@2.3.4-beta.1"],
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(exitCode === 0 ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe("bun install failed")
}),
)
})
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
const installer = command.command === "curl" ? command.args[2] : command.args[0]
expect(existsSync(path.dirname(installer))).toBe(true)
return {
exitCode: command.command === (failure === "download" ? "curl" : failure === "install" ? "bash" : "") ? 1 : 0,
stderr: Buffer.from(`${failure} failed`),
}
})
const result = yield* test.updater.upgrade("curl", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const installer = test.commands[0]?.[3]
expect(installer).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
...(failure === "download" ? [] : [["bash", installer, "--version", "2.3.4-beta.1", "--no-modify-path"]]),
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(failure === "success" ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe(`${failure} failed`)
}),
)
})
it.live("invalid version targets never execute a command or create a cache", () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* Effect.forEach(Updater.methods, (method) =>
Effect.forEach(
["", "latest", "2.3", "01.2.3", "vv2.3.4", "2.3.4; echo unsafe", "--global", "v2.3.4\n--force"],
(version) =>
Effect.gen(function* () {
const error = yield* test.updater.upgrade(method, version).pipe(Effect.flip)
expect(error.message).toBe(`Invalid version: ${version}`)
}),
),
)
expect(test.commands).toEqual([])
expect(yield* test.fs.exists(test.global.cache)).toBe(false)
}),
)
it.live("install failures expose stderr and process errors do not report success", () =>
Effect.gen(function* () {
const failed = yield* fixture(() => ({ exitCode: 1, stderr: Buffer.from(" registry denied access\n") }))
const error = yield* failed.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(error.message).toBe("registry denied access")
const missing = yield* fixture(() => ({ error: new AppProcess.AppProcessError({ command: "npm" }) }))
const unavailable = yield* missing.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(unavailable.message).toBe("Failed to update with npm")
expect(failed.commands).toHaveLength(1)
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", 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-ai/cli@2.3.4" : "opencode-ai@1.0.0"),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["pnpm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
])
}),
)
})
it.live("method detection tolerates unavailable package managers", () =>
Effect.gen(function* () {
const test = yield* fixture((command) =>
command.command === "yarn"
? { stdout: Buffer.from("@opencode-ai/cli@2.3.4") }
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(4)
}),
)
test("Node distribution honors the compile-time CLI name", async () => {
const child = Bun.spawn(
[
process.execPath,
"test",
import.meta.path,
"--define",
'OPENCODE_CLI_NAME="opencode2-node"',
"--test-name-pattern",
"^Node distribution resolves the published npm package$",
],
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
)
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(code, stderr).toBe(0)
expect(stderr).toContain("1 pass")
})
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "opencode-node"],
["pnpm", "list", "-g", "--depth=0", "opencode-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["npm", "install", "--global", "opencode-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=opencode-node", "opencode-node@2.3.4"],
])
}),
)
}
-109
View File
@@ -1,109 +0,0 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("upgrade command", () => {
test("is registered in root help and documents its options", async () => {
const root = await cli(["--help"], {}, "../src/index.ts")
const help = await cli(["upgrade", "--help"], {}, "../src/index.ts")
expect(root.exitCode).toBe(0)
expect(root.stdout).toContain("upgrade")
expect(help.exitCode).toBe(0)
expect(help.stdout).toContain("[<target>]")
expect(help.stdout).toContain("--method")
expect(help.stdout).toContain("-m")
})
test("detects the installation method and resolves the latest version", async () => {
const result = await cli([])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }])
expect(result.stdout).toContain("Upgrade complete")
})
test("accepts an explicit version and method without detection or a version lookup", async () => {
const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "pnpm", version: "v0.0.0-beta-target" }])
expect(result.stdout).toContain("0.0.0-beta-old → 0.0.0-beta-target")
})
test("accepts the short method flag and an explicit major upgrade", async () => {
const result = await cli(["2.0.0", "-m", "bun"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "bun", 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)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("already installed")
})
test("requires an explicit method when detection fails", async () => {
const result = await cli([], { UPGRADE_TEST_METHOD: "unknown" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("Pass --method")
})
test("rejects unsupported methods before attempting an upgrade", async () => {
const result = await cli(["--method", "brew"])
expect(result.exitCode).not.toBe(0)
expect(result.events).toEqual([])
})
test("reports version lookup failures without installing", async () => {
const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method", "latest"])
expect(result.stdout).toContain("Update check failed")
})
test("reports installation failures with a nonzero exit code", async () => {
const result = await cli([], { UPGRADE_TEST_INSTALL_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Upgrade failed")
expect(result.stdout).toContain("Permission denied")
expect(result.stdout).not.toContain("Upgrade complete")
})
})
async function cli(args: string[], env: Record<string, string> = {}, entry = "fixture/upgrade.ts") {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-upgrade-"))
try {
const child = Bun.spawn(
[process.execPath, "--define", 'OPENCODE_VERSION="0.0.0-beta-old"', path.join(import.meta.dir, entry), ...args],
{
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
OPENCODE_TEST_HOME: root,
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
...env,
},
stdout: "pipe",
stderr: "pipe",
},
)
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
const events = stdout
.split("\n")
.filter((line) => line.startsWith("EVENT "))
.map((line) => JSON.parse(line.slice(6)))
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
return { stdout, stderr, exitCode, events }
} finally {
await rm(root, { recursive: true, force: true })
}
}
-1
View File
@@ -3,7 +3,6 @@ import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
export type { PermissionCreateInput } from "./generated/types.js"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
+5 -18
View File
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
@@ -60,22 +60,9 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description:
```ts
const runtime = CodeMode.make({
tools: {
orders: Namespace.make({
description: "Purchases, fulfillment, and shipment tracking",
tools: { lookup: lookupOrder },
}),
},
})
```
Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come
from record keys, so the wrapper does not repeat `orders`. Dots in keys create nested paths; other characters use
bracket notation, such as `tools.context7["resolve-library-id"](...)`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
`tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -163,7 +150,7 @@ and `CodeMode.toolExpression(path)` supply the exact callable forms.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
`maxToolCalls`.
## Execution Limits
-1
View File
@@ -1,5 +1,4 @@
export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
@@ -1,6 +1,5 @@
import {
type AstNode,
AsyncIteratorSymbol,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
@@ -10,7 +9,6 @@ import {
GeneratorMethodReference,
InterpreterRuntimeError,
IntrinsicReference,
IteratorSymbol,
JsonMethodReference,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
@@ -44,12 +42,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
yield Reflect.get(value, key)
function* childValues(value: object): Generator<unknown> {
if (Array.isArray(value)) {
const length = value.length
for (let index = 0; index < length; index++) yield value[index]
return
}
yield* Object.values(value)
}
export const containsRuntimeReference = (value: unknown): boolean => {
@@ -91,14 +90,9 @@ export const containsOpaqueReference = (value: unknown): boolean => {
}
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const seen = new Set<object>()
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -110,7 +104,7 @@ export const rejectCircularInsertion = (
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
}
}
-24
View File
@@ -1,24 +0,0 @@
import type { Tools } from "./tools.js"
/** A tool namespace with optional model-visible metadata. */
export type Namespace<R = never> = {
readonly _tag: "CodeModeNamespace"
readonly description?: string
readonly tools: Tools<R>
}
/** Options for declaring one CodeMode namespace. */
export type Options<R = never> = {
readonly description?: string
readonly tools: Tools<R>
}
export const isNamespace = <R = never>(value: Namespace<R> | Tools<R>): value is Namespace<R> =>
Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace"
/** Declares a namespace when descriptions or other namespace metadata are needed. */
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
_tag: "CodeModeNamespace",
...(options.description === undefined ? {} : { description: options.description }),
tools: options.tools,
})
+1 -1
View File
@@ -53,6 +53,7 @@ export const fromSpec = (options: Options): Result => {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
@@ -98,7 +99,6 @@ export const fromSpec = (options: Options): Result => {
auth: options.auth,
headers: options.headers ?? {},
}
const segments = operationPath(method, path, operationValue, used, namespaces)
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
+3 -1
View File
@@ -461,7 +461,9 @@ export const operationInput = (
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
+15 -20
View File
@@ -1,6 +1,12 @@
import { Effect } from "effect"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -31,6 +37,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
}
return input as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = item
}
switch (name) {
case "keys":
return Object.keys(requireObject())
@@ -54,29 +64,14 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
"TypeError",
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const key of Reflect.ownKeys(source)) {
if (typeof key === "string") {
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
continue
}
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
guardedSet(key, Reflect.get(source, key))
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
+25 -34
View File
@@ -8,7 +8,6 @@ import {
inputTypeScript,
outputTypeScript,
} from "./tool-schema.js"
import { isNamespace, type Namespace } from "./namespace.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
@@ -278,7 +277,6 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
type ToolNode<R> = {
tool?: Tool<R>
namespace?: Namespace<R>
readonly children: Map<string, ToolNode<R>>
}
@@ -294,10 +292,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current = child
}
if (isTool<R>(value)) current.tool = value
else if (isNamespace<R>(value)) {
current.namespace = value
insert(current, value.tools)
} else insert(current, value)
else insert(current, value)
}
}
insert(root, tools)
@@ -307,33 +302,29 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
type VisibleTool<R> = {
readonly path: string
readonly tool: Tool<R>
readonly namespaces: ReadonlyArray<Namespace<R>>
}
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
namespaces: ReadonlyArray<Namespace<R>> = [],
): Array<VisibleTool<R>> => {
const next = node.namespace === undefined ? namespaces : [...namespaces, node.namespace]
return [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool, namespaces: next }]),
...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)),
]
}
): Array<{ path: string; tool: Tool<R> }> => [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
})
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
flattenTools(toolTrie(tools))
.sort((left, right) => compareText(left.path, right.path))
.map(({ path, tool }) => ({
path,
tool,
description: describeTool(path, tool),
}))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
@@ -429,13 +420,12 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
searchText: [
visible.path,
visible.tool.description,
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
path,
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -443,13 +433,14 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
+1 -11
View File
@@ -63,18 +63,8 @@ const docTags = (schema: JsonSchema): Array<string> => {
} catch {}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (schema.type === "integer") tags.push("@integer")
if (typeof schema.minimum === "number") tags.push(`@minimum ${schema.minimum}`)
if (typeof schema.maximum === "number") tags.push(`@maximum ${schema.maximum}`)
if (typeof schema.exclusiveMinimum === "number") tags.push(`@exclusiveMinimum ${schema.exclusiveMinimum}`)
if (typeof schema.exclusiveMaximum === "number") tags.push(`@exclusiveMaximum ${schema.exclusiveMaximum}`)
if (typeof schema.multipleOf === "number") tags.push(`@multipleOf ${schema.multipleOf}`)
if (typeof schema.minLength === "number") tags.push(`@minLength ${schema.minLength}`)
if (typeof schema.maxLength === "number") tags.push(`@maxLength ${schema.maxLength}`)
if (typeof schema.pattern === "string") tags.push(`@pattern ${schema.pattern}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
if (schema.uniqueItems === true) tags.push("@uniqueItems true")
return tags
}
@@ -137,8 +127,8 @@ const renderSchema = (
])
}
if (schema.allOf) {
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
+7 -13
View File
@@ -1,6 +1,4 @@
import { Effect, Schema } from "effect"
import type { Namespace } from "./namespace.js"
import type { Tools } from "./tools.js"
/**
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
@@ -21,17 +19,8 @@ export type JsonSchema = {
readonly default?: unknown
readonly format?: string
readonly deprecated?: boolean
readonly minimum?: number
readonly maximum?: number
readonly exclusiveMinimum?: number
readonly exclusiveMaximum?: number
readonly multipleOf?: number
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
readonly minItems?: number
readonly maxItems?: number
readonly uniqueItems?: boolean
readonly $ref?: string
readonly $defs?: Readonly<Record<string, JsonSchema>>
readonly definitions?: Readonly<Record<string, JsonSchema>>
@@ -61,8 +50,13 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
export const isTool = <R = never>(value: Tool<R> | Namespace<R> | Tools<R> | undefined): value is Tool<R> =>
value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool"
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
Object.hasOwn(value, "_tag") &&
value._tag === "CodeModeTool"
/**
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
readonly [name: string]: Tool<R> | Tools<R>
}
+4 -35
View File
@@ -25,12 +25,8 @@ const happyPathSpec = async (): Promise<Document> => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const toolAt = (tools: OpenAPI.Tools, name: string) =>
name
.split(".")
.reduce<
Tool.Tool<HttpClient.HttpClient> | OpenAPI.Tools | undefined
>((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools)
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -282,30 +278,6 @@ describe("OpenAPI.fromSpec", () => {
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("does not reserve names for unsupported operations between duplicate operation IDs", () => {
const operation = { operationId: "group.item", responses: { 200: { description: "Success" } } }
for (const unsupported of [false, true]) {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/first": { get: operation },
...(unsupported ? { "/unsupported": { get: { ...operation, "x-websocket": true } } } : {}),
"/last": { get: operation },
},
},
})
expect(Object.keys(result.tools)).toEqual(["group", "group_item_2"])
expect(toolAt(result.tools, "group.item")).toMatchObject({ _tag: "CodeModeTool", description: "GET /first" })
expect(toolAt(result.tools, "group_item_2")).toMatchObject({ _tag: "CodeModeTool", description: "GET /last" })
expect(result.skipped).toEqual(
unsupported ? [{ method: "GET", path: "/unsupported", reason: "WebSocket operations are not supported" }] : [],
)
}
})
test("synthesizes flat operation IDs from methods and paths", () => {
const response = { responses: { 200: { description: "Success" } } }
const tools = OpenAPI.fromSpec({
@@ -343,10 +315,7 @@ describe("OpenAPI.fromSpec", () => {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [
{ name: "limit", in: "query", schema: { type: "boolean" } },
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
],
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
responses: { 200: { description: "Success" } },
},
},
@@ -979,7 +948,7 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
const healthInput = isRecord(health) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
+16 -158
View File
@@ -139,81 +139,6 @@ describe("pretty signature rendering", () => {
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
})
test.each([
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
[{ type: "number", exclusiveMinimum: 0 }, "@exclusiveMinimum 0", "number"],
[{ type: "number", exclusiveMaximum: 0 }, "@exclusiveMaximum 0", "number"],
[{ type: "number", multipleOf: 0.25 }, "@multipleOf 0.25", "number"],
[{ type: "string", minLength: 0 }, "@minLength 0", "string"],
[{ type: "string", maxLength: 0 }, "@maxLength 0", "string"],
[{ type: "string", pattern: "^[a-z]+$" }, "@pattern ^[a-z]+$", "string"],
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
] as const)("renders constraint %j without changing the compact type", (value, tag, type) => {
const schema = { type: "object", properties: { value } }
expect(jsonSchemaToTypeScript(schema, true)).toBe(["{", ` /** ${tag} */`, ` value?: ${type},`, "}"].join("\n"))
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
})
test("documents integer numbers without adding redundant types or requiring uniqueness when false", () => {
expect(
jsonSchemaToTypeScript(
{
type: "object",
properties: {
count: { type: "integer" },
amount: { type: "number" },
name: { type: "string" },
enabled: { type: "boolean" },
values: { type: "array", uniqueItems: false },
choice: { type: ["integer", "string"] },
},
},
true,
),
).toBe(
[
"{",
" /** @integer */",
" count?: number,",
" amount?: number,",
" name?: string,",
" enabled?: boolean,",
" values?: Array<unknown>,",
" choice?: number | string,",
"}",
].join("\n"),
)
})
test.each([false, null, ""])("preserves default %j alongside constraint tags", (value) => {
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
` * @default ${JSON.stringify(value)}\n * @minLength 0\n`,
)
})
test("escapes comment terminators in tag values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
true,
),
).toBe(
[
"{",
" /**",
' * @default "* /"',
" * @format * /",
" * @pattern ^a* /b$",
" */",
" value?: string,",
"}",
].join("\n"),
)
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
@@ -419,100 +344,33 @@ describe("union schemas render every alternative", () => {
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf keeps siblings and parenthesized union members in order", () => {
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
properties: { common: { type: "boolean" } },
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
test.each([false, true])("allOf does not discard an unresolved constraint (pretty=%s)", (pretty) => {
for (const $ref of ["#/$defs/Missing", "#/definitions/Missing", "https://example.com/external.json"]) {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref }] }, pretty)).toBe("unknown")
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref }] }] }, pretty)).toBe("unknown")
expect(
jsonSchemaToTypeScript({ allOf: [{ properties: { nested: { $ref } } }, { type: "string" }] }, pretty),
).toBe("unknown")
}
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
expect(
jsonSchemaToTypeScript(
{
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
},
pretty,
),
jsonSchemaToTypeScript({
allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
}),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
}),
).toBe("string")
})
})
describe("JSDoc signatures in catalogs and search results", () => {
test.each([
{
source: "JSON Schema",
schema: {
type: "object",
properties: {
count: { type: "integer", minimum: 0, maximum: 10 },
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
labels: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
},
required: ["count", "name", "labels"],
},
},
{
source: "Effect",
schema: Schema.Struct({
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
labels: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(5)),
}),
},
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
const runtime = CodeMode.make({
tools: {
constrained: Tool.make({
description: "Constrained tool",
input: schema,
output: schema,
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"] }),
}),
},
})
const type = [
"{",
" /**",
" * @integer",
" * @minimum 0",
" * @maximum 10",
" */",
" count: number,",
" /**",
" * @minLength 1",
" * @maxLength 20",
" * @pattern ^[a-z]+$",
" */",
" name: string,",
" /**",
" * @minItems 1",
" * @maxItems 5",
" */",
" labels: Array<string>,",
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
expect(runtime.catalog()[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
expect(result.value).toMatchObject({ items: [{ signature }] })
})
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
-170
View File
@@ -17,8 +17,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
import { invokeObjectMethod } from "../src/stdlib/object.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
@@ -826,174 +824,6 @@ describe("stdlib integration", () => {
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
})
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
const target = {}
const reads: Array<boolean> = []
const source = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
})
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(target).toEqual({ nested })
})
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
const target = {}
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(Object.hasOwn(target, "nested")).toBe(false)
})
test("Object.assign cycle checks traverse sparse keys lazily", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperties([], {
4294967294: { enumerable: true, value: target },
later: {
enumerable: true,
get() {
reads.push(true)
return null
},
},
})
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(reads).toEqual([])
})
test("Object.assign stops after a supported symbol write fails", () => {
const previous = () => ({ done: true })
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
const reads: Array<boolean> = []
const source = Object.defineProperties(
{},
{
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
[AsyncIteratorSymbol]: {
enumerable: true,
get() {
reads.push(true)
return () => ({ done: true })
},
},
},
)
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
"Object.assign could not assign property",
)
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
expect(reads).toEqual([])
})
test("Object.assign rejects direct and nested cycles", async () => {
expect(
await value(`
const target = { kept: true }
try { Object.assign(target, { self: target }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = { kept: true }
const nested = { target }
try { Object.assign(target, { nested }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = {}
const source = {}
source[Symbol.iterator] = target
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
return true
`),
).toBe(false)
expect(
await value(`
const target = {}
const nested = {}
nested[Symbol.iterator] = target
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
return true
`),
).toBe(false)
})
test("Object.assign preserves mutations before a circular field", async () => {
expect(
await value(`
const target = {}
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
return null
`),
).toEqual({ before: 1 })
expect(
await value(`
const target = {}
const marker = {}
const source = {}
source[Symbol.iterator] = marker
source[Symbol.asyncIterator] = target
try { Object.assign(target, source) } catch {
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
}
return null
`),
).toEqual([true, false])
})
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
expect(
await value(`
const shared = { count: 1 }
const target = {}
const result = Object.assign(target, { left: shared, right: shared })
result.left.count = 2
return [result === target, result.left === shared, result.left === result.right, shared.count]
`),
).toEqual([true, true, true, 2])
})
test("Object.assign traverses shared aliases once", () => {
const reads: Array<boolean> = []
const shared = Object.defineProperty({}, "value", {
enumerable: true,
get() {
reads.push(true)
return 1
},
})
const target = {}
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
target,
)
expect(target).toEqual({ left: shared, right: shared })
expect(reads).toEqual([true])
})
test("assignment resolves and reads its left side before evaluating the right side", async () => {
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
+1 -43
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Namespace, Tool } from "../src/index.js"
import { CodeMode, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
Tool.make({
@@ -177,48 +177,6 @@ describe("blocked member names on tool paths", () => {
})
})
describe("namespace metadata", () => {
const tools = {
api: Namespace.make({
description: "Manage the workspace",
tools: {
users: Namespace.make({
description: "Directory and account administration",
tools: { list: echo("List users", "users") },
}),
status: echo("Read service status", "ok"),
},
}),
plain: { read: echo("Read plain data", "plain") },
}
const runtime = CodeMode.make({ tools })
test("the wrapper does not add a segment to callable paths", async () => {
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
})
test("search matches descriptions from every enclosing namespace", async () => {
const workspace = await value(runtime, `return search({ query: "workspace" })`)
expect((workspace as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.status",
"tools.api.users.list",
])
const directory = await value(runtime, `return search({ query: "account administration" })`)
expect((directory as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.users.list",
])
})
test("a namespace description is optional", async () => {
const optional = CodeMode.make({
tools: { api: Namespace.make({ tools: { read: echo("Read data", "read") } }) },
})
expect(await value(optional, `return await tools.api.read({})`)).toBe("read")
})
})
describe("empty segments", () => {
test("tool names with empty segments are rejected at make", () => {
for (const name of ["", "a..b", "trail.", ".lead"]) {
+10 -54
View File
@@ -1,29 +1,22 @@
export * as CodeModeCatalog from "./catalog.js"
import type { Namespace } from "@opencode-ai/schema/tool"
import { Schema } from "effect"
export const Tool = Schema.Struct({
export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Tool = typeof Tool.Type
export type Inventory = {
readonly tools: ReadonlyArray<Tool>
readonly namespaces?: ReadonlyMap<string, Namespace>
}
export type Entry = typeof Entry.Type
const Listing = Schema.Struct({
path: Schema.String,
line: Schema.String,
})
const NamespaceSummary = Schema.Struct({
const Namespace = Schema.Struct({
name: Schema.String,
description: Schema.optionalKey(Schema.String),
count: Schema.Number,
entries: Schema.Array(Listing),
})
@@ -31,30 +24,24 @@ const NamespaceSummary = Schema.Struct({
export const Summary = Schema.Struct({
total: Schema.Number,
shown: Schema.Number,
namespaces: Schema.Array(NamespaceSummary),
namespaces: Schema.Array(Namespace),
})
export type Summary = typeof Summary.Type
export type Options = {
readonly budget?: number
}
const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000
// Keep every namespace visible, then select full listings one per namespace per round,
// Keep every namespace searchable, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
export function summarize(inventory: Inventory, options: Options = {}): Summary {
const budget = options.budget ?? INLINE_BUDGET
const namespaces = [...Map.groupBy(inventory.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const description = inventory.namespaces?.get(name)?.description
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@@ -77,7 +64,6 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
)
return {
name,
...(description === undefined ? {} : { description }),
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
@@ -86,25 +72,11 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
})
const active = new Set(namespaces)
// TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline.
let remaining =
budget -
namespaces.reduce(
(total, namespace) =>
total +
cost(
namespaceLine({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: [],
}),
),
0,
) -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + cost(listing.line), 0)
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
@@ -121,31 +93,19 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: inventory.tools.length,
total: entries.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}`
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: cost(listing.line) }))
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
@@ -153,7 +113,3 @@ function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return 0
})
}
function cost(text: string) {
return Math.round(text.length / CHARACTERS_PER_TOKEN)
}
+10 -12
View File
@@ -23,7 +23,14 @@ export function render(catalog: CodeModeCatalog.Summary) {
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
const tools = catalog.namespaces.flatMap((namespace) => {
return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)]
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
})
return `${prompt(catalog.shown < catalog.total)}
@@ -40,15 +47,6 @@ ${render(current)}`
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return replacement
const descriptions = Instructions.diffByKey(
previous.namespaces.filter((namespace) => namespace.description !== undefined),
current.namespaces.filter((namespace) => namespace.description !== undefined),
(namespace) => namespace.name,
(before, after) => before.description !== after.description,
)
if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0)
return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
@@ -128,8 +126,8 @@ ${render(current)}`
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => {
const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory)
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.List => {
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
return Instructions.make({
key,
codec,
+14 -97
View File
@@ -1,18 +1,9 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
import type {
Content,
Context,
Error,
Info,
Metadata,
Namespace as ToolNamespace,
Result,
} from "@opencode-ai/schema/tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -40,21 +31,6 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
type ToolNode = {
tool?: Tool.Tool<never>
namespace?: ToolNamespace
readonly children: Map<string, ToolNode>
}
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
export type Inventory = {
readonly tools: ReadonlyMap<string, Info>
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
@@ -66,7 +42,7 @@ const description = [
].join("\n")
export const create = (
inventory: Inventory,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
) => {
return {
@@ -85,7 +61,7 @@ export const create = (
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
registrations,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
@@ -156,95 +132,36 @@ export const create = (
} satisfies Info
}
export const catalog = (inventory: Inventory) => {
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
const pinned = new Set(
Array.from(inventory.tools.values())
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return {
tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })),
...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }),
} satisfies CodeModeCatalog.Inventory
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
inventory: Inventory,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const [name, registration] of inventory.tools) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
getNode(root, qualifiedName(registration)).tool = Tool.make({
const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode(root: ToolNode, path: string) {
return path.split(".").reduce((parent, name) => {
const child: ToolNode = parent.children.get(name) ?? { children: new Map() }
parent.children.set(name, child)
return child
}, root)
}
function renderTools(root: ToolNode) {
const callables = new Map<string, Tool.Tool<never>>()
const tools = renderChildren(root, [], callables)
for (const [path, tool] of callables) tools[path] = tool
return tools
}
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): Tools {
return Object.fromEntries(
Array.from(node.children).flatMap(([name, child]) => {
const next = [...path, name]
// A record cannot hold both a top-level tool and namespace under the same key.
if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) {
const tools: Tools = {}
flattenTools(child, next, tools)
return Object.entries(tools)
}
return [[name, renderEntry(child, next, callables)]]
}),
)
}
function renderEntry(
node: ToolNode,
path: ReadonlyArray<string>,
callables: Map<string, Tool.Tool<never>>,
): Tools[string] {
const tools = renderChildren(node, path, callables)
// CodeMode merges this dotted tool path with the nested namespace entry.
if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0))
callables.set(path.join("."), node.tool)
if (node.namespace !== undefined)
return Namespace.make({
description: node.namespace.description,
tools,
})
if (node.tool === undefined) return tools
if (node.children.size === 0) return node.tool
return tools
}
function flattenTools(node: ToolNode, path: ReadonlyArray<string>, tools: Tools) {
if (node.tool !== undefined) tools[path.join(".")] = node.tool
for (const [name, child] of node.children) flattenTools(child, [...path, name], tools)
}
function qualifiedName(registration: Info) {
const normalized = normalizedName(registration)
if (registration.options?.namespace === undefined) return normalized
+3 -3
View File
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
}
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
const index = input.indexOf("/")
const [providerID, ...modelID] = input.split("/")
return {
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
providerID: Provider.ID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
@@ -1,33 +0,0 @@
# Experimental Browser Plugin
The server-side browser tool lives alongside the other built-in plugins. Its
implementation uses only the public plugin API, public schemas, and Effect. The
shared RPC contract is `@opencode-ai/schema/browser`; desktop clients do not import Core.
Disable it through normal plugin configuration:
```jsonc
{
"plugins": ["-opencode.browser"],
}
```
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
session's location. Subscribe to server events before calling `attach`; wait for
`server.connected`, then the matching `attached` control event. The `attach` call
stays pending for the attachment lifetime. Abort it when its event stream ends or
the desktop owner closes. Completing the attachment also ends that event consumer.
- `attach` holds one browser attachment per session until cancellation, plugin
unload, session deletion, or session movement.
- `state` reports the current page, or `null` when no page is open.
- `result` completes a command with its request ID and outcome.
- `control` events carry attachment confirmation, commands, and cancellation.
Control events use OpenCode's existing authenticated, server-wide event feed.
Consumers filter by `connectionID`; this identifier is correlation, not private
event delivery. State and results use RPC calls rather than broadcast events.
The plugin requests normal agent permissions before acting on a URL. Browser
content is untrusted. Pages use the desktop's network, with no server-side tunnel.
The desktop owns Chromium, page isolation, and native controls.
-185
View File
@@ -1,185 +0,0 @@
import { Plugin, Session, Tool } from "@opencode-ai/plugin/effect"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import { Deferred, Effect, Encoding, Stream } from "effect"
import { Browser } from "@opencode-ai/schema/browser"
type Attachment = {
connectionID: string
state: Browser.State | null
closed: Deferred.Deferred<void>
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
}
export default Plugin.define({
id: "opencode.browser",
effect: (ctx) =>
Effect.gen(function* () {
const browsers = new Map<Session.ID, Attachment>()
let active = true
const close = (sessionID: Session.ID) =>
Effect.gen(function* () {
const browser = browsers.get(sessionID)
if (!browser) return
browsers.delete(sessionID)
yield* Deferred.succeed(browser.closed, undefined)
})
yield* Effect.addFinalizer(() => {
active = false
return Effect.forEach(browsers.keys(), close, { discard: true })
})
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
.register(Browser.Definition, {
attach: (input, call) =>
Effect.gen(function* () {
const session = yield* ctx.session
.get({ sessionID: input.sessionID })
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
if (
session.location.directory !== ctx.location.directory ||
session.location.workspaceID !== ctx.location.workspaceID
)
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
const browser = yield* Effect.acquireRelease(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>()
if (!active || browsers.has(input.sessionID))
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const browser: Attachment = {
connectionID: input.connectionID,
state: null,
closed,
pending: new Map(),
}
browsers.set(input.sessionID, browser)
return browser
}),
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
state: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
browser.state = input.state
}),
result: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const pending = browser.pending.get(input.requestID)
if (!pending) return
if (input.outcome.type === "failure")
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
Effect.asVoid,
)
yield* Deferred.succeed(pending, input.outcome.result)
}).pipe(Effect.asVoid),
})
.pipe(Effect.orDie)
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "browser",
input: Browser.Action,
options: { codemode: false },
description:
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
execute: (action, tool) =>
Effect.gen(function* () {
const browser = browsers.get(tool.sessionID)
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
if (action.type !== "open") {
if (!browser.state) return yield* new Tool.Error({ message: "Open the browser first." })
const url = action.type === "navigate" ? action.url : browser.state.url
yield* ctx.permission
.assert({
action: "browser",
resources: [url],
metadata: { type: action.type, url },
sessionID: tool.sessionID,
agent: tool.agent,
source: { type: "tool", messageID: tool.messageID, id: tool.id },
})
.pipe(Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })))
}
const requestID = crypto.randomUUID()
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
browser.pending.set(requestID, pending)
const result = yield* rpc.events
.emit("control", {
type: "command",
connectionID: browser.connectionID,
requestID,
command: { action, generation: browser.state?.generation ?? 0 },
})
.pipe(
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
Effect.andThen(Deferred.await(pending)),
Effect.raceFirst(
Deferred.await(browser.closed).pipe(
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
),
),
Effect.onInterrupt(() =>
rpc.events
.emit("control", {
type: "cancel",
connectionID: browser.connectionID,
requestID,
})
.pipe(Effect.ignore),
),
Effect.timeoutOrElse({
duration: "30 seconds",
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
}),
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
)
return render(result)
}),
}),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
if (!browsers.has(event.sessionID)) delete event.tools.browser
}),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
Stream.runForEach((event) => close(event.data.sessionID)),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function render(result: Browser.Result): Tool.Result {
if (result.type === "screenshot")
return {
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{
type: "file",
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
mime: "image/png",
name: "browser-screenshot.png",
},
],
metadata: { url: result.state.url },
}
const content = JSON.stringify(result)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
return {
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
metadata: { url: result.state.url },
}
}
-1
View File
@@ -326,7 +326,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
}),
},
permission: {
assert: permission.assert,
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
-2
View File
@@ -75,7 +75,6 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "./browser/index.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -235,7 +234,6 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
+1 -1
View File
@@ -96,7 +96,7 @@ const layer = Layer.effect(
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
+20 -40
View File
@@ -1,6 +1,6 @@
export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Namespace, Options, Result } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
@@ -26,7 +26,6 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
export interface Draft {
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
readonly namespace: (namespace: Tool.Namespace) => void
readonly add: (tool: Tool.Info) => void
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
readonly remove: (id: string) => void
@@ -34,8 +33,7 @@ export interface Draft {
type Data = {
tools: Map<string, Tool.Info & { readonly id: string }>
namespaces: Map<string, Tool.Namespace>
errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[]
errors: { tool: Tool.Info; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
@@ -44,7 +42,7 @@ export interface Interface extends State.Transformable<Draft> {
export interface Snapshot {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: CodeModeCatalog.Inventory
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
readonly execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
@@ -153,24 +151,15 @@ const layer = Layer.effect(
name: "tool",
initial: () => ({
tools: new Map(),
namespaces: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
namespace: (namespace) => {
const error = namespaceError(namespace.name)
if (error) {
draft.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error })
return
}
draft.namespaces.set(namespace.name, { ...namespace })
},
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
@@ -187,7 +176,7 @@ const layer = Layer.effect(
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
@@ -199,10 +188,10 @@ const layer = Layer.effect(
finalize: () =>
Effect.forEach(
state.get().errors,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
name,
namespace,
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}),
{ discard: true },
@@ -221,25 +210,23 @@ const layer = Layer.effect(
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = state.get().namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const codemodeEnabled = !whollyDisabled("execute", rules)
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
)
: undefined
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codeModeTool ? [definition(codeModeTool)] : []),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
const context: Tool.Context = {
@@ -252,11 +239,11 @@ const layer = Layer.effect(
const event = yield* beforeExecute(input.call.name, input.call.input, context)
const requested = input.definitions?.get(event.tool)
// Preserve session context removal and alias resolution, now after the repair hook.
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
if (!requested && input.definitions && (direct.has(event.tool) || codemodeTool?.name === event.tool))
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
const name = requested?.name ?? event.tool
if (name === "execute" && codeModeTool)
return yield* executeTool(codeModeTool, name, event.input, context)
if (name === "execute" && codemodeTool)
return yield* executeTool(codemodeTool, name, event.input, context)
const tool = direct.get(name)
if (tool) return yield* executeTool(tool, name, event.input, context)
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
@@ -282,10 +269,8 @@ function schemaMakeError(error: unknown) {
function registrationError(tool: Tool.Info) {
const namespace = tool.options?.namespace
if (namespace !== undefined) {
const error = namespaceError(namespace)
if (error) return error
}
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
const name = normalizedName(tool)
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
const id = effectiveName(tool)
@@ -299,11 +284,6 @@ function registrationError(tool: Tool.Info) {
return Result.isFailure(result) ? result.failure : undefined
}
function namespaceError(name: string) {
if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return
return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` })
}
export const node = makeLocationNode({
service: Service,
layer,
-2
View File
@@ -32,8 +32,6 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Namespace descriptions are registered once through `draft.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description.
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
- The latest valid active registration for the same effective name wins.
+8 -11
View File
@@ -23,17 +23,14 @@ describe("CodeMode", () => {
const snapshot = yield* tools.snapshot()
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
expect(snapshot.codeModeCatalog).toStrictEqual({
tools: [
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
],
namespaces: new Map(),
})
expect(snapshot.codeModeCatalog).toStrictEqual([
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
])
}).pipe(
Effect.scoped,
Effect.provide(
+17 -69
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
@@ -15,24 +15,21 @@ const lookup = entry(
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
)
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Tool>,
current: ReadonlyArray<CodeModeCatalog.Tool>,
previous: ReadonlyArray<CodeModeCatalog.Entry>,
current: ReadonlyArray<CodeModeCatalog.Entry>,
budget?: number,
) =>
CodeModeInstructions.update(
CodeModeCatalog.summarize({ tools: previous }, budget === undefined ? {} : { budget }),
CodeModeCatalog.summarize({ tools: current }, budget === undefined ? {} : { budget }),
)
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
describe("CodeModeCatalog.summarize", () => {
test("retains namespace inventory without retaining tools outside the inline budget", () => {
const catalog = CodeModeCatalog.summarize(
{ tools: Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)) },
{ budget: 0 },
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
0,
)
expect(catalog).toEqual({
total: 10_000,
@@ -43,8 +40,8 @@ describe("CodeModeCatalog.summarize", () => {
test("retains every namespace when no full tool listing fits", () => {
const catalog = CodeModeCatalog.summarize(
{ tools: [entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")] },
{ budget: 0 },
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
0,
)
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
@@ -52,10 +49,7 @@ describe("CodeModeCatalog.summarize", () => {
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)]
const catalog = CodeModeCatalog.summarize(
{ tools: [...pinned, entry("alpha.unpinned", "Unpinned")] },
{ budget: 0 },
)
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
@@ -69,48 +63,22 @@ describe("CodeModeCatalog.summarize", () => {
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
const namespaceCost = [
{ name: "alpha", count: 1, entries: [] },
{ name: "beta", count: 1, entries: [] },
].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0)
expect(
CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost })
.shown,
).toBe(2)
expect(
CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost - 1 })
.shown,
).toBe(1)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize({
tools: [entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)],
})
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
})
test("limits inline descriptions to 120 characters", () => {
const catalog = CodeModeCatalog.summarize({ tools: [entry("alpha.one", "x".repeat(121))] })
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
expect(description).toHaveLength(120)
expect(description).toEndWith("...")
})
test("always retains namespace descriptions and charges them before tool listings", () => {
const tool = entry("alpha.one", "One")
const listingCost = Math.round(` - ${tool.signature} // One`.length / 4)
const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4)
const description = "A namespace description that stays visible beyond the available tool budget"
const namespaces = new Map([["alpha", { name: "alpha", description }]])
expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1)
const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost })
expect(catalog.shown).toBe(0)
expect(catalog.namespaces[0]?.description).toBe(description)
expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`)
})
})
describe("CodeModeInstructions.render", () => {
@@ -136,8 +104,7 @@ describe("CodeModeInstructions.render", () => {
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" /**\n * @integer\n * @exclusiveMinimum 0\n */\n limit?: number,")
expect(partial).toContain(" /**\n * @integer\n * @minimum 0\n */\n offset?: number,")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
})
@@ -151,11 +118,7 @@ describe("CodeModeInstructions.render", () => {
)
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
const namespaceCost = [
{ name: "alpha", count: 2, entries: [] },
{ name: "beta", count: 1, entries: [] },
].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0)
const instructions = render([cheapAlpha, expensive, cheapBeta], 40 + namespaceCost)
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
expect(instructions).toContain("## Search")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
@@ -207,21 +170,6 @@ describe("CodeModeInstructions.update", () => {
)
})
test("restates namespace descriptions when they change", () => {
const previous = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]),
})
const current = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "New description" }]]),
})
const text = CodeModeInstructions.update(previous, current)
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("- notes (1 tool) // New description")
expect(text).not.toContain("Old description")
})
test("restates the full catalog when the rendering mode crosses full and compact", () => {
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
const text = update([echo], [echo, ...wide], 30)
@@ -9,13 +9,13 @@ import { Effect, Schema } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const echo: CodeModeCatalog.Tool = {
const echo: CodeModeCatalog.Entry = {
path: "notes.echo",
description: "Echo text",
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
}
const lookup: CodeModeCatalog.Tool = {
const lookup: CodeModeCatalog.Entry = {
path: "orders.lookup",
description: "Look up an order",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
@@ -24,16 +24,16 @@ const lookup: CodeModeCatalog.Tool = {
describe("CodeModeInstructions", () => {
it.effect("instructs the model not to call execute while the catalog is empty", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [] }))
const initialized = yield* readInitial(CodeModeInstructions.make([]))
expect(initialized.text).toBe(
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
)
const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), initialized)
const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(echo.signature)
expect(yield* readUpdate(CodeModeInstructions.make({ tools: [] }), { values: added.values })).toMatchObject({
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
text:
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
@@ -43,7 +43,7 @@ describe("CodeModeInstructions", () => {
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
@@ -51,13 +51,13 @@ describe("CodeModeInstructions", () => {
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo, lookup] }), initialized)
const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized)
expect(added.text).toContain("The Code Mode tool catalog has changed.")
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
expect(added.text).not.toContain("## Available tools")
const removed = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), { values: added.values })
const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values })
expect(removed.text).toBe(
"The Code Mode tool catalog has changed.\n\n" +
"The following tools are no longer available and must not be called: tools.orders.lookup.",
@@ -93,27 +93,22 @@ describe("CodeModeInstructions", () => {
const initialized = yield* Effect.scoped(
Effect.gen(function* () {
yield* tools.transform((draft) => {
draft.namespace({ name: "tools", description: "Project utilities" })
draft.add({ ...zeta, options: { namespace: "tools" } })
draft.add({ ...alpha, options: { namespace: "tools" } })
})
const snapshot = yield* tools.snapshot()
return yield* readInitial(CodeModeInstructions.make(snapshot.codeModeCatalog))
return yield* readInitial(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog))
}),
)
const reordered = yield* Effect.scoped(
Effect.gen(function* () {
yield* tools.transform((draft) => {
draft.namespace({ name: "tools", description: "Project utilities" })
draft.add({ ...alpha, options: { namespace: "tools" } })
draft.add({ ...zeta, options: { namespace: "tools" } })
})
const snapshot = yield* tools.snapshot()
return yield* readUpdate(CodeModeInstructions.make(snapshot.codeModeCatalog), initialized)
return yield* readUpdate(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), initialized)
}),
)
expect(initialized.text).toContain("- tools (2 tools) // Project utilities")
expect(reordered.changed).toBe(false)
expect(reordered.text).toBe("")
}).pipe(Effect.provide(layer))
@@ -147,20 +147,6 @@ describe("cross-spawn spawner", () => {
})
describe("stderr", () => {
fx.live(
"captures both streams across backpressure",
Effect.gen(function* () {
const handle = yield* js(
'process.stdout.write("o".repeat(256 * 1024)); process.stderr.write("e".repeat(256 * 1024))',
)
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["o".repeat(256 * 1024), "e".repeat(256 * 1024)])
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"captures stderr output",
Effect.gen(function* () {
@@ -194,6 +180,37 @@ describe("cross-spawn spawner", () => {
})
describe("combined output (all)", () => {
for (const output of ["stdout", "stderr", "all"] as const) {
fx.live(
`captures ${output} when reading starts after process exit`,
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
// Let exit callbacks finish before attaching a reader; the handle scope remains open.
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)))
expect((yield* decodeByteStream(handle[output])).split("\n").toSorted()).toEqual(
output === "all" ? ["stderr", "stdout"] : [output],
)
}).pipe(Effect.timeout("3 seconds")),
)
}
fx.live(
"drains output larger than the capture buffers",
Effect.gen(function* () {
const text = "x".repeat(1024 * 1024)
const handle = yield* js(
`const text = "x".repeat(${text.length}); process.stdout.write(text); process.stderr.write(text)`,
)
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: 2,
})
expect(stdout).toBe(text)
expect(stderr).toBe(text)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}).pipe(Effect.timeout("3 seconds")),
)
fx.effect(
"captures stdout via .all when no stderr",
Effect.gen(function* () {
@@ -213,30 +230,6 @@ describe("cross-spawn spawner", () => {
)
})
describe("delayed output consumption", () => {
for (const combined of [false, true]) {
fx.live(
`retains ${combined ? "combined" : "separate"} output after process completion`,
Effect.gen(function* () {
const handle = yield* js(
'require("node:fs").writeSync(1, "stdout\\n"); require("node:fs").writeSync(2, "stderr\\n")',
)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
if (combined) {
const output = yield* decodeByteStream(handle.all)
expect(output).toContain("stdout")
expect(output).toContain("stderr")
return
}
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["stdout", "stderr"])
}),
)
}
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",
@@ -255,6 +248,63 @@ describe("cross-spawn spawner", () => {
})
describe("process control", () => {
fx.live(
"reports exit without waiting for unread stdout",
Effect.gen(function* () {
const handle = yield* js("process.stdout.write(Buffer.alloc(1024 * 1024)); process.exit(0)")
expect(yield* Effect.promise(() => gone(Number(handle.pid)))).toBe(true)
expect(yield* handle.exitCode.pipe(Effect.timeout("500 millis"))).toBe(ChildProcessSpawner.ExitCode(0))
expect(yield* handle.isRunning).toBe(false)
}),
)
fx.live(
"releases a process with unread buffered stdout",
Effect.gen(function* () {
const pid = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* js(
'process.stdout.write("x".repeat(1024 * 1024)); process.stderr.write("ready"); setInterval(() => {}, 10_000)',
{ forceKillAfter: 100 },
)
expect(yield* decodeByteStream(handle.stderr.pipe(Stream.take(1)))).toBe("ready")
return Number(handle.pid)
}),
)
expect(yield* Effect.promise(() => gone(pid))).toBe(true)
}).pipe(Effect.timeout("3 seconds")),
)
// Node puts non-detached Windows children in a kill-on-parent-exit job; this guards POSIX group cleanup.
const groupTest = process.platform === "win32" ? fx.live.skip : fx.live
groupTest(
"preserves successful descendants when an exit-only scope closes",
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const pidFile = path.join(tmp.path, "child.pid")
yield* Effect.addFinalizer(() =>
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
Effect.ignore,
),
)
yield* Effect.scoped(
Effect.gen(function* () {
// This fixture's child shares the process group and holds stdio after the parent exits on stdin EOF.
const handle = yield* ChildProcess.make(
"node",
[path.join(import.meta.dir, "../fixture/held-stdio.cjs"), "mcp", pidFile],
{ stdin: "ignore", forceKillAfter: 100 },
)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
expect(alive(Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8"))))).toBe(true)
}).pipe(Effect.timeout("3 seconds")),
)
for (const mode of ["exit", "SIGKILL"] as const) {
const test = mode === "SIGKILL" && process.platform === "win32" ? fx.live.skip : fx.live
test(
+1 -1
View File
@@ -35,7 +35,7 @@ export function waitForCodeModeTool(
): Effect.Effect<Tool.Snapshot, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
}
+5 -9
View File
@@ -1764,9 +1764,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain(
"ok: boolean",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
expect(execute?.description).not.toContain("tools.demo.search")
}),
)
@@ -1784,9 +1782,7 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools"
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain(
"sessionID",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
const directSessionID = Session.ID.make("ses_mcp_direct")
yield* toolSet.execute({
@@ -1830,7 +1826,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true)
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
@@ -1916,7 +1912,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const fiber = yield* toolSet
.execute({
@@ -1960,7 +1956,7 @@ it.effect("does not call MCP when permission is blocked", () =>
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_blocked"),
-17
View File
@@ -5,23 +5,6 @@ import { Provider } from "@opencode-ai/core/provider"
const decode = Schema.decodeUnknownSync(Model.Ref)
describe("Model.parse", () => {
test.each([
["vendor/model", "vendor", "model"],
["vendor/team/model", "vendor", "team/model"],
["vendor", "vendor", ""],
["", "", ""],
["/model", "", "model"],
["vendor/", "vendor", ""],
["vendor//model/", "vendor", "/model/"],
])("parses %j at the first slash", (input, providerID, modelID) => {
expect(Model.parse(input)).toEqual({
providerID: Provider.ID.make(providerID),
modelID: Model.ID.make(modelID),
})
})
})
describe("Model.Ref", () => {
test("accepts a model selection without a variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
-10
View File
@@ -248,16 +248,6 @@ describe("Patch", () => {
).toBe("line 1\nline 2\nadded 1\nadded 2\n")
})
test.each(["", "original\n"])("preserves equal-offset insertion order and frozen chunks for %j", (original) => {
const chunks = Object.freeze([
Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["first"]) }),
Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["second", "third"]) }),
])
const expected = { content: original + "first\nsecond\nthird\n", bom: false }
expect(Patch.derive("update.txt", chunks, original)).toEqual(expected)
expect(Patch.derive("update.txt", chunks, original)).toEqual(expected)
})
test("applies a pure-addition chunk after an earlier replacement", () => {
expect(
Patch.derive(
-1
View File
@@ -104,7 +104,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
reload: () => Effect.die("unused mcp.reload"),
},
permission: overrides.permission ?? {
assert: () => Effect.die("unused permission.assert"),
hook: () => Effect.die("unused permission.hook"),
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
@@ -1,189 +0,0 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Permission } from "@opencode-ai/core/permission"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/promise/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Cause, Deferred, Effect, Exit, Fiber, Queue } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
import { testEffect } from "../lib/effect"
const it = testEffect(
LayerNode.compile(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), {
replacements: [
Location.node.replace(tempLocationLayer),
Config.node.replace(Config.testLayer()),
Mcp.node.replace(emptyMcpLayer),
],
}),
)
const setup = Effect.gen(function* () {
const database = yield* Database.Service
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const asked = yield* Queue.unbounded<void>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Permission.Event.Asked.type ? Queue.offer(asked, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const ready = yield* Deferred.make<Context>()
yield* plugins.activate([{ id: "permission-test", version: "1", effect: (ctx) => Deferred.succeed(ready, ctx) }])
const ctx = yield* Deferred.await(ready)
yield* ctx.agent.transform((draft) =>
draft.update("permission-test", (agent) => {
agent.permissions = [
{ action: "deploy", resource: "*", effect: "ask" },
{ action: "deploy", resource: "allowed", effect: "allow" },
{ action: "deploy", resource: "blocked", effect: "deny" },
]
}),
)
const sessionID = Session.ID.create()
yield* database.db
.insert(ProjectTable)
.values({ id: location.project.id, worktree: location.directory, sandboxes: [] })
.onConflictDoNothing()
.run()
yield* database.db
.insert(SessionTable)
.values({
id: sessionID,
project_id: location.project.id,
slug: "permission-test",
directory: location.directory,
title: "Permission test",
version: "test",
agent: "missing",
})
.run()
const input = {
id: Permission.ID.create(),
sessionID,
agent: Agent.ID.make("permission-test"),
action: "deploy",
resources: ["staging"],
save: ["staging"],
metadata: { environment: "staging" },
source: { type: "tool", messageID: "msg_test", id: "call_test" },
} satisfies Permission.AssertInput
return { ctx, input, asked }
})
describe("plugin permission.assert", () => {
it.live("preserves Effect decisions, rejection defects, feedback, and cancellation cleanup", () =>
Effect.gen(function* () {
const { ctx, input, asked } = yield* setup
expect(yield* ctx.permission.assert({ ...input, resources: ["allowed"] })).toBeUndefined()
expect(yield* ctx.permission.assert({ ...input, resources: ["blocked"] }).pipe(Effect.flip)).toBeInstanceOf(
Permission.BlockedError,
)
expect(yield* ctx.permission.list(input)).toEqual([])
yield* Effect.forEach(["once", "reject", "feedback", "cancel"] as const, (reply) =>
Effect.gen(function* () {
const fiber = yield* ctx.permission.assert(input).pipe(Effect.forkScoped)
yield* Queue.take(asked)
expect(fiber.pollUnsafe()).toBeUndefined()
expect(yield* ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
id: input.id,
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
})
if (reply === "cancel") yield* Fiber.interrupt(fiber)
if (reply !== "cancel")
yield* ctx.permission.reply({
sessionID: input.sessionID,
requestID: input.id,
reply: reply === "feedback" ? "reject" : reply,
message: reply === "feedback" ? "Use the test environment" : undefined,
})
const exit = yield* Fiber.await(fiber)
if (reply === "once") expect(exit).toEqual(Exit.succeed(undefined))
if (reply !== "once") {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
if (reply === "cancel") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
if (reply === "reject")
expect(exit.cause.reasons).toContainEqual(
expect.objectContaining({ _tag: "Die", defect: expect.any(Permission.DeclinedError) }),
)
if (reply === "feedback")
expect(exit.cause.reasons).toContainEqual(
expect.objectContaining({
_tag: "Fail",
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
}),
)
}
}
expect(yield* ctx.permission.list(input)).toEqual([])
}),
)
}),
)
it.live("decodes Promise inputs and preserves void results and permission errors through the real host", () =>
Effect.gen(function* () {
const { ctx, input, asked } = yield* setup
yield* PluginPromise.fromPromise(
define({
id: "promise-permission-test",
setup: async (ctx) => {
await expect(
Reflect.apply(ctx.permission.assert, undefined, [{ ...input, resources: [42] }]),
).rejects.toBeDefined()
expect(await ctx.permission.list(input)).toEqual([])
expect(await ctx.permission.assert({ ...input, id: null, resources: ["allowed"] })).toBeUndefined()
await expect(ctx.permission.assert({ ...input, resources: ["blocked"] })).rejects.toBeInstanceOf(
Permission.BlockedError,
)
for (const reply of ["once", "reject", "feedback"] as const) {
const pending = ctx.permission.assert(input)
const settled = pending.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
)
await Effect.runPromise(Queue.take(asked))
expect(await ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
metadata: input.metadata,
source: input.source,
save: input.save,
})
await ctx.permission.reply({
sessionID: input.sessionID,
requestID: input.id,
reply: reply === "feedback" ? "reject" : reply,
...(reply === "feedback" ? { message: "Use the test environment" } : {}),
})
if (reply === "once") expect(await settled).toEqual({ value: undefined })
if (reply === "reject") expect(await settled).toEqual({ error: expect.any(Permission.DeclinedError) })
if (reply === "feedback")
expect(await settled).toEqual({
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
})
expect(await ctx.permission.list(input)).toEqual([])
}
},
}),
).effect(ctx)
}),
)
})
+2 -2
View File
@@ -963,7 +963,7 @@ describe("fromPromise", () => {
})
const original = yield* registry.snapshot()
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(original.codeModeCatalog?.tools).toEqual([])
expect(original.codeModeCatalog).toEqual([])
yield* PluginPromise.fromPromise(
define({
@@ -981,7 +981,7 @@ describe("fromPromise", () => {
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["acme.hello"])
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(
yield* snapshot.execute({
+7 -9
View File
@@ -115,15 +115,13 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
codeModeCatalog: {
tools: [
{
path: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},
],
},
codeModeCatalog: [
{
path: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},
],
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
execute: () => Effect.die(new Error("unused")),
}),
+25 -42
View File
@@ -1454,49 +1454,32 @@ describe("SessionRunnerLLM", () => {
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
})
for (const delivery of ["steer", "queue"] as const) {
scenario(`preserves a tool continuation and step allowance across chained moves (${delivery})`, function* (s) {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("build"), (agent) => {
agent.steps = 2
}),
)
yield* s.admit("Echo before moving")
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* Effect.forEach(["steer", delivery] as const, (delivery) =>
s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery,
},
}),
)
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(messageRoles(s.requests[1])?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(s.requests[0]?.toolChoice).toBeUndefined()
expect(s.requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === "session.step.started.1" || type === "session.moved.1",
),
).toEqual(["session.step.started.1", "session.moved.1", "session.moved.1", "session.step.started.1"])
expect(yield* s.inbox).toEqual([])
scenario("preserves a tool continuation across a steered move", function* (s) {
yield* s.admit("Echo before moving")
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
}
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests.map(messageRoles).at(1)?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(yield* s.inbox).toEqual([])
})
scenario("keeps queued input parked across a mid-turn move", function* (s) {
yield* s.admit("Echo before moving")
+40
View File
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
@@ -272,6 +273,45 @@ describe("Session.shell", () => {
)
}
it.effect("keeps success when the invocation timeout expires during post-exit capture", () =>
Effect.gen(function* () {
const fixture = yield* setup
const pidFile = path.join(fixture.tmp.path, "child.pid")
yield* Effect.addFinalizer(() =>
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
Effect.ignore,
),
)
const info = yield* fixture.shell.create({
command: `node "${path.join(import.meta.dir, "fixture/held-stdio.cjs")}" exit "${pidFile}"`,
timeout: 500,
})
const completion = yield* fixture.shell.wait(info.id).pipe(Effect.forkScoped)
// Wait for the real process without advancing its invocation timeout or capture deadline.
yield* fixture.shell
.get(info.id)
.pipe(
Effect.repeat({ until: (info) => info.status === "exited", schedule: Schedule.spaced("10 millis") }),
Effect.timeout("3 seconds"),
TestClock.withLive,
)
yield* TestClock.adjust("500 millis")
expect(yield* fixture.shell.get(info.id)).toMatchObject({ status: "exited", exit: 0 })
expect(completion.pollUnsafe()).toBeUndefined()
yield* TestClock.adjust("500 millis")
expect(yield* Fiber.join(completion).pipe(Effect.timeout("3 seconds"), TestClock.withLive)).toMatchObject({
status: "exited",
exit: 0,
})
const result = yield* fixture.shell.result(info)
expect(result.capture?.output).toContain("foreground-out")
expect(result.capture?.output).toContain("foreground-err")
const pid = Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8")))
expect(() => process.kill(pid, 0)).not.toThrow()
}),
)
for (const outcome of [
{
status: "killed",
+1 -1
View File
@@ -17,7 +17,7 @@ const context = {
}
const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context))
CodeModeTool.create(tools, (_, tool, input, context) => execute(tool, input, context))
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
-16
View File
@@ -241,22 +241,6 @@ describe("PatchTool", () => {
),
)
it.live("replaces a file with a directory containing an added file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "parent"), "before\n"))
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: parent\n*** Add File: parent/child.txt\n+after\n*** End Patch"),
)
expect(settled.status).toBe("completed")
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "parent/child.txt"), "utf8"))).toBe(
"after\n",
)
}),
),
)
it.live("counts deleted lines with and without a trailing newline", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
+11 -100
View File
@@ -230,7 +230,7 @@ describe("Tool", () => {
})
const scope = yield* Scope.make()
yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope))
expect((yield* service.snapshot()).codeModeCatalog?.tools).toEqual([])
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
@@ -239,7 +239,7 @@ describe("Tool", () => {
yield* Fiber.join(reload)
const refreshed = yield* service.snapshot()
expect(refreshed.definitions[0]?.description).toBe("Updated")
expect(refreshed.codeModeCatalog?.tools).toEqual([])
expect(refreshed.codeModeCatalog).toEqual([])
expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" })
@@ -247,7 +247,7 @@ describe("Tool", () => {
yield* update.dispose
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["hidden"])
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["hidden"])
yield* service.transform((draft) =>
draft.update("acme_echo", (tool) => {
@@ -440,7 +440,7 @@ describe("Tool", () => {
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools).toEqual([])
expect(snapshot.codeModeCatalog).toEqual([])
}).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -465,7 +465,7 @@ describe("Tool", () => {
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
expect((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" })
expect(snapshot.codeModeCatalog?.tools).toEqual([])
expect(snapshot.codeModeCatalog).toEqual([])
}),
)
@@ -502,7 +502,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([
"-lookup",
"123",
"123._private.-tools.2d_get_scene",
@@ -534,7 +534,6 @@ describe("Tool", () => {
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "invalid..namespace", description: "Invalid" })
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
@@ -542,95 +541,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
}),
)
it.effect("keeps namespace descriptions beside catalog tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "registry", description: "Package publishing and discovery" })
draft.namespace({ name: "registry.search", description: "Pricing operations" })
draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } })
draft.add({ ...make(), name: "direct", options: { namespace: "registry", codemode: false } })
draft.add({ ...make(), name: "search", description: "Search packages", options: { namespace: "registry" } })
draft.add({ ...make(), name: "sales", description: "Read sales", options: { namespace: "registry.search" } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
"legacy.plain",
"registry.search",
"registry.search.sales",
])
expect(snapshot.codeModeCatalog?.namespaces).toEqual(
new Map([
["registry", { name: "registry", description: "Package publishing and discovery" }],
["registry.search", { name: "registry.search", description: "Pricing operations" }],
]),
)
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "namespace-search",
name: "execute",
input: { code: 'return search({ query: "pricing operations" })' },
},
})
expect(result.output).toMatchObject({ output: expect.stringContaining("tools.registry.search") })
const callable = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "callable-namespace",
name: "execute",
input: {
code: `return await Promise.all([
tools.registry.search({ text: "search" }),
tools.registry.search.sales({ text: "sales" }),
])`,
},
},
})
expect(callable.output).toMatchObject({
output: expect.stringContaining('"text": "sales"'),
toolCalls: [
{ tool: "registry.search", status: "completed" },
{ tool: "registry.search.sales", status: "completed" },
],
})
}),
)
it.effect("preserves a top-level tool that also has child tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "pricing", description: "Pricing operations" })
draft.add({ ...make(), name: "pricing" })
draft.add({ ...make(), name: "sales", options: { namespace: "pricing" } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "top-level-callable",
name: "execute",
input: {
code: `return await Promise.all([
tools.pricing({ text: "pricing" }),
tools.pricing.sales({ text: "sales" }),
])`,
},
},
})
expect(result.output).toMatchObject({ output: expect.stringContaining('"text": "sales"') })
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
}),
)
@@ -664,7 +575,7 @@ describe("Tool", () => {
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["codemode"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -729,7 +640,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
expect(snapshot.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
}),
)
@@ -739,7 +650,7 @@ describe("Tool", () => {
const available = yield* service.snapshot()
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(available.codeModeCatalog?.tools).toEqual([])
expect(available.codeModeCatalog).toEqual([])
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
expect(denied.definitions).toEqual([])
@@ -1192,7 +1103,7 @@ describe("Tool", () => {
}).pipe(Scope.provide(scope))
const toolSet = yield* service.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)
-1
View File
@@ -35,7 +35,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -1,284 +0,0 @@
import type { Browser } from "@opencode-ai/schema/browser"
import electron, { type BrowserWindow } from "electron"
type AXNode = {
nodeId: string
backendDOMNodeId?: number
childIds?: string[]
ignored?: boolean
role?: { value?: string }
name?: { value?: unknown }
properties?: Array<{ name: string; value?: { value?: unknown } }>
}
export type BrowserPage = ReturnType<typeof createBrowserPage>
export function createBrowserPage(win: BrowserWindow, publish: (error?: string) => void, fail: () => void) {
const view = new electron.WebContentsView({
webPreferences: {
partition: `opencode-browser-${crypto.randomUUID()}`,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
webviewTag: false,
devTools: false,
disableDialogs: true,
},
})
const contents = view.webContents
const refs = new Map<string, { id: number; editable: boolean }>()
let generation = 0
let nextRef = 0
let closed = false
const state = (): Browser.State => ({
url: contents.getURL().slice(0, 16_384),
title: contents.getTitle().slice(0, 1_024),
loading: contents.isLoading(),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
generation,
})
const update = () => {
if (!closed) publish()
}
contents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
const step =
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
? 0.5
: input.key === "-" || input.code === "NumpadSubtract"
? -0.5
: 0
if (!step && input.key !== "0") return
event.preventDefault()
contents.setZoomLevel(input.key === "0" ? 0 : contents.getZoomLevel() + step)
})
const session = contents.session
session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
session.setPermissionCheckHandler(() => false)
session.setDevicePermissionHandler(() => false)
session.setDisplayMediaRequestHandler((_request, callback) => callback({}))
session.on("will-download", (event) => event.preventDefault())
contents.setWindowOpenHandler(() => ({ action: "deny" }))
contents.on("content-bounds-updated", (event) => event.preventDefault())
const guard = (event: Electron.Event<{ url: string }>) => {
if (event.url === "about:blank" || destinationOrigin(event.url)) return
event.preventDefault()
publish("ERR_BLOCKED_BY_CLIENT")
}
contents.on("will-frame-navigate", guard)
contents.on("will-redirect", guard)
contents.on("did-stop-loading", update)
contents.on("did-navigate-in-page", update)
contents.on("page-title-updated", update)
contents.on("did-start-navigation", (event) => {
if (!event.isMainFrame) return
generation++
refs.clear()
update()
})
contents.on("did-fail-load", (_event, code, error, _url, mainFrame) => {
if (!closed && mainFrame && code !== -3) publish(error)
})
contents.on("render-process-gone", () => {
if (!closed) fail()
})
contents.debugger.on("detach", () => {
if (!closed) fail()
})
view.setVisible(false)
win.contentView.addChildView(view)
return {
view,
state,
execute,
ready: Promise.resolve().then(() => contents.loadURL("about:blank")),
dispose() {
if (closed) return
closed = true
refs.clear()
if (!win.isDestroyed()) win.contentView.removeChildView(view)
if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false })
},
}
async function execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
const action = command.action
check()
if (action.type === "navigate") {
await navigate(action.url, signal)
return { type: "state", state: state() }
}
if (["open", "back", "forward", "reload", "stop"].includes(action.type)) {
if (action.type === "stop") contents.stop()
if (action.type === "reload") contents.reload()
if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
if (action.type === "forward" && contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
return { type: "state", state: state() }
}
if (action.type === "evaluate") {
const value: unknown = await contents.executeJavaScript(action.script)
check()
return { type: "evaluate", state: state(), content: (JSON.stringify(value) ?? "null").slice(0, 100_000) }
}
if (action.type === "snapshot") {
const tree = (await send("Accessibility.getFullAXTree", { depth: 6 })) as { nodes: AXNode[] }
refs.clear()
const nodes = new Map(tree.nodes.map((node) => [node.nodeId, node]))
const lines = [`Page: ${clean(state().title)}`, `URL: ${state().url}`, ""]
if (tree.nodes[0]) walk(tree.nodes[0], 0)
return {
type: "snapshot",
state: state(),
content: lines.join("\n").slice(0, 40_960),
}
function walk(node: AXNode, depth: number) {
if (depth > 6 || lines.length >= 503) return
const role = (node.role?.value ?? "node").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40)
const properties = new Map((node.properties ?? []).map((item) => [item.name, item.value?.value]))
const editable =
["textbox", "searchbox", "combobox", "spinbutton"].includes(role) || !!properties.get("editable")
if (!node.ignored) {
const actionable = properties.get("focusable") || /^(button|link|textbox|combobox)$/.test(role)
const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : ""
if (ref && node.backendDOMNodeId)
refs.set(ref, {
id: node.backendDOMNodeId,
editable: editable && !properties.get("disabled") && !properties.get("readonly"),
})
const flags = ["checked", "disabled", "expanded", "selected"].flatMap((flag) =>
properties.has(flag) ? [`${flag}=${properties.get(flag)}`] : [],
)
lines.push(
`${" ".repeat(depth)}${ref ? `@${ref}` : ""} [${role}] ${JSON.stringify(clean(node.name?.value))} ${flags.join(" ")}`,
)
}
// Editable descendants can repeat the field's value as static text.
if (!editable)
node.childIds?.forEach((id) => {
const child = nodes.get(id)
if (child) walk(child, depth + 1)
})
}
}
if (action.type === "screenshot") {
const source = await contents.capturePage()
check()
const size = source.getSize()
if (!size.width || !size.height) throw new Error("internal")
const scale = Math.min(1, 2_000 / Math.max(size.width, size.height))
const image = source.resize({
width: Math.max(1, Math.round(size.width * scale)),
height: Math.max(1, Math.round(size.height * scale)),
})
const data = new Uint8Array(image.toPNG())
if (data.byteLength > 5 * 1_024 * 1_024) throw new Error("result_too_large")
return { type: "screenshot", state: state(), data }
}
if (action.type === "click" || action.type === "fill") {
const target = refs.get(action.ref.replace(/^@/, ""))
if (!target || (action.type === "fill" && !target.editable)) throw new Error("stale_ref")
if (action.type === "fill") {
await send("DOM.focus", { backendNodeId: target.id })
await key({ key: "a", code: "KeyA", modifiers: process.platform === "darwin" ? 4 : 2 })
await key({ key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 })
await send("Input.insertText", { text: action.text })
}
if (action.type === "click") {
await send("DOM.scrollIntoViewIfNeeded", { backendNodeId: target.id })
const result = (await send("DOM.getBoxModel", { backendNodeId: target.id })) as {
model: { content: number[] }
}
const box = result.model.content
const point = { x: (box[0] + box[4]) / 2, y: (box[1] + box[5]) / 2 }
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
await send("Input.dispatchMouseEvent", { type, ...point, button: "left", clickCount: 1 })
}
}
}
if (action.type === "press") {
const codes: Record<Browser.Key, number> = {
Enter: 13,
Tab: 9,
Escape: 27,
Backspace: 8,
Delete: 46,
ArrowUp: 38,
ArrowDown: 40,
ArrowLeft: 37,
ArrowRight: 39,
PageUp: 33,
PageDown: 34,
Home: 36,
End: 35,
Space: 32,
}
await key({
key: action.key === "Space" ? " " : action.key,
code: action.key,
windowsVirtualKeyCode: codes[action.key],
})
}
if (action.type === "scroll") {
const bounds = view.getBounds()
await send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: bounds.width / 2,
y: bounds.height / 2,
deltaX: action.direction === "left" ? -action.pixels : action.direction === "right" ? action.pixels : 0,
deltaY: action.direction === "up" ? -action.pixels : action.direction === "down" ? action.pixels : 0,
})
}
check()
return { type: "state", state: state() }
function check() {
if (closed) throw new Error("not_attached")
if (signal.aborted) throw new Error("aborted")
// Opening may create a new document before the command runs.
if (action.type !== "open" && generation !== command.generation) throw new Error("stale_ref")
}
function key(params: Record<string, unknown>) {
return send("Input.dispatchKeyEvent", { type: "keyDown", ...params }).finally(() =>
contents.debugger.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", ...params }),
)
}
async function send(method: string, params: Record<string, unknown>): Promise<unknown> {
check()
if (!contents.debugger.isAttached()) contents.debugger.attach("1.3")
const result: unknown = await contents.debugger.sendCommand(method, params)
check()
return result
}
}
async function navigate(input: string, signal: AbortSignal) {
const value = input.trim() || "about:blank"
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
const url =
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value)
? value
: `${local ? "http" : "https"}://${value}`
if (url.length > 16_384 || (url !== "about:blank" && !destinationOrigin(url))) throw new Error("invalid_url")
const cancel = () => {
if (!closed) contents.stop()
}
signal.addEventListener("abort", cancel, { once: true })
await contents.loadURL(url).finally(() => signal.removeEventListener("abort", cancel))
if (signal.aborted) throw new Error("aborted")
}
}
function clean(value: unknown) {
return typeof value === "string" ? value.replaceAll(/\s+/g, " ").trim().slice(0, 300) : ""
}
export function destinationOrigin(input: string) {
if (!URL.canParse(input)) return
const url = new URL(input)
return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined
}
@@ -1,15 +0,0 @@
import { expect, test } from "bun:test"
import { destinationOrigin } from "./browser-chromium"
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
expect(destinationOrigin("http://localhost:3000/")).toBe("http://localhost:3000")
for (const url of [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,test",
"https://user:pass@example.com",
]) {
expect(destinationOrigin(url)).toBeUndefined()
}
})
-242
View File
@@ -1,242 +0,0 @@
import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode-ai/app/desktop"
import { NodeHttpClient } from "@effect/platform-node"
import { Browser } from "@opencode-ai/schema/browser"
import { OpenCode } from "@opencode-ai/client/effect"
import { SessionID } from "@opencode-ai/schema/session-id"
import type { BrowserWindow } from "electron"
import { Deferred, Effect, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
import { createBrowserPage, destinationOrigin, type BrowserPage } from "./browser-chromium"
import { emitIpcEvent } from "./ipc-events"
type Entry = {
bindingID: string
win: BrowserWindow
abort: AbortController
registered: PromiseWithResolvers<void>
requests: Map<string, AbortController>
report?: (event: Extract<BrowserPaneEvent["event"], { type: "state" }>) => void
cleanup?: () => void
page?: BrowserPage
}
export function createBrowserPane() {
const entries = new Map<string, Entry>()
// Keep long-lived RPC requests off Chromium's shared HTTP connection pool.
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
let disposed = false
return {
async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) {
if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid")
if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid")
if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid")
if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable")
const sessionID = SessionID.make(target.sessionID)
const entry: Entry = {
bindingID,
win,
abort: new AbortController(),
registered: Promise.withResolvers(),
requests: new Map(),
}
const stop = () => close(entry)
const navigate = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
if (event.isMainFrame && !event.isSameDocument) stop()
}
win.webContents.once("destroyed", stop)
win.webContents.on("did-start-navigation", navigate)
entry.cleanup = () => {
win.webContents.off("destroyed", stop)
win.webContents.off("did-start-navigation", navigate)
}
entries.set(bindingID, entry)
void runtime
.runPromise(
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe(
Effect.provideService(
HttpClient.HttpClient,
target.endpoint.password
? HttpClient.mapRequest(
http,
HttpClientRequest.basicAuth(target.endpoint.username ?? "opencode", target.endpoint.password),
)
: http,
),
)
const session = yield* client.session.get({ sessionID })
const options = {
location: { directory: session.location.directory, workspace: session.location.workspaceID },
}
const attachment = { sessionID, connectionID: crypto.randomUUID() }
const rpc = client.rpc(Browser.Definition)
const connected = yield* Deferred.make<void>()
const outbound = yield* Queue.unbounded<Effect.Effect<void, unknown>>()
// Report state before publishing it locally or completing a command.
entry.report = (event) => {
Queue.offerUnsafe(
outbound,
rpc
.state({ ...attachment, state: event.state }, options)
.pipe(Effect.tap(() => Effect.sync(() => publish(entry, event)))),
)
}
const receive = client.event.subscribe().pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (event.type === "server.connected") {
yield* Deferred.succeed(connected, undefined)
return
}
if (event.type !== "rpc.experimental.browser.control") return
const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data)
if (message.connectionID !== attachment.connectionID) return
if (message.type === "attached") return entry.registered.resolve()
if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort()
const abort = new AbortController()
entry.requests.set(message.requestID, abort)
yield* Effect.promise(async () => {
const outcome: Browser.Outcome = await execute(entry, message.command, abort.signal).then(
(result) => ({ type: "success" as const, result }),
(error: unknown) => ({
type: "failure" as const,
message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
}),
)
Queue.offerUnsafe(
outbound,
rpc.result(
{
...attachment,
requestID: message.requestID,
outcome: Schema.encodeSync(Browser.Outcome)(outcome),
},
options,
),
)
}).pipe(
Effect.ensuring(
Effect.sync(() => {
abort.abort()
entry.requests.delete(message.requestID)
}),
),
Effect.catchCause(() => Effect.sync(stop)),
Effect.forkScoped,
)
}),
),
)
yield* Effect.raceAllFirst([
receive,
Stream.fromQueue(outbound).pipe(Stream.runForEach((send) => send)),
Deferred.await(connected).pipe(Effect.andThen(rpc.attach(attachment, options))),
])
}).pipe(Effect.scoped, Effect.ensuring(Effect.sync(stop))),
{ signal: entry.abort.signal },
)
.catch(stop)
await entry.registered.promise
if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed")
publishState(entry)
},
layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) {
const entry = owned(win, bindingID)
if (!value) return closePage(entry)
const bounds = value.bounds
if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
entry.page?.view.setVisible(false)
return
}
const page = create(entry)
page.view.setBounds(bounds)
page.view.setVisible(true)
},
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
const entry = owned(win, bindingID)
await execute(
entry,
{ action: command, generation: entry.page?.state().generation ?? 0 },
new AbortController().signal,
)
},
async close(win: BrowserWindow, bindingID: string) {
close(owned(win, bindingID))
},
async dispose() {
disposed = true
entries.forEach(close)
await runtime.dispose()
},
}
function owned(win: BrowserWindow, bindingID: string) {
const entry = entries.get(bindingID)
if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable")
return entry
}
function publish(entry: Entry, event: BrowserPaneEvent["event"]) {
if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return
emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event }))
}
function close(entry: Entry) {
if (entries.get(entry.bindingID) !== entry) return
entry.report = undefined
closePage(entry, "browser.pane.registration.closed")
entries.delete(entry.bindingID)
entry.registered.reject(new Error("browser.pane.registration.closed"))
entry.cleanup?.()
entry.abort.abort()
}
function closePage(entry: Entry, error?: string) {
entry.requests.forEach((request) => request.abort())
entry.requests.clear()
entry.page?.dispose()
entry.page = undefined
publishState(entry, error)
}
function publishState(entry: Entry, error?: string) {
const event = {
type: "state" as const,
state: entry.page?.state() ?? null,
...(error === undefined ? {} : { error }),
}
if (entry.report) return entry.report(event)
publish(entry, event)
}
function create(entry: Entry) {
if (entry.page) return entry.page
const fail = () => {
if (entry.page === page) closePage(entry, "page_crashed")
}
const page = createBrowserPage(
entry.win,
(error) => {
if (entry.page === page) publishState(entry, error)
},
fail,
)
entry.page = page
void page.ready
.then(() => {
if (entry.page === page) publishState(entry)
})
.catch(fail)
return page
}
async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) {
if (command.action.type === "open") publish(entry, { type: "open" })
const page = command.action.type === "open" ? create(entry) : entry.page
if (!page) throw new Error("not_attached")
await page.ready
return page.execute(command, signal)
}
}
@@ -1,35 +1,14 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { createBrowserPane } from "../browser-pane"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/protocol"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const shutdown = yield* Shutdown.Service
const browser = createBrowserPane()
const stop = Effect.promise(() => browser.dispose())
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
BrowserPane: ({ request }, context) =>
Effect.tryPromise(async () => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
return browser.close(win, request.bindingID)
}).pipe(Effect.orDie),
})
}),
)
@@ -1,9 +1,7 @@
import type { BrowserPaneEvent } from "@opencode-ai/app/desktop"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -25,11 +23,6 @@ export type UpdaterAPI = {
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
browserPane: {
request(request: BrowserPaneRequest): Promise<void>
send(request: BrowserPaneRequest): void
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
-5
View File
@@ -25,11 +25,6 @@ const updaterHandler = (state: UpdaterState) => {
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
browserPane: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(mutable(value))),
},
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
@@ -30,33 +30,6 @@ export function createDesktopPlatform(
windowID: windowState.id,
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
...createDesktopStorage(api),
browserPane: {
register(target, onEvent) {
const bindingID = crypto.randomUUID()
let closed = false
const dispose = api.browserPane.onEvent((value) => {
if (!closed && value.bindingID === bindingID) onEvent(value.event)
})
const ready = api.browserPane.request({ type: "register", bindingID, target })
return {
setLayout(layout) {
if (!closed)
void ready
.then(() =>
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
)
.catch(() => undefined)
},
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
close() {
if (closed) return
closed = true
dispose()
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
},
}
},
},
updater,
exportDebugLogs: () => api.exportDebugLogs(),
setForceFocus: (enabled) => api.setForceFocus(enabled),
@@ -1,31 +0,0 @@
import { Browser } from "@opencode-ai/schema/browser"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
const bindingID = text(128)
const endpoint = Schema.Struct({
url: text(16_384),
username: Schema.optionalKey(text(1_024)),
password: Schema.optionalKey(text(4_096)),
})
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
const layout = Schema.Struct({ visible: Schema.Boolean, bounds: Schema.optionalKey(bounds) })
export const BrowserPaneRequestSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
])
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
export const BrowserPaneEventSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("open") }),
Schema.Struct({
type: Schema.Literal("state"),
state: Schema.NullOr(Browser.State),
error: Schema.optionalKey(Schema.String),
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
@@ -1,14 +1,8 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
bindingID: Schema.String,
event: BrowserPaneEventSchema,
}) {}
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
urls: Schema.Array(Schema.String),
}) {}
@@ -38,7 +32,6 @@ export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("
}) {}
export const DesktopEvent = Schema.Union([
BrowserPaneEvent,
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
@@ -50,4 +43,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents)
-2
View File
@@ -13,8 +13,6 @@ export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Session } from "@opencode-ai/schema/session"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+1 -3
View File
@@ -1,8 +1,7 @@
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/effect/api"
import type { PermissionApi } from "@opencode-ai/client/effect/api"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Permission } from "@opencode-ai/schema/permission"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect } from "effect"
import type { Hooks } from "./registration.js"
export interface PermissionEvaluation {
@@ -21,6 +20,5 @@ export interface PermissionHooks {
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly assert: (input: PermissionCreateInput) => Effect.Effect<void, unknown>
readonly hook: Hooks<PermissionHooks>
}

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