mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-29 20:17:40 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
346b407804 | ||
|
|
a565ea8c74 | ||
|
|
3126808e0e |
@@ -426,6 +426,7 @@ interface ActiveContent {
|
||||
readonly type: "text" | "reasoning"
|
||||
readonly id: string
|
||||
readonly thinking?: MistralThinkingContent
|
||||
readonly thinkingUnits?: MistralThinkingUnit[]
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
@@ -502,8 +503,8 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
|
||||
state.lifecycle,
|
||||
events,
|
||||
state.active.id,
|
||||
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
|
||||
thinkingText(state.active.thinking?.thinking ?? []),
|
||||
thinkingMetadata({ ...state.active.thinking, type: "thinking", thinking: state.active.thinkingUnits ?? [] }),
|
||||
thinkingText(state.active.thinkingUnits ?? []),
|
||||
)
|
||||
return { ...state, lifecycle, active: undefined }
|
||||
}
|
||||
@@ -524,20 +525,23 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
|
||||
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
|
||||
const units = thinkingUnits(part.thinking)
|
||||
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
|
||||
// Keep native units out of streamed events until the block is complete.
|
||||
const accumulated = active.thinkingUnits ?? []
|
||||
accumulated.push(...units)
|
||||
const thinking = {
|
||||
...active.thinking,
|
||||
...part,
|
||||
type: "thinking" as const,
|
||||
thinking: [...(active.thinking?.thinking ?? []), ...units],
|
||||
thinking: [],
|
||||
}
|
||||
const text = thinkingText(units)
|
||||
return {
|
||||
...current,
|
||||
lifecycle:
|
||||
text.length > 0
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
|
||||
active: { ...active, thinking },
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text)
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id),
|
||||
active: { ...active, thinking, thinkingUnits: accumulated },
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,8 @@ const QUOTA_CODES = new Set([
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
|
||||
// Z.ai's Responses API reports account and plan rejections mid-stream as `permission_denied`.
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error", "permission_denied", "api_key_invalid"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
@@ -124,6 +125,7 @@ const SERVER_CODES = new Set([
|
||||
])
|
||||
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
|
||||
const INVALID_REQUEST_CODES = new Set([
|
||||
"model_not_found",
|
||||
"invalid_prompt",
|
||||
"invalid_request",
|
||||
"invalid_request_error",
|
||||
|
||||
@@ -355,6 +355,28 @@ describe("provider error rawBody classification", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies Z.ai Responses stream rejections without retrying them", () => {
|
||||
const failed = (code: string, message: string) =>
|
||||
JSON.stringify({
|
||||
response: { created_at: 0, error: { code, message }, id: "resp_1", model: "glm-4.6", status: "failed" },
|
||||
sequence_number: 0,
|
||||
type: "response.failed",
|
||||
})
|
||||
const cases = [
|
||||
["model_not_found", "Unknown Model, please check the model code.[20260930021741fdb0b57893db4642]"],
|
||||
[
|
||||
"permission_denied",
|
||||
"Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website. https://z.ai/subscribe。[20260930021742807eb8e2b21c4484]",
|
||||
],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([code, message]) =>
|
||||
classifyProviderFailure({ message: `${code}: ${message}`, rawBody: failed(code, message) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "Authentication"])
|
||||
})
|
||||
|
||||
test("classifies Google invalid API keys as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
|
||||
@@ -303,7 +303,8 @@ describe("Mistral Chat", () => {
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Con" }] }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "sider" }], closed: true }] }),
|
||||
chunk({ content: [{ type: "text", text: "Answer" }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
@@ -313,6 +314,11 @@ describe("Mistral Chat", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Consider")
|
||||
expect(response.text).toBe("Answer")
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
|
||||
["Con", undefined],
|
||||
["sider", undefined],
|
||||
])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
@@ -321,8 +327,12 @@ describe("Mistral Chat", () => {
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
thinking: [
|
||||
{ type: "text", text: "Con" },
|
||||
{ type: "text", text: "sider" },
|
||||
],
|
||||
marker: "empty",
|
||||
closed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,8 +347,12 @@ describe("Mistral Chat", () => {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
thinking: [
|
||||
{ type: "text", text: "Con" },
|
||||
{ type: "text", text: "sider" },
|
||||
],
|
||||
marker: "empty",
|
||||
closed: true,
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
@@ -357,6 +371,8 @@ describe("Mistral Chat", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toEqual([])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
|
||||
@@ -4,12 +4,12 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
|
||||
@@ -4,13 +4,13 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { redactConfig } from "./redact"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpSe
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm } from "../auth/form"
|
||||
@@ -21,7 +21,7 @@ export default Runtime.handler(
|
||||
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
|
||||
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
|
||||
intro("Authenticate an MCP server")
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
|
||||
|
||||
@@ -4,12 +4,12 @@ import { OpenCode, type McpServer } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
@@ -12,7 +12,7 @@ const location = { directory: process.cwd() }
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
|
||||
@@ -10,6 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
if ((yield* ServiceConfig.read()).disabled === true)
|
||||
return yield* Effect.fail(
|
||||
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
|
||||
)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const urls = Option.isSome(input.url)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export interface Item {
|
||||
readonly runtime: "Server" | "TUI"
|
||||
@@ -16,7 +16,7 @@ export interface Item {
|
||||
}
|
||||
|
||||
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const location = { directory: process.cwd() }
|
||||
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -16,7 +16,7 @@ import { discoverPluginTargets, localPluginDirectories, localSource } from "@ope
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -18,7 +18,7 @@ export type Resolved = {
|
||||
readonly service?: ReturnType<typeof managedService>
|
||||
}
|
||||
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) {
|
||||
if (args.server !== undefined && args.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
if (args.server !== undefined) {
|
||||
@@ -38,7 +38,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
)
|
||||
return { endpoint } satisfies Resolved
|
||||
}
|
||||
if (args.standalone) {
|
||||
if (args.standalone || (yield* ServiceConfig.read()).disabled === true) {
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { selfCommand } from "../util/process"
|
||||
// registration file (by channel), which version, and how to spawn opencode.
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
@@ -20,7 +21,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password", "cors", "env"] as const
|
||||
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -78,7 +79,15 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
|
||||
if (
|
||||
key === "disabled" ||
|
||||
key === "hostname" ||
|
||||
key === "port" ||
|
||||
key === "password" ||
|
||||
key === "cors" ||
|
||||
key === "env"
|
||||
)
|
||||
return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -151,6 +160,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
return String((yield* read()).disabled ?? false)
|
||||
}
|
||||
case "hostname": {
|
||||
return (yield* read()).hostname ?? ""
|
||||
}
|
||||
@@ -177,6 +189,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
if (selected !== "env" && nestedValue !== undefined)
|
||||
throw new Error(`Usage: opencode service set ${selected} <value>`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false")
|
||||
if (value === "true") yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), disabled: value === "true" })
|
||||
return
|
||||
}
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), hostname: value })
|
||||
@@ -221,6 +239,11 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
const { disabled: _disabled, ...next } = yield* read()
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { hostname: _hostname, ...next } = yield* read()
|
||||
|
||||
@@ -8,6 +8,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServerConnection } from "../src/services/server-connection"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { isolatedEnv } from "./fixture/environment"
|
||||
|
||||
test("resolution groups Effect-native lifecycle operations only for the managed service", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-"))
|
||||
@@ -38,7 +39,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
pid: process.pid,
|
||||
}),
|
||||
)
|
||||
const resolved = await runPromise(ServerConnection.resolve({}))
|
||||
const resolved = await runPromise(ServerConnection.resolve())
|
||||
|
||||
expect(resolved.endpoint.url).toBe(server.url.toString())
|
||||
expect(resolved.service).toBeDefined()
|
||||
@@ -69,3 +70,61 @@ test("service options only require a matching version when requested", async ()
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled background service creates a private server per CLI call without registering a daemon", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-"))
|
||||
const env = isolatedEnv(root)
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts")]
|
||||
const execute = async (...args: string[]) => {
|
||||
const child = Bun.spawn([...command, ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exit }
|
||||
}
|
||||
const run = async (...args: string[]) => {
|
||||
const { stdout, stderr, exit } = await execute(...args)
|
||||
expect(exit, stderr).toBe(0)
|
||||
expect(stderr).not.toContain("Starting background server")
|
||||
return stdout
|
||||
}
|
||||
|
||||
try {
|
||||
await run("service", "set", "disabled", "true")
|
||||
expect(await run("service", "get", "disabled")).toBe("true\n")
|
||||
const first = JSON.parse(await run("api", "get", "/api/info"))
|
||||
const second = JSON.parse(await run("api", "get", "/api/info"))
|
||||
expect(first.pid).toBeGreaterThan(0)
|
||||
expect(second.pid).toBeGreaterThan(0)
|
||||
expect(second.pid).not.toBe(first.pid)
|
||||
expect(await run("mcp", "list")).toContain("No MCP servers configured")
|
||||
const pairing = await execute("pair")
|
||||
expect(pairing.exit).not.toBe(0)
|
||||
expect(pairing.stderr).toContain("Pairing requires the background service")
|
||||
|
||||
const explicit = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: 12345, urls: [] })
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(JSON.parse(await run("api", "--server", explicit.url.toString(), "get", "/api/info")).pid).toBe(12345)
|
||||
} finally {
|
||||
await explicit.stop(true)
|
||||
}
|
||||
expect(await fs.readdir(path.join(root, "state", "opencode")).catch(() => [])).toEqual([])
|
||||
|
||||
await run("service", "unset", "disabled")
|
||||
expect(await run("service", "get", "disabled")).toBe("false\n")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, FileSystem, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -21,6 +21,24 @@ test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
})
|
||||
|
||||
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-config-"))
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
|
||||
try {
|
||||
expect(await run(ServiceConfig.get("disabled"))).toBe("false")
|
||||
await expect(run(ServiceConfig.set("disabled", "yes"))).rejects.toThrow("Disabled must be true or false")
|
||||
expect(await run(ServiceConfig.read())).toEqual({})
|
||||
await run(ServiceConfig.set("disabled", "true"))
|
||||
expect(await run(ServiceConfig.read())).toEqual({ disabled: true })
|
||||
await run(ServiceConfig.unset("disabled"))
|
||||
expect(await run(ServiceConfig.read())).toEqual({})
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,17 @@ opencode --standalone
|
||||
opencode --server http://localhost:4096
|
||||
```
|
||||
|
||||
To make private servers the default for CLI commands instead of starting the shared service, disable it:
|
||||
|
||||
```bash
|
||||
opencode service set disabled true
|
||||
opencode
|
||||
```
|
||||
|
||||
This stops the running background service. `--server` still connects to an explicitly selected server; `opencode service start`
|
||||
can still start the shared service explicitly. To return to automatic service use, run `opencode service unset disabled`.
|
||||
Pairing requires the shared service and is unavailable while it is disabled.
|
||||
|
||||
See [Troubleshooting](/troubleshooting) for shared service diagnostics and the [API reference](/api) for server endpoints.
|
||||
|
||||
## Paths
|
||||
|
||||
Reference in New Issue
Block a user