Compare commits

...

4 Commits
v1.2.9 ... dev

Author SHA1 Message Date
Brendan Allan
443214871e sdk: build to dist/ instead of dist/src (#14383) 2026-02-20 14:10:38 +08:00
Brendan Allan
1c2416b6de desktop: don't spawn sidecar if default is localhost server 2026-02-20 12:18:39 +08:00
Rafi Khardalian
d86c10816d docs: clarify tool name collision precedence (#14313)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 22:00:06 -06:00
Aiden Cline
04a634a80d test: merge test files into a single file (#14366) 2026-02-19 20:32:01 -06:00
13 changed files with 285 additions and 252 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ target
opencode-dev
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -40,7 +40,9 @@ use crate::windows::{LoadingWindow, MainWindow};
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
struct ServerReadyData {
url: String,
username: Option<String>,
password: Option<String>,
is_sidecar: bool
}
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -605,6 +607,7 @@ async fn initialize(app: AppHandle) {
child,
health_check,
url,
username,
password,
} => {
let app = app.clone();
@@ -631,7 +634,7 @@ async fn initialize(app: AppHandle) {
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData { url, password })
Ok(ServerReadyData { url, username,password, is_sidecar: true })
}
.map(move |res| {
let _ = server_ready_tx.send(res);
@@ -641,7 +644,9 @@ async fn initialize(app: AppHandle) {
ServerConnection::Existing { url } => {
let _ = server_ready_tx.send(Ok(ServerReadyData {
url: url.to_string(),
username: None,
password: None,
is_sidecar: false,
}));
None
}
@@ -719,6 +724,7 @@ enum ServerConnection {
},
CLI {
url: String,
username: Option<String>,
password: Option<String>,
child: CommandChild,
health_check: server::HealthCheck,
@@ -730,11 +736,15 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = custom_url
&& server::check_health_or_ask_retry(&app, &url).await
if let Some(url) = &custom_url
&& server::check_health_or_ask_retry(&app, url).await
{
tracing::info!(%url, "Connected to custom server");
return ServerConnection::Existing { url: url.clone() };
// If the default server is already local, no need to also spawn a sidecar
if server::is_localhost_url(url) {
return ServerConnection::Existing { url: url.clone() };
}
// Remote default server: fall through and also spawn a local sidecar
}
let local_port = get_sidecar_port();
@@ -755,6 +765,7 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
ServerConnection::CLI {
url: local_url,
username: Some("opencode".to_string()),
password: Some(password),
child,
health_check,

View File

@@ -150,7 +150,7 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
return false;
};
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
if url_is_localhost(&url) {
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
@@ -178,6 +178,10 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
.unwrap_or(false)
}
pub fn is_localhost_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
}
fn url_is_localhost(url: &reqwest::Url) -> bool {
url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")

View File

@@ -35,7 +35,9 @@ export type LoadingWindowComplete = null;
export type ServerReadyData = {
url: string,
username: string | null,
password: string | null,
is_sidecar: boolean,
};
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

View File

@@ -23,7 +23,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
@@ -31,7 +31,7 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { commands, ServerReadyData, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -452,16 +452,19 @@ render(() => {
<AppBaseProviders>
<ServerGate>
{(data) => {
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Any = data.is_sidecar
? {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http,
}
: { type: "http", http }
function Inner() {
const cmd = useCommand()
@@ -485,10 +488,8 @@ render(() => {
)
}, root!)
type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
return (
@@ -516,7 +517,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
</div>
}
>
{(data) => props.children(data)}
{(data) => props.children(data())}
</Show>
</Show>
)

View File

@@ -1,104 +0,0 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
test("keeps stored part order stable when file resolution is async", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
})
})
})

View File

@@ -1,56 +0,0 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
// Verify the URL is properly encoded (# should be %23)
expect(fileParts[0].url).toContain("%23")
// Verify the URL can be correctly converted back to a file path
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
// Verify the file content was read correctly
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})

View File

@@ -1,68 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
})
})

View File

@@ -0,0 +1,211 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
test("keeps stored part order stable when file resolution is async", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
})
})
})
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
})
})

View File

@@ -13,15 +13,15 @@
"./client": "./src/client.ts",
"./server": "./src/server.ts",
"./v2": {
"types": "./dist/src/v2/index.d.ts",
"types": "./dist/v2/index.d.ts",
"default": "./src/v2/index.ts"
},
"./v2/client": {
"types": "./dist/src/v2/client.d.ts",
"types": "./dist/v2/client.d.ts",
"default": "./src/v2/client.ts"
},
"./v2/gen/client": {
"types": "./dist/src/v2/gen/client/index.d.ts",
"types": "./dist/v2/gen/client/index.d.ts",
"default": "./src/v2/gen/client/index.ts"
},
"./v2/server": "./src/v2/server.ts"

View File

@@ -7,7 +7,8 @@
"declaration": true,
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"composite": true
"composite": true,
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -79,6 +79,32 @@ This creates two tools: `math_add` and `math_multiply`.
---
#### Name collisions with built-in tools
Custom tools are keyed by tool name. If a custom tool uses the same name as a built-in tool, the custom tool takes precedence.
For example, this file replaces the built-in `bash` tool:
```ts title=".opencode/tools/bash.ts"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Restricted bash wrapper",
args: {
command: tool.schema.string(),
},
async execute(args) {
return `blocked: ${args.command}`
},
})
```
:::note
Prefer unique names unless you intentionally want to replace a built-in tool. If you want to disable a built in tool but not override it, use [permissions](/docs/permissions).
:::
---
### Arguments
You can use `tool.schema`, which is just [Zod](https://zod.dev), to define argument types.

View File

@@ -308,6 +308,10 @@ The `tool` helper creates a custom tool that opencode can call. It takes a Zod s
Your custom tools will be available to opencode alongside built-in tools.
:::note
If a plugin tool uses the same name as a built-in tool, the plugin tool takes precedence.
:::
---
### Logging