diff --git a/.gitignore b/.gitignore index 5fca1ab7532..b678f695639 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ logs/ *.bun-build # Telemetry ID -telemetry-id \ No newline at end of file +telemetry-id +tsconfig.tsbuildinfo diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index ed093883692..db3fcc21b99 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -40,7 +40,9 @@ use crate::windows::{LoadingWindow, MainWindow}; #[derive(Clone, serde::Serialize, specta::Type, Debug)] struct ServerReadyData { url: String, + username: Option, password: Option, + 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(); @@ -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, password: Option, 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, diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index a13b450bb3a..2c43c1cc8c7 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -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") diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 67816ad414e..6d05bfc56e9 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -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" }; diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9e0ce8f0a62..9c356a8fcf5 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -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(() => { {(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) => JSX.Element }) { +function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) { const [serverData] = createResource(() => commands.awaitInitialization(new Channel() as any)) return ( @@ -516,7 +517,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. } > - {(data) => props.children(data)} + {(data) => props.children(data())} ) diff --git a/packages/opencode/test/session/prompt-missing-file.test.ts b/packages/opencode/test/session/prompt-missing-file.test.ts deleted file mode 100644 index c3f52f56cc7..00000000000 --- a/packages/opencode/test/session/prompt-missing-file.test.ts +++ /dev/null @@ -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) - }, - }) - }) -}) diff --git a/packages/opencode/test/session/prompt-special-chars.test.ts b/packages/opencode/test/session/prompt-special-chars.test.ts deleted file mode 100644 index dce0b004950..00000000000 --- a/packages/opencode/test/session/prompt-special-chars.test.ts +++ /dev/null @@ -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) - }, - }) - }) -}) diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts deleted file mode 100644 index 83ae175c6e1..00000000000 --- a/packages/opencode/test/session/prompt-variant.test.ts +++ /dev/null @@ -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 - } - }) -}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 00000000000..e8a8c65b03d --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -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 + } + }) +}) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 303b4801ffe..6871ad8966f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -18,15 +18,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" diff --git a/packages/sdk/js/tsconfig.json b/packages/sdk/js/tsconfig.json index 92ddd545778..3ab5fcb7689 100644 --- a/packages/sdk/js/tsconfig.json +++ b/packages/sdk/js/tsconfig.json @@ -7,7 +7,8 @@ "declaration": true, "moduleResolution": "nodenext", "lib": ["es2022", "dom", "dom.iterable"], - "composite": true + "composite": true, + "rootDir": "src" }, "include": ["src"] }