Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions packages/opencode/src/config/v2-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ export interface Result {

const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const Record = Schema.Record(Schema.String, Schema.Unknown)
const Timeout = Schema.Struct({
startup: Schema.optional(PositiveInt),
catalog: Schema.optional(PositiveInt),
execution: Schema.optional(PositiveInt),
})
const Timeout = Schema.Union([
PositiveInt,
Schema.Struct({
startup: Schema.optional(PositiveInt),
request: Schema.optional(PositiveInt),
catalog: Schema.optional(PositiveInt),
execution: Schema.optional(PositiveInt),
}),
])
const OAuth = Schema.Struct({
client_id: Schema.optional(Schema.String),
client_secret: Schema.optional(Schema.String),
Expand Down Expand Up @@ -251,10 +255,10 @@ function normalizeMcp(input: Record<string, unknown>, result: Record<string, unk
const timeout = Schema.decodeUnknownOption(Timeout, decodeOptions)(mcp.value.timeout)
const globalTimeout =
Option.isSome(timeout) &&
Option.isSome(timeoutRecord) &&
!isDirectServer(timeoutRecord.value) &&
(Object.keys(timeoutRecord.value).length === 0 ||
["startup", "catalog", "execution"].some((key) => Object.hasOwn(timeoutRecord.value, key)))
(Option.isNone(timeoutRecord) ||
(!isDirectServer(timeoutRecord.value) &&
(Object.keys(timeoutRecord.value).length === 0 ||
["startup", "request", "catalog", "execution"].some((key) => Object.hasOwn(timeoutRecord.value, key)))))

for (const [name, value] of Object.entries(mcp.value)) {
if (name === "servers" && envelope) continue
Expand Down Expand Up @@ -360,7 +364,9 @@ function lowerSelection(input: Schema.Schema.Type<typeof Selection>) {
}

function lowerTimeout(input: Schema.Schema.Type<typeof Timeout>) {
if (typeof input === "number") return input
if (input.startup !== undefined) return undefined
if (input.request !== undefined) return input.request
if (input.catalog === undefined || input.execution === undefined) return undefined
if (input.catalog !== input.execution) return undefined
return input.catalog
Expand Down
80 changes: 80 additions & 0 deletions packages/opencode/test/config/v2-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,50 @@ describe("ConfigV2Compat.lower", () => {
})
})

test("retains a numeric MCP server timeout", () => {
const config = lower({
mcp: {
servers: {
remote: { type: "remote", url: "https://example.com/mcp", timeout: 18000 },
},
},
})

expect(config.mcp).toEqual({
remote: { type: "remote", url: "https://example.com/mcp", enabled: true, timeout: 18000 },
})
})

test("retains a request-shaped MCP server timeout", () => {
const config = lower({
mcp: {
servers: {
local: { type: "local", command: ["local-mcp"], timeout: { request: 12000 } },
},
},
})

expect(config.mcp).toEqual({
local: { type: "local", command: ["local-mcp"], enabled: true, timeout: 12000 },
})
})

test("maps a numeric global MCP timeout to the V1 experimental field", () => {
const config = lower({
mcp: {
timeout: 60000,
servers: {
remote: { type: "remote", url: "https://example.com/mcp" },
},
},
})

expect(config.experimental?.mcp_timeout).toBe(60000)
expect(config.mcp).toEqual({
remote: { type: "remote", url: "https://example.com/mcp", enabled: true },
})
})

test("reports unsupported settings and lossy conversions without their values", () => {
const secret = "do-not-log-credentials"
const result = ConfigV2Compat.lower({
Expand Down Expand Up @@ -368,6 +412,42 @@ describe("V2 configuration loading", () => {
}),
)

it.instance("retains MCP servers configured with a numeric timeout", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(
path.join(instance.directory, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
timeout: 60000,
servers: {
remote: { type: "remote", url: "https://example.com/mcp", timeout: 18000 },
local: { type: "local", command: ["local-mcp"], timeout: { request: 12000 } },
},
},
}),
)

const config = yield* Config.use.get()

expect(config.mcp?.remote).toEqual({
type: "remote",
url: "https://example.com/mcp",
enabled: true,
timeout: 18000,
})
expect(config.mcp?.local).toEqual({
type: "local",
command: ["local-mcp"],
enabled: true,
timeout: 12000,
})
expect(config.experimental?.mcp_timeout).toBe(60000)
}),
)

it.instance("keeps legacy TUI normalization when loading a mixed V1 and V2 document", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
Expand Down
Loading