mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d372c72ccd | |||
| 9a3768522f | |||
| e4178886fa | |||
| 1e6bfaf3d7 | |||
| 876a4a2586 | |||
| 5e77c494c7 | |||
| 9be9dd737c |
@@ -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")
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user