Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 8272d68335 fix(console): harden zen stream relay diagnostics
Add stream lifecycle metrics and extract the Zen relay loop so missing bodies, decoder stalls, EOF framing, and postprocess failures are easier to diagnose. Cover the new relay behavior with focused repro tests for the streaming edge cases we suspect in production.
2026-03-26 11:24:56 -04:00
7 changed files with 853 additions and 771 deletions
@@ -39,6 +39,7 @@ import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
import { createTrialLimiter } from "./trialLimiter"
import { createStickyTracker } from "./stickyProviderTracker"
import { relay } from "./stream"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Resource } from "@opencode-ai/console-resource"
import { i18n, type Key } from "~/i18n"
@@ -254,80 +255,41 @@ export async function handler(
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
const usageParser = providerInfo.createUsageParser()
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
let buffer = ""
let responseLength = 0
function pump(): Promise<void> {
return (
reader?.read().then(async ({ done, value: rawValue }) => {
if (done) {
logger.metric({
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
dataDumper?.flush()
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
}
c.close()
return
}
if (responseLength === 0) {
const now = Date.now()
logger.metric({
time_to_first_byte: now - startTimestamp,
"timestamp.first_byte": now,
})
}
const value = binaryDecoder ? binaryDecoder(rawValue) : rawValue
if (!value) return
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
dataDumper?.provideStream(buffer)
const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? ""
for (let part of parts) {
logger.debug("PART: " + part)
part = part.trim()
usageParser.parse(part)
if (providerInfo.format !== opts.format) {
part = streamConverter(part)
c.enqueue(encoder.encode(part + "\n\n"))
}
}
if (providerInfo.format === opts.format) {
c.enqueue(value)
}
return pump()
}) || Promise.resolve()
)
}
return pump()
const metric = (values: Record<string, unknown>) =>
logger.metric({
request: requestId,
session: sessionId,
client: ocClient,
provider: providerInfo.id,
model: modelInfo.id,
...values,
})
const stream = relay({
body: res.body,
separator: providerInfo.streamSeparator,
signal: input.request.signal,
start: startTimestamp,
same: providerInfo.format === opts.format,
binary: binaryDecoder,
parse: (part) => {
logger.debug("PART: " + part)
usageParser.parse(part)
},
convert: streamConverter,
tail: async () => {
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (!usage) return
const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
return buildCostChunk(opts.format, cost)
},
metric,
dump: dataDumper,
})
return new Response(stream, {
status: resStatus,
@@ -0,0 +1,196 @@
const done = new Set(["[DONE]", "message_stop", "response.completed"])
type Dump = {
provideStream: (chunk: string) => void
flush: () => void
}
type Opts = {
body: ReadableStream<Uint8Array> | null | undefined
separator: string
signal: AbortSignal
start: number
same: boolean
binary?: (chunk: Uint8Array) => Uint8Array | undefined
parse: (part: string) => void
convert: (part: string) => string
tail: () => Promise<string | undefined>
metric: (values: Record<string, unknown>) => void
dump?: Dump
}
export const eventName = (part: string) => {
const line = part.split("\n", 1)[0]?.trim() ?? ""
if (line.startsWith("event:")) return line.slice(6).trim() || "message"
if (part.includes("[DONE]")) return "[DONE]"
if (line.startsWith("data:")) return "message"
return "unknown"
}
const errInfo = (err: unknown) => {
if (err instanceof Error) {
return {
"stream.error_type": err.constructor.name,
"stream.error_message": err.message,
}
}
return {
"stream.error_type": typeof err,
"stream.error_message": String(err),
}
}
const stats = (len: number, cnt: number, seen: number, buf: string, end: string | undefined, gap: number) => ({
"stream.response_length": len,
"stream.chunk_count": cnt,
"stream.event_count": seen,
"stream.pending_length": buf.length,
"stream.last_event": end,
"stream.max_gap_ms": gap || undefined,
})
export const relay = (opts: Opts) =>
new ReadableStream({
async start(c) {
let phase = "start"
let len = 0
let cnt = 0
let seen = 0
let end: string | undefined
let gap = 0
let prev: number | undefined
let completed = false
let aborted = false
let buf = ""
if (!opts.body) {
opts.metric({
"stream.event": "missing_body",
"stream.phase": phase,
})
opts.dump?.flush()
c.close()
return
}
const reader = opts.body.getReader()
const dec = new TextDecoder()
const enc = new TextEncoder()
const names: Record<string, number> = {}
const abort = () => {
aborted = true
reader.cancel().catch(() => undefined)
}
const note = (part: string) => {
const name = eventName(part)
end = name
seen += 1
names[name] = (names[name] ?? 0) + 1
if (done.has(name)) completed = true
}
opts.signal.addEventListener("abort", abort)
opts.metric({
"stream.event": "started",
})
try {
while (true) {
phase = "read"
const raw = await reader.read()
if (raw.done) break
if (len === 0) {
const now = Date.now()
opts.metric({
time_to_first_byte: now - opts.start,
"timestamp.first_byte": now,
})
}
const value = opts.binary ? opts.binary(raw.value) : raw.value
if (!value) continue
cnt += 1
len += value.length
const now = Date.now()
if (prev !== undefined) gap = Math.max(gap, now - prev)
prev = now
const text = dec.decode(value, { stream: true })
buf += text
opts.dump?.provideStream(text)
const parts = buf.split(opts.separator)
buf = parts.pop() ?? ""
for (let part of parts) {
part = part.trim()
if (!part) continue
note(part)
phase = "parse"
opts.parse(part)
if (opts.same) continue
phase = "convert"
c.enqueue(enc.encode(opts.convert(part) + "\n\n"))
}
if (opts.same) c.enqueue(value)
}
const tail = dec.decode()
if (tail) {
buf += tail
opts.dump?.provideStream(tail)
}
if (buf.trim()) {
const part = buf.trim()
note(part)
phase = "parse"
opts.parse(part)
if (!opts.same) {
phase = "convert"
c.enqueue(enc.encode(opts.convert(part) + "\n\n"))
}
buf = ""
}
opts.metric({
response_length: len,
"timestamp.last_byte": Date.now(),
})
opts.dump?.flush()
phase = "tail"
const chunk = await opts.tail()
if (chunk) c.enqueue(enc.encode(chunk))
c.close()
opts.metric({
"stream.event": "finished",
"stream.phase": "done",
"stream.duration_ms": Date.now() - opts.start,
"stream.saw_completed": completed,
"stream.events": JSON.stringify(names),
...stats(len, cnt, seen, buf, end, gap),
})
} catch (err) {
opts.metric({
"stream.event": aborted ? "aborted" : "error",
"stream.phase": phase,
"stream.duration_ms": Date.now() - opts.start,
"stream.saw_completed": completed,
"stream.events": JSON.stringify(names),
...stats(len, cnt, seen, buf, end, gap),
...errInfo(err),
})
c.error(err)
} finally {
opts.signal.removeEventListener("abort", abort)
}
},
})
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test"
import { eventName, relay } from "../src/routes/zen/util/stream"
const enc = new TextEncoder()
const read = (stream: ReadableStream<Uint8Array>) => new Response(stream).text()
const body = (parts: string[]) =>
new ReadableStream<Uint8Array>({
async start(c) {
for (const part of parts) c.enqueue(enc.encode(part))
c.close()
},
})
describe("zen stream", () => {
test("parses known event names", () => {
expect(eventName("event: response.created\ndata: {}")).toBe("response.created")
expect(eventName('data: {"ok":true}')).toBe("message")
expect(eventName("data: [DONE]")).toBe("[DONE]")
})
test("relays split OpenAI responses and logs completion", async () => {
const seen: string[] = []
const logs: Array<Record<string, unknown>> = []
const stream = relay({
body: body([
"event: response.created\n",
'data: {"type":"response.created"}\n\n',
"event: response.completed\n",
'data: {"response":{"usage":{"input_tokens":1,"output_tokens":2}}}\n\n',
]),
separator: "\n\n",
signal: new AbortController().signal,
start: Date.now(),
same: true,
parse: (part) => {
seen.push(part)
},
convert: (part) => part,
tail: async () => undefined,
metric: (values) => logs.push(values),
})
const text = await read(stream)
expect(text).toContain("response.created")
expect(text).toContain("response.completed")
expect(seen).toHaveLength(2)
expect(logs.at(-1)?.["stream.event"]).toBe("finished")
expect(logs.at(-1)?.["stream.saw_completed"]).toBe(true)
})
test("keeps reading when binary decoder needs another chunk", async () => {
let calls = 0
const logs: Array<Record<string, unknown>> = []
const stream = relay({
body: body(["a", "b"]),
separator: "\n\n",
signal: new AbortController().signal,
start: Date.now(),
same: true,
binary: (chunk) => {
calls += 1
if (calls === 1) return
return chunk
},
parse: () => undefined,
convert: (part) => part,
tail: async () => undefined,
metric: (values) => logs.push(values),
})
const text = await read(stream)
expect(text).toBe("b")
expect(logs.at(-1)?.["stream.event"]).toBe("finished")
})
test("flushes a final unterminated event at EOF", async () => {
const seen: string[] = []
const logs: Array<Record<string, unknown>> = []
const stream = relay({
body: body(['event: response.completed\ndata: {"response":{"usage":{"input_tokens":1}}}']),
separator: "\n\n",
signal: new AbortController().signal,
start: Date.now(),
same: true,
parse: (part) => {
seen.push(part)
},
convert: (part) => part,
tail: async () => undefined,
metric: (values) => logs.push(values),
})
const text = await read(stream)
expect(text).toContain("response.completed")
expect(seen).toHaveLength(1)
expect(logs.at(-1)?.["stream.saw_completed"]).toBe(true)
})
test("closes cleanly when upstream body is missing", async () => {
const logs: Array<Record<string, unknown>> = []
const stream = relay({
body: null,
separator: "\n\n",
signal: new AbortController().signal,
start: Date.now(),
same: true,
parse: () => undefined,
convert: (part) => part,
tail: async () => undefined,
metric: (values) => logs.push(values),
})
expect(await read(stream)).toBe("")
expect(logs.at(-1)?.["stream.event"]).toBe("missing_body")
})
test("surfaces postprocess failures with stream metrics", async () => {
const logs: Array<Record<string, unknown>> = []
const stream = relay({
body: body(["event: response.created\ndata: {}\n\n"]),
separator: "\n\n",
signal: new AbortController().signal,
start: Date.now(),
same: true,
parse: () => undefined,
convert: (part) => part,
tail: async () => {
throw new Error("boom")
},
metric: (values) => logs.push(values),
})
await expect(read(stream)).rejects.toThrow("boom")
expect(logs.at(-1)?.["stream.event"]).toBe("error")
expect(logs.at(-1)?.["stream.phase"]).toBe("tail")
})
})
+1 -1
View File
@@ -186,5 +186,5 @@ Still open and likely worth migrating:
- [ ] `SessionCompaction`
- [ ] `Provider`
- [x] `Project`
- [x] `LSP`
- [ ] `LSP`
- [x] `MCP`
+342 -405
View File
@@ -11,9 +11,6 @@ import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -65,6 +62,92 @@ export namespace LSP {
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
// If experimental flag is enabled, disable pyright
if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"]
}
} else {
// If experimental flag is disabled, disable ty
if (servers["ty"]) {
delete servers["ty"]
}
}
}
const state = Instance.state(
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = {}
const cfg = await Config.get()
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
return {
broken: new Set<string>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
}
}
for (const server of Object.values(LSPServer)) {
servers[server.id] = server
}
filterExperimentalServers(servers)
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
delete servers[name]
continue
}
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => {
return {
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: {
...process.env,
...item.env,
},
}),
initialization: item.initialization,
}
},
}
}
log.info("enabled LSP servers", {
serverIds: Object.values(servers)
.map((server) => server.id)
.join(", "),
})
return {
broken: new Set<string>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
}
},
async (state) => {
await Promise.all(state.clients.map((client) => client.shutdown()))
},
)
export async function init() {
return state()
}
export const Status = z
.object({
id: z.string(),
@@ -77,6 +160,168 @@ export namespace LSP {
})
export type Status = z.infer<typeof Status>
export async function status() {
return state().then((x) => {
const result: Status[] = []
for (const client of x.clients) {
result.push({
id: client.serverID,
name: x.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
status: "connected",
})
}
return result
})
}
async function getClients(file: string) {
const s = await state()
// Only spawn LSP clients for files within the instance directory
if (!Instance.containsPath(file)) {
return []
}
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
return value
})
.catch((err) => {
s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id })
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) {
return undefined
}
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
return existing
}
s.clients.push(client)
return client
}
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
const inflight = s.spawning.get(root + server.id)
if (inflight) {
const client = await inflight
if (!client) continue
result.push(client)
continue
}
const task = schedule(server, root, root + server.id)
s.spawning.set(root + server.id, task)
task.finally(() => {
if (s.spawning.get(root + server.id) === task) {
s.spawning.delete(root + server.id)
}
})
const client = await task
if (!client) continue
result.push(client)
Bus.publish(Event.Updated, {})
}
return result
}
export async function hasClients(file: string) {
const s = await state()
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
}
return false
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
log.info("touching file", { file: input })
const clients = await getClients(input)
await Promise.all(
clients.map(async (client) => {
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
})
}
export async function diagnostics() {
const results: Record<string, LSPClient.Diagnostic[]> = {}
for (const result of await runAll(async (client) => client.diagnostics)) {
for (const [path, diagnostics] of result.entries()) {
const arr = results[path] || []
arr.push(...diagnostics)
results[path] = arr
}
}
return results
}
export async function hover(input: { file: string; line: number; character: number }) {
return run(input.file, (client) => {
return client.connection
.sendRequest("textDocument/hover", {
textDocument: {
uri: pathToFileURL(input.file).href,
},
position: {
line: input.line,
character: input.character,
},
})
.catch(() => null)
})
}
enum SymbolKind {
File = 1,
Module = 2,
@@ -117,423 +362,115 @@ export namespace LSP {
SymbolKind.Enum,
]
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"]
}
} else {
if (servers["ty"]) {
delete servers["ty"]
}
}
export async function workspaceSymbol(query: string) {
return runAll((client) =>
client.connection
.sendRequest("workspace/symbol", {
query,
})
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
type LocInput = { file: string; line: number; character: number }
interface State {
clients: LSPClient.Info[]
servers: Record<string, LSPServer.Info>
broken: Set<string>
spawning: Map<string, Promise<LSPClient.Info | undefined>>
export async function documentSymbol(uri: string) {
const file = fileURLToPath(uri)
return run(file, (client) =>
client.connection
.sendRequest("textDocument/documentSymbol", {
textDocument: {
uri,
},
})
.catch(() => []),
)
.then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
.then((result) => result.filter(Boolean))
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly hasClients: (file: string) => Effect.Effect<boolean>
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
readonly hover: (input: LocInput) => Effect.Effect<any>
readonly definition: (input: LocInput) => Effect.Effect<any[]>
readonly references: (input: LocInput) => Effect.Effect<any[]>
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]>
readonly workspaceSymbol: (query: string) => Effect.Effect<LSP.Symbol[]>
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
export async function definition(input: { file: string; line: number; character: number }) {
return run(input.file, (client) =>
client.connection
.sendRequest("textDocument/definition", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
).then((result) => result.flat().filter(Boolean))
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LSP") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () {
const cfg = yield* Effect.promise(() => Config.get())
const servers: Record<string, LSPServer.Info> = {}
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
} else {
for (const server of Object.values(LSPServer)) {
servers[server.id] = server
}
filterExperimentalServers(servers)
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
delete servers[name]
continue
}
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
initialization: item.initialization,
}),
}
}
log.info("enabled LSP servers", {
serverIds: Object.values(servers)
.map((server) => server.id)
.join(", "),
})
}
const s: State = {
clients: [],
servers,
broken: new Set(),
spawning: new Map(),
}
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await Promise.all(s.clients.map((client) => client.shutdown()))
}),
)
return s
}),
)
const getClients = Effect.fnUntraced(function* (file: string) {
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
return value
})
.catch((err) => {
s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id })
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) return undefined
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
return existing
}
s.clients.push(client)
return client
}
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
const inflight = s.spawning.get(root + server.id)
if (inflight) {
const client = await inflight
if (!client) continue
result.push(client)
continue
}
const task = schedule(server, root, root + server.id)
s.spawning.set(root + server.id, task)
task.finally(() => {
if (s.spawning.get(root + server.id) === task) {
s.spawning.delete(root + server.id)
}
})
const client = await task
if (!client) continue
result.push(client)
Bus.publish(Event.Updated, {})
}
return result
export async function references(input: { file: string; line: number; character: number }) {
return run(input.file, (client) =>
client.connection
.sendRequest("textDocument/references", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
context: { includeDeclaration: true },
})
})
.catch(() => []),
).then((result) => result.flat().filter(Boolean))
}
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
const clients = yield* getClients(file)
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
})
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
})
const init = Effect.fn("LSP.init")(function* () {
yield* InstanceState.get(state)
})
const status = Effect.fn("LSP.status")(function* () {
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
status: "connected",
})
}
return result
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
}
return false
export async function implementation(input: { file: string; line: number; character: number }) {
return run(input.file, (client) =>
client.connection
.sendRequest("textDocument/implementation", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
})
.catch(() => null),
).then((result) => result.flat().filter(Boolean))
}
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
clients.map(async (client) => {
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
}),
)
})
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
const results: Record<string, LSPClient.Diagnostic[]> = {}
const all = yield* runAll(async (client) => client.diagnostics)
for (const result of all) {
for (const [p, diags] of result.entries()) {
const arr = results[p] || []
arr.push(...diags)
results[p] = arr
}
}
return results
})
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
return yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/hover", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
})
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/definition", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
return results.flat().filter(Boolean)
})
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/references", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
context: { includeDeclaration: true },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/implementation", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
return results.flat().filter(Boolean)
})
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
const file = fileURLToPath(uri)
const results = yield* run(file, (client) =>
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
)
return (results.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]).filter(Boolean)
})
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) =>
client.connection
.sendRequest("workspace/symbol", { query })
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
)
return results.flat() as LSP.Symbol[]
})
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})
const callHierarchyRequest = Effect.fnUntraced(function* (
input: LocInput,
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
) {
const results = yield* run(input.file, async (client) => {
const items = (await client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => [])) as any[]
if (!items?.length) return []
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) {
return run(input.file, (client) =>
client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
return results.flat().filter(Boolean)
})
.catch(() => []),
).then((result) => result.flat().filter(Boolean))
}
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
})
export async function incomingCalls(input: { file: string; line: number; character: number }) {
return run(input.file, async (client) => {
const items = (await client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => [])) as any[]
if (!items?.length) return []
return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => [])
}).then((result) => result.flat().filter(Boolean))
}
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
})
export async function outgoingCalls(input: { file: string; line: number; character: number }) {
return run(input.file, async (client) => {
const items = (await client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => [])) as any[]
if (!items?.length) return []
return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => [])
}).then((result) => result.flat().filter(Boolean))
}
return Service.of({
init,
status,
hasClients,
touchFile,
diagnostics,
hover,
definition,
references,
implementation,
documentSymbol,
workspaceSymbol,
prepareCallHierarchy,
incomingCalls,
outgoingCalls,
})
}),
)
async function runAll<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
}
const { runPromise } = makeRuntime(Service, layer)
export const init = async () => runPromise((svc) => svc.init())
export const status = async () => runPromise((svc) => svc.status())
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
async function run<T>(file: string, input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await getClients(file)
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
}
export namespace Diagnostic {
export function pretty(diagnostic: LSPClient.Diagnostic) {
+140 -145
View File
@@ -67,8 +67,141 @@ export namespace Server {
export const createApp = (opts: { cors?: string[] }): Hono => {
const app = new Hono()
return app
.onError((err, c) => {
log.error("failed", {
error: err,
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name === "ProviderAuthValidationFailed") status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
return c.json(err.toObject(), { status })
}
if (err instanceof HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
return c.json(new NamedError.Unknown({ message }).toObject(), {
status: 500,
})
})
.use((c, next) => {
// Allow CORS preflight requests to succeed without auth.
// Browser clients sending Authorization headers will preflight with OPTIONS.
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return basicAuth({ username, password })(c, next)
})
.use(async (c, next) => {
const skipLogging = c.req.path === "/log"
if (!skipLogging) {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
}
const timer = log.time("request", {
method: c.req.method,
path: c.req.path,
})
await next()
if (!skipLogging) {
timer.stop()
}
})
.use(
cors({
origin(input) {
if (!input) return
const InstanceRoutes: Hono = new Hono()
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost"
)
return input
// *.opencode.ai (https only, adjust if needed)
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
return input
}
if (opts?.cors?.includes(input)) {
return input
}
return
},
}),
)
.route("/global", GlobalRoutes())
.put(
"/auth/:providerID",
describeRoute({
summary: "Set auth credentials",
description: "Set authentication credentials",
operationId: "auth.set",
responses: {
200: {
description: "Successfully set authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
validator("json", Auth.Info.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
await Auth.set(providerID, info)
return c.json(true)
},
)
.delete(
"/auth/:providerID",
describeRoute({
summary: "Remove auth credentials",
description: "Remove authentication credentials",
operationId: "auth.remove",
responses: {
200: {
description: "Successfully removed authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
await Auth.remove(providerID)
return c.json(true)
},
)
.use(async (c, next) => {
if (c.req.path === "/log") return next()
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
@@ -376,143 +509,6 @@ export namespace Server {
return c.json(await Format.status())
},
)
return app
.onError((err, c) => {
log.error("failed", {
error: err,
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name === "ProviderAuthValidationFailed") status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
return c.json(err.toObject(), { status })
}
if (err instanceof HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
return c.json(new NamedError.Unknown({ message }).toObject(), {
status: 500,
})
})
.use((c, next) => {
// Allow CORS preflight requests to succeed without auth.
// Browser clients sending Authorization headers will preflight with OPTIONS.
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return basicAuth({ username, password })(c, next)
})
.use(async (c, next) => {
const skipLogging = c.req.path === "/log"
if (!skipLogging) {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
}
const timer = log.time("request", {
method: c.req.method,
path: c.req.path,
})
await next()
if (!skipLogging) {
timer.stop()
}
})
.use(
cors({
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost"
)
return input
// *.opencode.ai (https only, adjust if needed)
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
return input
}
if (opts?.cors?.includes(input)) {
return input
}
return
},
}),
)
.route("/global", GlobalRoutes())
.put(
"/auth/:providerID",
describeRoute({
summary: "Set auth credentials",
description: "Set authentication credentials",
operationId: "auth.set",
responses: {
200: {
description: "Successfully set authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
validator("json", Auth.Info.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
await Auth.set(providerID, info)
return c.json(true)
},
)
.delete(
"/auth/:providerID",
describeRoute({
summary: "Remove auth credentials",
description: "Remove authentication credentials",
operationId: "auth.remove",
responses: {
200: {
description: "Successfully removed authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
await Auth.remove(providerID)
return c.json(true)
},
)
.route("/", InstanceRoutes)
.all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
@@ -520,14 +516,13 @@ export namespace Server {
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
const file = await Filesystem.readArrayBuffer(match).catch(() => null)
if (file) {
const mime = Filesystem.mimeType(match)
c.header("Content-Type", mime)
if (mime.startsWith("text/html")) {
const file = Bun.file(match)
if (await file.exists()) {
c.header("Content-Type", file.type)
if (file.type.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(file)
return c.body(await file.arrayBuffer())
} else {
return c.json({ error: "Not Found" }, 404)
}
@@ -548,7 +543,7 @@ export namespace Server {
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
}) as Hono
}) as unknown as Hono
}
export async function openapi() {
@@ -1,147 +0,0 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import path from "path"
import * as Lsp from "../../src/lsp/index"
import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
function withInstance(fn: (dir: string) => Promise<void>) {
return async () => {
await using tmp = await tmpdir()
try {
await Instance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
} finally {
await Instance.disposeAll()
}
}
}
describe("LSP service lifecycle", () => {
let spawnSpy: ReturnType<typeof spyOn>
beforeEach(() => {
spawnSpy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
})
afterEach(() => {
spawnSpy.mockRestore()
})
test(
"init() completes without error",
withInstance(async () => {
await Lsp.LSP.init()
}),
)
test(
"status() returns empty array initially",
withInstance(async () => {
const result = await Lsp.LSP.status()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
)
test(
"diagnostics() returns empty object initially",
withInstance(async () => {
const result = await Lsp.LSP.diagnostics()
expect(typeof result).toBe("object")
expect(Object.keys(result).length).toBe(0)
}),
)
test(
"hasClients() returns true for .ts files in instance",
withInstance(async (dir) => {
const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true)
}),
)
test(
"hasClients() returns false for files outside instance",
withInstance(async (dir) => {
const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts"))
// hasClients checks servers but doesn't check containsPath — getClients does
// So hasClients may return true even for outside files (it checks extension + root)
// The guard is in getClients, not hasClients
expect(typeof result).toBe("boolean")
}),
)
test(
"workspaceSymbol() returns empty array with no clients",
withInstance(async () => {
const result = await Lsp.LSP.workspaceSymbol("test")
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
)
test(
"definition() returns empty array for unknown file",
withInstance(async (dir) => {
const result = await Lsp.LSP.definition({
file: path.join(dir, "nonexistent.ts"),
line: 0,
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
)
test(
"references() returns empty array for unknown file",
withInstance(async (dir) => {
const result = await Lsp.LSP.references({
file: path.join(dir, "nonexistent.ts"),
line: 0,
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
)
test(
"multiple init() calls are idempotent",
withInstance(async () => {
await Lsp.LSP.init()
await Lsp.LSP.init()
await Lsp.LSP.init()
// Should not throw or create duplicate state
}),
)
})
describe("LSP.Diagnostic", () => {
test("pretty() formats error diagnostic", () => {
const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } },
message: "Type 'string' is not assignable to type 'number'",
severity: 1,
} as any)
expect(result).toBe("ERROR [10:5] Type 'string' is not assignable to type 'number'")
})
test("pretty() formats warning diagnostic", () => {
const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
message: "Unused variable",
severity: 2,
} as any)
expect(result).toBe("WARN [1:1] Unused variable")
})
test("pretty() defaults to ERROR when no severity", () => {
const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
message: "Something wrong",
} as any)
expect(result).toBe("ERROR [1:1] Something wrong")
})
})