Compare commits

..

7 Commits

Author SHA1 Message Date
neriousy d372c72ccd refactor(core): scope skill bypass to tool context 2026-08-21 13:44:17 +00:00
neriousy 9a3768522f fix(core): allow explicitly referenced skills 2026-08-21 13:30:17 +00:00
Shoubhit Dash e4178886fa fix(core): align websocket network policy (#43875) 2026-08-21 18:49:57 +05:30
Filip 1e6bfaf3d7 feat: /skills command (#43869) 2026-08-21 14:31:17 +02:00
opencode-agent[bot] 876a4a2586 fix(stats): merge renamed model data (#43812)
Co-authored-by: fwang <83515+fwang@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-08-21 08:48:12 +00:00
Brendan Allan 5e77c494c7 fix(app): show folder names in file tree (#43835) 2026-08-21 16:40:07 +08:00
Brendan Allan 9be9dd737c fix(app): restore project menu spacing (#43810) 2026-08-21 15:59:32 +08:00
22 changed files with 315 additions and 87 deletions
+2
View File
@@ -380,6 +380,8 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
@@ -60,39 +60,6 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
test("keeps previous file search results visible while the next search loads", async ({ page }) => {
const searchPending = Promise.withResolvers<void>()
await setup(page, async ({ query }) => {
if (query === "file-0") return ["file-00.ts"]
if (query === "file-7") {
await searchPending.promise
return ["file-79.ts"]
}
return []
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill("file-0")
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
const nextSearch = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/find" && url.searchParams.get("query") === "file-7"
})
await filter.fill("file-7")
await nextSearch
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
searchPending.resolve()
await expect(panel.getByRole("option", { name: "file-79.ts" })).toBeVisible()
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeHidden()
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function writeProbe(page: Page) {
@@ -107,10 +74,7 @@ async function readProbe(page: Page) {
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(
page: Page,
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>,
) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -155,7 +119,6 @@ async function setup(
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles,
pageMessages: () => ({ items: [] }),
})
@@ -60,7 +60,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
if (path) return []
return [
{
name: "frontend",
name: "",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
@@ -116,6 +116,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
+1 -1
View File
@@ -29,7 +29,7 @@ export interface MockServerConfig {
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
}
@@ -277,7 +277,6 @@ export function PromptProjectSelector(props: {
return (
<Menu
appearance="standard"
open={triggerReady() && props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
@@ -292,13 +291,13 @@ export function PromptProjectSelector(props: {
<Menu.Content
ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex flex-col">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
@@ -397,7 +396,7 @@ export function PromptProjectSelector(props: {
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<div class="flex flex-col">
<Show
when={props.controller.servers().length > 1}
fallback={
@@ -422,7 +421,7 @@ export function PromptProjectSelector(props: {
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
@@ -508,17 +507,8 @@ function ProjectItem(props: {
id={key()}
value={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
@@ -549,17 +539,8 @@ function ProjectAction(props: {
<Menu.Item
id={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
@@ -100,7 +100,11 @@ const FileTreeNodeV2 = (
>
{local.children}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<bdi dir="auto">{local.node.name}</bdi>
<bdi dir="auto">
{local.node.type === "directory"
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
: local.node.name}
</bdi>
</span>
{(() => {
const value = kind()
@@ -1,5 +1,5 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { createQuery } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
@@ -52,7 +52,6 @@ export function SessionFileBrowserTab(props: {
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
placeholderData: keepPreviousData,
}
})
const files = createMemo(() => {
+2
View File
@@ -133,6 +133,8 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
@@ -1,8 +1,8 @@
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { NodeSocket } from "@effect/platform-node"
import { Socket } from "effect/unstable/socket"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { WebSocketConstructor } from "./websocket-constructor.js"
export const requestExecutor = makeGlobalNode({
service: RequestExecutor.Service,
@@ -14,7 +14,7 @@ export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLM
export const webSocketConstructor = makeGlobalNode({
service: Socket.WebSocketConstructor,
layer: NodeSocket.layerWebSocketConstructorWS,
layer: WebSocketConstructor.layer,
deps: [],
})
@@ -0,0 +1,89 @@
import { NodeSocket } from "@effect/platform-node"
import { HttpProxyAgent } from "http-proxy-agent"
import { HttpsProxyAgent } from "https-proxy-agent"
import { Layer } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
interface WebSocketOptions {
readonly headers?: Headers.Headers
readonly protocols?: string | Array<string>
}
type BunWebSocketConstructor = new (
url: string,
options: WebSocketOptions & { readonly proxy?: string },
) => globalThis.WebSocket
type Environment = Readonly<Record<string, string | undefined>>
const environmentValue = (environment: Environment, name: string) =>
environment[name] ?? environment[name.toLowerCase()]
const bypassesProxy = (url: URL, value: string | undefined) => {
if (!value) return false
const port = url.port || (url.protocol === "wss:" ? "443" : "80")
return value.split(/[\s,]+/).some((entry) => {
if (!entry) return false
if (entry === "*") return true
const match = entry.match(/^(.+?):(\d+)$/)
if (match?.[2] && match[2] !== port) return false
const host = (match?.[1] ?? entry).toLowerCase().replace(/^\*/, "")
const hostname = url.hostname.toLowerCase()
return host.startsWith(".") ? hostname.endsWith(host) : hostname === host
})
}
const proxy = (value: string, environment: Environment = process.env) => {
const url = new URL(value)
if (["127.0.0.1", "localhost", "::1"].includes(url.hostname)) return undefined
if (bypassesProxy(url, environmentValue(environment, "NO_PROXY"))) return undefined
const protocolProxy = url.protocol === "wss:" ? "WSS_PROXY" : "WS_PROXY"
const standardProxy = url.protocol === "wss:" ? "HTTPS_PROXY" : "HTTP_PROXY"
return (
environmentValue(environment, protocolProxy) ??
environmentValue(environment, standardProxy) ??
environmentValue(environment, "ALL_PROXY")
)
}
const constructorOptions = (input: string | Array<string> | undefined): WebSocketOptions => {
if (typeof input === "string" || Array.isArray(input)) return { protocols: input }
// AI routes pass handshake options through Effect's browser-shaped constructor.
return (input ?? {}) as WebSocketOptions
}
const proxyAgent = (url: string, selectedProxy: string | undefined) => {
if (!selectedProxy) return undefined
if (url.startsWith("wss:") || selectedProxy.startsWith("https:")) return new HttpsProxyAgent(selectedProxy)
return new HttpProxyAgent(selectedProxy)
}
const layer = Layer.succeed(Socket.WebSocketConstructor, (url, input) => {
const config = constructorOptions(input)
const selectedProxy = proxy(url)
// Keep trust on the runtime store so NODE_EXTRA_CA_CERTS remains additive.
if (typeof Bun !== "undefined") {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Bun extends the browser constructor with handshake and network options.
const WebSocket = globalThis.WebSocket as unknown as BunWebSocketConstructor
return new WebSocket(url, {
headers: config.headers,
protocols: config.protocols,
...(selectedProxy ? { proxy: selectedProxy } : {}),
})
}
const native = {
headers: config.headers,
agent: proxyAgent(url, selectedProxy),
// Reject redirects before headers can cross an origin boundary; the caller safely falls back to HTTP.
followRedirects: false,
}
const socket = config.protocols
? new NodeSocket.NodeWS.WebSocket(url, config.protocols, native)
: new NodeSocket.NodeWS.WebSocket(url, native)
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ws implements the WebSocket surface consumed by the AI transport.
return socket as unknown as globalThis.WebSocket
})
export const WebSocketConstructor = { layer, proxy } as const
+4
View File
@@ -369,6 +369,9 @@ const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
const userRequests = loaded.messages
.findLast((message) => message.type === "user")
?.skills?.map((skill) => ({ action: "skill", resource: skill.id }))
yield* diagnosePromptCache(session.id, prepared.request)
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
@@ -457,6 +460,7 @@ const layer = Layer.effect(
agent: agent.id,
messageID: assistantMessageID,
call: event,
userRequests,
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
+3
View File
@@ -37,6 +37,7 @@ export interface Snapshot {
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly userRequests?: Tool.Context["userRequests"]
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}
@@ -234,6 +235,7 @@ const layer = Layer.effect(
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly userRequests?: Tool.Context["userRequests"]
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
@@ -241,6 +243,7 @@ const layer = Layer.effect(
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
...(input.userRequests?.length ? { userRequests: input.userRequests } : {}),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
+9 -8
View File
@@ -51,14 +51,15 @@ export const Plugin = {
const skill = current.find((skill) => skill.id === input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
if (!context.userRequests?.some((request) => request.action === name && request.resource === skill.id))
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { WebSocketTransport } from "@opencode-ai/ai/route"
import { WebSocketConstructor } from "@opencode-ai/core/effect/websocket-constructor"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
const makeServer = (fetch: (request: Request, server: Bun.Server<undefined>) => Response | undefined) =>
Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch,
websocket: { message() {} },
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
describe("WebSocket network policy", () => {
test("uses protocol-specific and standard proxy variables", () => {
expect(
WebSocketConstructor.proxy("wss://provider.test/responses", {
WSS_PROXY: "http://wss-proxy.test",
HTTPS_PROXY: "http://https-proxy.test",
}),
).toBe("http://wss-proxy.test")
expect(
WebSocketConstructor.proxy("wss://provider.test/responses", { HTTPS_PROXY: "http://https-proxy.test" }),
).toBe("http://https-proxy.test")
expect(WebSocketConstructor.proxy("ws://provider.test/responses", { HTTP_PROXY: "http://http-proxy.test" })).toBe(
"http://http-proxy.test",
)
expect(WebSocketConstructor.proxy("ws://provider.test/responses", { ALL_PROXY: "http://all-proxy.test" })).toBe(
"http://all-proxy.test",
)
})
test("respects no-proxy hosts, domains, ports, and wildcards", () => {
const environment = { HTTPS_PROXY: "http://proxy.test" }
expect(
WebSocketConstructor.proxy("wss://provider.test/responses", { ...environment, NO_PROXY: "provider.test" }),
).toBeUndefined()
expect(
WebSocketConstructor.proxy("wss://api.provider.test/responses", { ...environment, NO_PROXY: ".provider.test" }),
).toBeUndefined()
expect(
WebSocketConstructor.proxy("wss://provider.test:8443/responses", {
...environment,
NO_PROXY: "provider.test:443",
}),
).toBe("http://proxy.test")
expect(
WebSocketConstructor.proxy("wss://provider.test/responses", { ...environment, NO_PROXY: "*" }),
).toBeUndefined()
})
test("rejects redirects without forwarding authorization", async () => {
let destinationRequests = 0
await Effect.runPromise(
Effect.gen(function* () {
const destination = yield* makeServer((request, server) => {
destinationRequests++
if (server.upgrade(request)) return undefined
return new Response("upgrade failed", { status: 426 })
})
const redirect = yield* makeServer(
() =>
new Response(null, {
status: 302,
headers: { location: destination.url.toString().replace(/^http/, "ws") },
}),
)
const constructor = yield* Socket.WebSocketConstructor
yield* WebSocketTransport.open({
url: redirect.url.toString().replace(/^http/, "ws"),
headers: Headers.fromInput({ authorization: "Bearer secret" }),
}).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor), Effect.flip)
expect(destinationRequests).toBe(0)
}).pipe(Effect.scoped, Effect.provide(WebSocketConstructor.layer)),
)
})
})
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { NodeSocket } from "@effect/platform-node"
import { AIError, LLM, Message } from "@opencode-ai/ai"
import {
LLMClient,
@@ -10,6 +9,7 @@ import {
} from "@opencode-ai/ai/route"
import { configure } from "@opencode-ai/ai/providers/openai"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { WebSocketConstructor } from "@opencode-ai/core/effect/websocket-constructor"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Headers } from "effect/unstable/http"
@@ -48,7 +48,7 @@ const withServer = <A>(
}),
),
)
}).pipe(Effect.scoped, Effect.provide(NodeSocket.layerWebSocketConstructorWS)),
}).pipe(Effect.scoped, Effect.provide(WebSocketConstructor.layer)),
)
const collect = (transport: SessionModelTransport.Interface, item: WebSocketChannelExchange) =>
@@ -78,14 +78,15 @@ const collectComplete = (
const automatic = () => {
const connections: Array<{
readonly messages: Queue.Queue<string | Uint8Array, AIError>
readonly headers: Headers.Headers
closed: number
sent: string[]
}> = []
const connector: WebSocketConnector = {
open: () =>
open: (input) =>
Effect.gen(function* () {
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
const record = { messages, closed: 0, sent: [] as string[] }
const record = { messages, headers: input.headers, closed: 0, sent: [] as string[] }
connections.push(record)
const connection: WebSocketConnection = {
sendText: (message) =>
@@ -583,7 +584,7 @@ describe("SessionModelTransport", () => {
)
})
test("rotates when handshake affinity or connection age changes", async () => {
test("rotates when refreshed authorization changes handshake affinity", async () => {
const fixture = automatic()
await run(
@@ -594,10 +595,26 @@ describe("SessionModelTransport", () => {
yield* collect(executor, exchange("first", { headers: { authorization: "one" } }))
yield* collect(executor, exchange("second", { headers: { authorization: "one" } }))
yield* collect(executor, exchange("third", { headers: { authorization: "two" } }))
expect(fixture.connections).toHaveLength(2)
expect(fixture.connections[0]?.closed).toBe(1)
expect(fixture.connections.map((item) => item.headers.authorization)).toEqual(["one", "two"])
}),
)
})
test("rotates when the connection exceeds its requested age limit", async () => {
const fixture = automatic()
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
yield* collect(executor, exchange("first"))
yield* Effect.sleep("5 millis")
yield* collect(executor, exchange("fourth", { headers: { authorization: "two" }, rotateAfterMs: 1 }))
expect(fixture.connections).toHaveLength(3)
expect(fixture.connections.slice(0, 2).map((item) => item.closed)).toEqual([1, 1])
yield* collect(executor, exchange("second", { rotateAfterMs: 1 }))
expect(fixture.connections).toHaveLength(2)
expect(fixture.connections[0]?.closed).toBe(1)
}),
)
})
+13
View File
@@ -131,12 +131,25 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
userRequests: [{ action: "skill", resource: "other" }],
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
userRequests: [{ action: "skill", resource: "effect" }],
call: { type: "tool-call", id: "call-user-skill", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: Skill.toModelOutput(info, [reference]) }],
})
expect(assertions).toHaveLength(3)
deny = false
const flat = Skill.Info.make({
id: Skill.ID.make("public"),
+1
View File
@@ -16,6 +16,7 @@ export interface Context {
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: CallID
readonly userRequests?: ReadonlyArray<{ readonly action: string; readonly resource: string }>
readonly progress: (update: Metadata) => Effect.Effect<void>
}
@@ -38,6 +38,17 @@ describe("inference stat normalization", () => {
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
})
test("merges renamed models under their current name", () => {
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
{
provider: "openai",
model: "ox-alpha",
},
])
})
test("model aggregates prefer provider.model and use normalized model", () => {
expect(toModelAggregate(aggregate("alpha-gpt-next", "openai"))).toEqual([])
@@ -5,6 +5,7 @@ import type { ModelStatAggregate } from "./model"
import {
EXCLUDED_MODELS,
MODEL_AUTHOR_RULES,
MODEL_NAME_ALIASES,
RETIRED_STAT_PROVIDERS,
statModel,
statProvider,
@@ -253,6 +254,9 @@ function sqlString(value: string) {
function statModelSql(model: string, providerModel: string) {
return `COALESCE(NULLIF(regexp_replace(CASE
WHEN lower(${model}) = 'big-pickle' THEN NULLIF(${providerModel}, '')
${Object.entries(MODEL_NAME_ALIASES)
.map(([from, to]) => ` WHEN lower(${model}) = ${sqlString(from)} THEN ${sqlString(to)}`)
.join("\n")}
ELSE ${model}
END, '(-free|:global)+$', ''), ''), 'unknown')`
}
@@ -13,7 +13,11 @@ export const MODEL_AUTHOR_RULES = [
{ match: "qwen", author: "qwen" },
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const RETIRED_STAT_MODELS = ["big-pickle"]
export const MODEL_NAME_ALIASES: Record<string, string> = {
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
export const RETIRED_STAT_MODELS = ["big-pickle", ...Object.keys(MODEL_NAME_ALIASES)]
export const RETIRED_STAT_PROVIDERS = ["opencode"]
export function normalizeInferenceModel(value: string | undefined) {
@@ -29,6 +33,8 @@ export function modelAuthor(value: string | undefined) {
export function statModel(model: string | undefined, providerModel: string | undefined) {
const normalized = normalizeInferenceModel(model)
const alias = MODEL_NAME_ALIASES[normalized.toLowerCase()]
if (alias) return alias
if (RETIRED_STAT_MODELS.includes(normalized.toLowerCase())) return normalizeInferenceModel(providerModel)
return normalized
}
@@ -30,6 +30,7 @@ import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
@@ -44,6 +45,7 @@ import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
@@ -580,6 +582,44 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = stringWidth(normalized)
},
},
{
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slash: { name: "skills" },
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation.ref}
onSelect={(skill) => {
if (store.prompt.skills?.some((item) => item.id === skill)) return
const text = `@${skill}`
const start = input.cursorOffset
input.insertText(text + " ")
const extmarkId = input.extmarks.create({
start,
end: start + promptOffsetWidth(text),
virtual: true,
styleId: skillStyleId,
typeId: promptPartTypeId,
})
setStore(
produce((draft) => {
draft.prompt.text = input.plainText
const skills = (draft.prompt.skills ??= [])
const index = skills.length
skills.push({
id: Skill.ID.make(skill),
mention: { start, end: start + promptOffsetWidth(text), text },
})
draft.extmarkToPart.set(extmarkId, { type: "skill", index })
}),
)
}}
/>
))
},
},
{
title: "Move session",
desc: "Move to another project dir",
@@ -621,6 +661,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"session.move",