Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1030db291 | |||
| a4fed69a82 | |||
| 41a3cfcdd9 | |||
| 72845e09fc | |||
| abaab29cb3 | |||
| 30936a9bca | |||
| a9144eccf8 |
+3
-1
@@ -17,4 +17,6 @@ if (process.versions.bun !== expectedBunVersion) {
|
||||
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
|
||||
}
|
||||
'
|
||||
bun typecheck
|
||||
# Only typecheck packages affected since the merge base with origin/dev,
|
||||
# plus their dependents. Override the base with TURBO_SCM_BASE if needed.
|
||||
TURBO_SCM_BASE="${TURBO_SCM_BASE:-origin/dev}" bun turbo typecheck --affected
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-/Tyscbq9D9pDm18icK0CBsuZPduQmMKuauPnYbae+X8=",
|
||||
"aarch64-linux": "sha256-foak7M2pny+8U5rkYpIl8tjYk5vgYkVtcZsnJN3bkV4=",
|
||||
"aarch64-darwin": "sha256-JxiN/6nF+jvLIfX/B8HEZZmp0WujTEjY8pvMva1pQy4=",
|
||||
"x86_64-darwin": "sha256-6Lt7RgGSqt0z3Lc9XbfHX/pPPlLjMnrGJUD8eqyexnk="
|
||||
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
|
||||
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
|
||||
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
|
||||
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
describe("matchesModelSearch", () => {
|
||||
test("matches model names across separators", () => {
|
||||
expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
|
||||
})
|
||||
|
||||
test("matches any searchable model field", () => {
|
||||
expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match unrelated searches", () => {
|
||||
expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export const normalizeModelSearch = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
|
||||
|
||||
export const matchesModelSearch = (query: string, values: string[]) => {
|
||||
const search = normalizeModelSearch(query)
|
||||
if (!search) return true
|
||||
|
||||
const compactSearch = compactModelSearch(query)
|
||||
return values.some(
|
||||
(value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import {
|
||||
Component,
|
||||
ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
Show,
|
||||
ValidComponent,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -17,6 +27,9 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -242,14 +255,9 @@ export function ModelSelectorPopoverV2(props: {
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
const search = store.search.trim()
|
||||
const filtered = search
|
||||
? allModels().filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -333,19 +341,23 @@ export function ModelSelectorPopoverV2(props: {
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const search = value.trim()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
createEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -8,6 +8,7 @@ import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
@@ -101,6 +102,16 @@ export function createPromptProjectController(input: {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
@@ -127,16 +138,7 @@ export function createPromptProjectController(input: {
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
},
|
||||
setSearch(value: string) {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
},
|
||||
setSearch,
|
||||
clearSearch() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
setTimeout(() => searchRef?.focus())
|
||||
@@ -170,6 +172,9 @@ export function createPromptProjectController(input: {
|
||||
focusSearch() {
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
},
|
||||
handleSearchKeydown(event: KeyboardEvent) {
|
||||
return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +248,13 @@ export function PromptProjectSelector(props: {
|
||||
return project ? props.controller.projectKey(project) : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.controller.open()) return
|
||||
const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
|
||||
document.addEventListener("keydown", handler, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", handler, true))
|
||||
})
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const editableSelector = "input, textarea, select, [contenteditable=''], [contenteditable='true']"
|
||||
|
||||
export function handleDocumentSearchKeydown(
|
||||
input: HTMLInputElement | undefined,
|
||||
event: KeyboardEvent,
|
||||
inputValue: string,
|
||||
setInputValue: (value: string) => void,
|
||||
) {
|
||||
if (!input) return false
|
||||
if (event.defaultPrevented || event.isComposing) return false
|
||||
if (event.target === input) return false
|
||||
if (event.target instanceof Element && event.target.closest(editableSelector)) return false
|
||||
|
||||
const action = searchKeyAction(event)
|
||||
if (!action) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
input.focus()
|
||||
|
||||
const start = input.selectionStart ?? inputValue.length
|
||||
const end = input.selectionEnd ?? inputValue.length
|
||||
|
||||
if (action.type === "selectAll") {
|
||||
input.setSelectionRange(0, inputValue.length)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "move") {
|
||||
moveSelection(input, inputValue, action.delta, event.shiftKey)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "home") {
|
||||
setBoundarySelection(input, start, 0, event.shiftKey)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "end") {
|
||||
setBoundarySelection(input, start, inputValue.length, event.shiftKey)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "deleteBackward") {
|
||||
if (start !== end)
|
||||
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
|
||||
if (start === 0) return true
|
||||
return updateValue(input, inputValue.slice(0, start - 1) + inputValue.slice(end), start - 1, setInputValue)
|
||||
}
|
||||
|
||||
if (action.type === "deleteForward") {
|
||||
if (start !== end)
|
||||
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
|
||||
if (end === inputValue.length) return true
|
||||
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end + 1), start, setInputValue)
|
||||
}
|
||||
|
||||
return updateValue(
|
||||
input,
|
||||
inputValue.slice(0, start) + action.value + inputValue.slice(end),
|
||||
start + action.value.length,
|
||||
setInputValue,
|
||||
)
|
||||
}
|
||||
|
||||
function searchKeyAction(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
|
||||
return { type: "selectAll" } as const
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return undefined
|
||||
if (event.key.length === 1) return { type: "insert", value: event.key } as const
|
||||
if (event.key === "Backspace") return { type: "deleteBackward" } as const
|
||||
if (event.key === "Delete") return { type: "deleteForward" } as const
|
||||
if (event.key === "ArrowLeft") return { type: "move", delta: -1 } as const
|
||||
if (event.key === "ArrowRight") return { type: "move", delta: 1 } as const
|
||||
if (event.key === "Home") return { type: "home" } as const
|
||||
if (event.key === "End") return { type: "end" } as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
function moveSelection(input: HTMLInputElement, inputValue: string, delta: -1 | 1, extend: boolean) {
|
||||
const start = input.selectionStart ?? inputValue.length
|
||||
const end = input.selectionEnd ?? inputValue.length
|
||||
if (!extend && start !== end) {
|
||||
const caret = delta < 0 ? start : end
|
||||
input.setSelectionRange(caret, caret)
|
||||
return
|
||||
}
|
||||
|
||||
if (!extend) {
|
||||
const caret = Math.max(0, Math.min(inputValue.length, start + delta))
|
||||
input.setSelectionRange(caret, caret)
|
||||
return
|
||||
}
|
||||
|
||||
const backward = input.selectionDirection === "backward"
|
||||
const anchor = backward ? end : start
|
||||
const focus = backward ? start : end
|
||||
const next = Math.max(0, Math.min(inputValue.length, focus + delta))
|
||||
input.setSelectionRange(Math.min(anchor, next), Math.max(anchor, next), next < anchor ? "backward" : "forward")
|
||||
}
|
||||
|
||||
function setBoundarySelection(input: HTMLInputElement, anchor: number, focus: number, extend: boolean) {
|
||||
if (!extend) {
|
||||
input.setSelectionRange(focus, focus)
|
||||
return
|
||||
}
|
||||
input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward")
|
||||
}
|
||||
|
||||
function updateValue(input: HTMLInputElement, value: string, caret: number, setInputValue: (value: string) => void) {
|
||||
input.value = value
|
||||
setInputValue(value)
|
||||
input.setSelectionRange(caret, caret)
|
||||
return true
|
||||
}
|
||||
@@ -287,41 +287,35 @@ export const CodeModeTool = Tool.define(
|
||||
|
||||
const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled)))
|
||||
const logs = result.logs ?? []
|
||||
const withLogs = (text: string) => {
|
||||
if (logs.length === 0) return text
|
||||
return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
if (ctx.abort.aborted) {
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls, error: true },
|
||||
output: "Execution cancelled.",
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}
|
||||
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
|
||||
return yield* Effect.fail(new Error(withLogs([result.error.message, ...hints].join("\n"))))
|
||||
}
|
||||
|
||||
const attached = attachments.length > 0 ? { attachments } : {}
|
||||
const hints = result.ok
|
||||
? []
|
||||
: (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
|
||||
const metadata: Metadata = result.ok ? { toolCalls: calls } : { toolCalls: calls, error: true }
|
||||
let output: string
|
||||
if (typeof result.value === "string") output = result.value
|
||||
else if (result.value === undefined) output = "undefined"
|
||||
else {
|
||||
try {
|
||||
output = JSON.stringify(result.value, null, 2) ?? String(result.value)
|
||||
} catch {
|
||||
output = String(result.value)
|
||||
if (result.ok) {
|
||||
if (typeof result.value === "string") output = result.value
|
||||
else if (result.value === undefined) output = "undefined"
|
||||
else {
|
||||
try {
|
||||
output = JSON.stringify(result.value, null, 2) ?? String(result.value)
|
||||
} catch {
|
||||
output = String(result.value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output = [result.error.message, ...hints].join("\n")
|
||||
}
|
||||
if (logs.length > 0)
|
||||
output = output.length > 0 ? `${output}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
|
||||
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls },
|
||||
output: withLogs(output),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
metadata,
|
||||
output,
|
||||
...attached,
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}, Effect.orDie),
|
||||
}),
|
||||
}
|
||||
return init
|
||||
}),
|
||||
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
type Tool as MCPToolDef,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
const PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
|
||||
const SERVER = "fixtures"
|
||||
|
||||
@@ -141,8 +140,8 @@ async function buildTool() {
|
||||
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.mock(Plugin.Service, {
|
||||
trigger: (((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"]),
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
}),
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
@@ -161,12 +160,6 @@ async function buildTool() {
|
||||
}
|
||||
|
||||
const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
|
||||
// Program failures die at the tool boundary; recover the defect for message assertions.
|
||||
const runFailed = async (code: string) => {
|
||||
const exit = await Effect.runPromise(tool.execute({ code }, ctx).pipe(Effect.exit))
|
||||
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
|
||||
return Cause.squash(exit.cause) as Error
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const built = await buildTool()
|
||||
@@ -178,9 +171,7 @@ describe("code mode integration (real MCP server)", () => {
|
||||
test("the appended catalog inlines full signatures with real MCP schemas", () => {
|
||||
expect(description).toContain("Available tools (COMPLETE list")
|
||||
expect(description).toContain("- fixtures (4 tools)")
|
||||
expect(description).toContain(
|
||||
"tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>",
|
||||
)
|
||||
expect(description).toContain("tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>")
|
||||
expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise<unknown>")
|
||||
expect(description).toContain("// Add two numbers and return the structured sum")
|
||||
expect(description).not.toContain("$codemode")
|
||||
@@ -257,8 +248,9 @@ describe("code mode integration (real MCP server)", () => {
|
||||
})
|
||||
|
||||
test("an uncaught MCP error surfaces as a failed execution", async () => {
|
||||
const error = await runFailed("await tools.fixtures.boom({}); return 'unreachable'")
|
||||
expect(error.message).toContain("kaboom")
|
||||
const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
|
||||
expect(out.metadata.error).toBe(true)
|
||||
expect(out.output).toContain("kaboom")
|
||||
})
|
||||
|
||||
test("console output is captured and appended as a Logs section after the result", async () => {
|
||||
@@ -273,13 +265,14 @@ describe("code mode integration (real MCP server)", () => {
|
||||
})
|
||||
|
||||
test("console output is preserved on the error path", async () => {
|
||||
const error = await runFailed(`
|
||||
const out = await run(`
|
||||
console.log("before the throw")
|
||||
await tools.fixtures.boom({})
|
||||
return "unreachable"
|
||||
`)
|
||||
expect(error.message).toContain("kaboom")
|
||||
expect(error.message).toContain("Logs:\nbefore the throw")
|
||||
expect(out.metadata.error).toBe(true)
|
||||
expect(out.output).toContain("kaboom")
|
||||
expect(out.output).toContain("Logs:\nbefore the throw")
|
||||
})
|
||||
|
||||
test("a program that logs nothing gets no Logs section", async () => {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
CODE_MODE_TOOL,
|
||||
CodeModeTool,
|
||||
Parameters,
|
||||
describeCatalog,
|
||||
} from "@/tool/code-mode"
|
||||
import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { Agent } from "@/agent/agent"
|
||||
@@ -15,7 +10,7 @@ import { Session } from "@/session/session"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Cause, Effect, Exit, Layer, Schema } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.make("ses_code-mode"),
|
||||
@@ -91,13 +86,6 @@ function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[],
|
||||
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
|
||||
}
|
||||
|
||||
// Program failures die at the tool boundary; recover the defect for message assertions.
|
||||
async function failure(effect: Effect.Effect<unknown>) {
|
||||
const exit = await Effect.runPromise(effect.pipe(Effect.exit))
|
||||
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
|
||||
return Cause.squash(exit.cause) as Error
|
||||
}
|
||||
|
||||
describe("code mode execute", () => {
|
||||
test("defines execute input with an Effect schema", async () => {
|
||||
const decode = Schema.decodeUnknownEffect(Parameters)
|
||||
@@ -164,7 +152,9 @@ describe("code mode execute", () => {
|
||||
expect(description).not.toContain("Browse one namespace")
|
||||
expect(description).toContain("## Workflow")
|
||||
expect(description).toContain("1. Pick a tool from the list under `## Available tools`")
|
||||
expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
|
||||
expect(description).toContain(
|
||||
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
|
||||
)
|
||||
expect(description).toContain("Return only the fields you need")
|
||||
expect(description).not.toContain("total_count")
|
||||
})
|
||||
@@ -213,7 +203,9 @@ describe("code mode execute", () => {
|
||||
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
|
||||
expect(description).toContain("tools.$codemode.search(")
|
||||
expect(description).toContain("1. Find a tool (skip when it is already listed below)")
|
||||
expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
|
||||
expect(description).toContain(
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
)
|
||||
expect(description).not.toContain("total_count")
|
||||
expect(description).toContain("tools.alpha.op_0(")
|
||||
expect(description).not.toContain("tools.alpha.op_99(")
|
||||
@@ -247,7 +239,10 @@ describe("code mode execute", () => {
|
||||
linear_search: mcpTool("search", () => ""),
|
||||
})
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx),
|
||||
tool.execute(
|
||||
{ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" },
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 })
|
||||
})
|
||||
@@ -318,16 +313,18 @@ describe("code mode execute", () => {
|
||||
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
|
||||
})
|
||||
|
||||
test("a program failure fails the tool with a readable error", async () => {
|
||||
test("returns a readable error when the program throws", async () => {
|
||||
const tool = await build({})
|
||||
const error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx))
|
||||
expect(error.message).toBe("Uncaught: boom")
|
||||
const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
|
||||
expect(output.output).toBe("Uncaught: boom")
|
||||
expect(output.metadata.error).toBe(true)
|
||||
})
|
||||
|
||||
test("reports an unknown tool as a failed execution", async () => {
|
||||
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
|
||||
const error = await failure(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
|
||||
expect(error.message).toContain("Unknown tool 'known.missing'")
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
expect(output.output).toContain("Unknown tool 'known.missing'")
|
||||
})
|
||||
|
||||
test("propagates an MCP tool error into the program as a catchable failure", async () => {
|
||||
@@ -552,7 +549,12 @@ describe("code mode execute", () => {
|
||||
const tool = await build({
|
||||
docs_find: mcpTool("find", () => ({
|
||||
content: [
|
||||
{ type: "resource_link", uri: "https://example.com/guide.pdf", name: "guide.pdf", mimeType: "application/pdf" },
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "https://example.com/guide.pdf",
|
||||
name: "guide.pdf",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
{ type: "resource_link", uri: "file:///tmp/notes.md", name: "notes.md" },
|
||||
],
|
||||
})),
|
||||
@@ -567,17 +569,15 @@ describe("code mode execute", () => {
|
||||
const tool = await build({
|
||||
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
|
||||
})
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx),
|
||||
)
|
||||
const out = await Effect.runPromise(tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx))
|
||||
expect(out.output).toBe("captured")
|
||||
expect(out.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("isolates the sandbox from host globals", async () => {
|
||||
const tool = await build({})
|
||||
const error = await failure(tool.execute({ code: "return process.env" }, ctx))
|
||||
expect(error.message).toContain("process")
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
})
|
||||
|
||||
test("cancelling via ctx.abort interrupts the running program", async () => {
|
||||
@@ -627,9 +627,12 @@ describe("code mode execute", () => {
|
||||
)
|
||||
expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")
|
||||
|
||||
const error = await failure(tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx))
|
||||
expect(error.message).toContain("Uncaught: boom")
|
||||
expect(error.message).toContain("Logs:\nbefore the throw")
|
||||
const err = await Effect.runPromise(
|
||||
tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx),
|
||||
)
|
||||
expect(err.metadata.error).toBe(true)
|
||||
expect(err.output).toContain("Uncaught: boom")
|
||||
expect(err.output).toContain("Logs:\nbefore the throw")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -674,14 +677,15 @@ describe("code mode permission visibility", () => {
|
||||
[deny("github_create_issue")],
|
||||
)
|
||||
|
||||
const denied = await failure(tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx))
|
||||
expect(denied.message).toContain("Unknown tool 'github.create_issue'")
|
||||
expect(denied.message).not.toContain("permission")
|
||||
const denied = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx),
|
||||
)
|
||||
expect(denied.metadata.error).toBe(true)
|
||||
expect(denied.output).toContain("Unknown tool 'github.create_issue'")
|
||||
expect(denied.output).not.toContain("permission")
|
||||
expect(called).toEqual([])
|
||||
|
||||
const allowed = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.github.list_issues({})" }, ctx),
|
||||
)
|
||||
const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx))
|
||||
expect(allowed.metadata.error).toBeUndefined()
|
||||
expect(allowed.output).toBe("ok")
|
||||
})
|
||||
@@ -694,9 +698,7 @@ describe("code mode permission visibility", () => {
|
||||
["github"],
|
||||
[askRule("github_list_issues")],
|
||||
)
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx),
|
||||
)
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx))
|
||||
expect(out.output).toBe("ok")
|
||||
expect(asked).toEqual(["github_list_issues"])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user