diff options
Diffstat (limited to 'packages')
48 files changed, 3652 insertions, 14 deletions
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 0ba25d8..e7204db 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -45,3 +45,7 @@ export function parseMetricsOrdinal(key: string): number { const n = Number.parseInt(last, 10); return Number.isNaN(n) ? -1 : n; } + +export function cwdKey(conversationId: string): string { + return `conv:${conversationId}:cwd`; +} diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index a82b4cc..49b0e58 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -750,3 +750,49 @@ describe("ConversationStore reconcile.repair span", () => { } }); }); + +describe("ConversationStore cwd", () => { + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("setCwd then getCwd returns the value", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/home/user/project"); + const result = await store.getCwd("conv1"); + expect(result).toBe("/home/user/project"); + }); + + it("getCwd returns null when never set", async () => { + const store = createConversationStore(storage); + const result = await store.getCwd("conv_unknown"); + expect(result).toBeNull(); + }); + + it("setCwd is an upsert (second set overwrites)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/first/path"); + await store.setCwd("conv1", "/second/path"); + const result = await store.getCwd("conv1"); + expect(result).toBe("/second/path"); + }); + + it("cwd persists across a fresh store instance on the same db file", async () => { + const store1 = createConversationStore(storage); + await store1.setCwd("conv1", "/persisted/path"); + + const store2 = createConversationStore(storage); + const result = await store2.getCwd("conv1"); + expect(result).toBe("/persisted/path"); + }); + + it("cwd of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setCwd("convA", "/path/a"); + await store.setCwd("convB", "/path/b"); + expect(await store.getCwd("convA")).toBe("/path/a"); + expect(await store.getCwd("convB")).toBe("/path/b"); + }); +}); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 6b91d58..0948f64 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -11,6 +11,7 @@ import { defineService } from "@dispatch/kernel"; import { chunkKey, chunkPrefix, + cwdKey, metricsKey, metricsPrefix, metricsSeqKey, @@ -28,6 +29,10 @@ export interface ConversationStore { ) => Promise<readonly StoredChunk[]>; readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; + /** The persisted working directory for a conversation, or null if never set. */ + readonly getCwd: (conversationId: string) => Promise<string | null>; + /** Persist (upsert) the working directory for a conversation. */ + readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -154,5 +159,16 @@ export function createConversationStore( return result; }, + + async getCwd(conversationId) { + return await storage.get(cwdKey(conversationId)); + }, + + async setCwd(conversationId, cwd) { + await storage.set(cwdKey(conversationId), cwd); + if (logger !== undefined) { + logger.debug("cwd set", { conversationId }); + } + }, }; } diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 0d2b817..27a4a1b 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -20,6 +20,7 @@ "@dispatch/tool-edit-file": "workspace:*", "@dispatch/tool-write-file": "workspace:*", "@dispatch/journal-sink": "workspace:*", + "@dispatch/lsp": "workspace:*", "@dispatch/surface-loaded-extensions": "workspace:*", "@dispatch/surface-registry": "workspace:*", "@dispatch/transport-ws": "workspace:*" diff --git a/packages/host-bin/src/config.test.ts b/packages/host-bin/src/config.test.ts index a5cfde1..fc74a79 100644 --- a/packages/host-bin/src/config.test.ts +++ b/packages/host-bin/src/config.test.ts @@ -51,6 +51,26 @@ describe("envToConfigMap", () => { expect(result["provider.openai-compat.baseURL"]).toBeUndefined(); expect(result["provider.openai-compat.model"]).toBeUndefined(); }); + + it("maps SURFACE_WS_PORT to surfaceWsPort", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "24206" }); + expect(result.surfaceWsPort).toBe(24206); + }); + + it("ignores a non-numeric SURFACE_WS_PORT", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "abc" }); + expect(result.surfaceWsPort).toBeUndefined(); + }); + + it("ignores a non-positive SURFACE_WS_PORT", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "0" }); + expect(result.surfaceWsPort).toBeUndefined(); + }); + + it("omits surfaceWsPort when SURFACE_WS_PORT is unset", () => { + const result = envToConfigMap({}); + expect(result.surfaceWsPort).toBeUndefined(); + }); }); describe("configMapToAccess", () => { diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts index f79ef4b..9a22c00 100644 --- a/packages/host-bin/src/config.ts +++ b/packages/host-bin/src/config.ts @@ -39,6 +39,14 @@ export function envToConfigMap( } } + const surfaceWsPort = env.SURFACE_WS_PORT; + if (surfaceWsPort !== undefined) { + const n = Number(surfaceWsPort); + if (Number.isFinite(n) && n > 0) { + map.surfaceWsPort = n; + } + } + return map; } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 1594dcc..420f12e 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -19,6 +19,7 @@ import { type SecretsAccess, type StorageNamespace, } from "@dispatch/kernel"; +import { extension as lspExt } from "@dispatch/lsp"; import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; import { extension as skillsExt } from "@dispatch/skills"; @@ -75,6 +76,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ sessionOrchestratorExt, skillsExt, cacheWarmingExt, + lspExt, createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), @@ -169,8 +171,13 @@ async function boot(): Promise<void> { } } + let shuttingDown = false; const shutdown = async () => { - logger.info("Shutting down — draining collector"); + if (shuttingDown) return; + shuttingDown = true; + logger.info("Shutting down — deactivating extensions"); + await host.deactivate(); + logger.info("Draining collector"); await supervisor.stop(); process.exit(0); }; diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json index 77de667..efe3815 100644 --- a/packages/host-bin/tsconfig.json +++ b/packages/host-bin/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../skills" }, { "path": "../throughput-store" }, { "path": "../transport-http" }, - { "path": "../transport-ws" } + { "path": "../transport-ws" }, + { "path": "../lsp" } ] } diff --git a/packages/kernel/src/logging/logger.test.ts b/packages/kernel/src/logging/logger.test.ts new file mode 100644 index 0000000..5d7bf45 --- /dev/null +++ b/packages/kernel/src/logging/logger.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { LogDeps, LogRecord, LogSink } from "../contracts/logging.js"; +import { createLogger } from "./logger.js"; + +function harness() { + let idCounter = 0; + const deps: LogDeps = { + now: () => 1000 + idCounter * 10, + newId: () => `span-${++idCounter}`, + }; + const records: LogRecord[] = []; + const sink: LogSink = { emit: (r) => records.push(r) }; + return { logger: createLogger({ extensionId: "test" }, sink, deps), records }; +} + +describe("createLogger child-bound attributes", () => { + it("merges child-bound attrs into BOTH span-open and span-close records", () => { + const { logger, records } = harness(); + // Bind `warm: true` via child() — mirrors the cache-warming capture path. + const warmLogger = logger.child({ conversationId: "c1", attrs: { warm: true } }); + + const span = warmLogger.span("provider.request", { model: "x" }); + span.end({ attrs: { "usage.cacheReadTokens": 0 } }); + + const open = records.find((r) => r.kind === "span-open"); + const close = records.find((r) => r.kind === "span-close"); + + // Open carries the bound attr (pre-existing behavior). + expect(open?.attributes?.warm).toBe(true); + // Close MUST carry it too, so a `warm = true` query finds the closed span + // (with its usage/status) — not just the open record. + expect(close?.attributes?.warm).toBe(true); + // Span-specific attrs from span()/end() are still present on close. + expect(close?.attributes?.model).toBe("x"); + expect(close?.attributes?.["usage.cacheReadTokens"]).toBe(0); + }); + + it("omits attributes entirely when neither bound nor span attrs exist", () => { + const { logger, records } = harness(); + const span = logger.span("bare"); + span.end(); + const close = records.find((r) => r.kind === "span-close"); + expect(close?.attributes).toBeUndefined(); + }); +}); diff --git a/packages/kernel/src/logging/logger.ts b/packages/kernel/src/logging/logger.ts index 70bc18c..4d2a609 100644 --- a/packages/kernel/src/logging/logger.ts +++ b/packages/kernel/src/logging/logger.ts @@ -194,6 +194,15 @@ export function createLogger( } const hasAttrs = Object.keys(spanAttrsMutable).length > 0; + // Merge child-bound default attrs (state.attrs) the SAME way span-open + // does (buildSpanOpen). Without this, an attribute bound via + // `logger.child({ attrs })` appears on the span-open record but NOT the + // span-close record — so a query like `warm = true` can't find the + // closed span (with its usage/status). Open and close must agree. + const mergedCloseAttrs = mergeAttributes( + state.attrs, + hasAttrs ? spanAttrsMutable : undefined, + ); const hasLinks = links.length > 0; const base = { kind: "span-close" as const, @@ -209,7 +218,7 @@ export function createLogger( ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), - ...(hasAttrs ? { attributes: { ...spanAttrsMutable } } : {}), + ...(mergedCloseAttrs !== undefined ? { attributes: mergedCloseAttrs } : {}), ...(hasLinks ? { links: [...links] } : {}), ...(outcome?.body !== undefined ? { body: outcome.body } : {}), }; diff --git a/packages/lsp/package.json b/packages/lsp/package.json new file mode 100644 index 0000000..2b5a360 --- /dev/null +++ b/packages/lsp/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/lsp", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } +} diff --git a/packages/lsp/src/client.test.ts b/packages/lsp/src/client.test.ts new file mode 100644 index 0000000..681860f --- /dev/null +++ b/packages/lsp/src/client.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest"; +import { + type FileWatcher, + type FsAccess, + LanguageServerClient, + type SpawnProcess, +} from "./client.js"; +import { encode } from "./framing.js"; + +function makeClient(overrides?: { + readonly spawn?: SpawnProcess; + readonly fileWatcher?: FileWatcher; + readonly fs?: FsAccess; + readonly initialization?: Record<string, unknown>; +}): { + client: LanguageServerClient; + stdinChunks: Uint8Array[]; + serverResponses: (msg: string) => void; +} { + const stdinChunks: Uint8Array[] = []; + let serverMessageHandler: ((msg: string) => void) | null = null; + + const mockSpawn: SpawnProcess = () => ({ + stdin: { write: (bytes) => stdinChunks.push(bytes) }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + // We'll feed messages through serverResponses + serverMessageHandler = (msg: string) => { + cb(encode(msg)); + }; + }, + }, + pid: 123, + kill: () => {}, + }); + + const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({ + close: () => {}, + }); + + const mockFs: FsAccess = { + readText: async (path) => `// content of ${path}`, + exists: async () => true, + }; + + const client = new LanguageServerClient({ + spawn: overrides?.spawn ?? mockSpawn, + fileWatcher: overrides?.fileWatcher ?? mockFileWatcher, + fs: overrides?.fs ?? mockFs, + command: ["test-lsp"], + root: "/project", + serverId: "test", + ...(overrides?.initialization ? { initialization: overrides.initialization } : {}), + }); + + return { + client, + stdinChunks, + serverResponses: (msg: string) => serverMessageHandler?.(msg), + }; +} + +describe("client", () => { + it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => { + const { client, stdinChunks, serverResponses } = makeClient(); + + const startPromise = client.start(); + + // Wait for the initialize message to be sent + await new Promise((r) => setTimeout(r, 50)); + + // Parse the sent messages to find initialize + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize"); + expect(initMsg).toBeDefined(); + expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe( + true, + ); + + // Send initialize response + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: initMsg.id, + result: { capabilities: {} }, + }), + ); + + await startPromise; + expect(client.getState()).toBe("connected"); + }); + + it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register workspace/didChangeWatchedFiles + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-watched", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }, + { + id: "reg-diag", + method: "textDocument/diagnostic", + registerOptions: {}, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const registry = client.getWatchedFilesRegistry(); + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); + }); + + it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => { + const callbackHolder: { + cb: + | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void) + | null; + } = { cb: null }; + + const trackingFileWatcher: FileWatcher = (_root, onEvent) => { + callbackHolder.cb = onEvent; + return { close: () => {} }; + }; + + const { client, stdinChunks, serverResponses } = makeClient({ + fileWatcher: trackingFileWatcher, + }); + + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register a watcher for sourcemap.json + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "sourcemap.json" }], + }, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Simulate a file change + const onFsEvent = callbackHolder.cb; + if (!onFsEvent) throw new Error("file watcher callback was never registered"); + onFsEvent({ type: "change", path: "/project/sourcemap.json" }); + + await new Promise((r) => setTimeout(r, 100)); + + // Check that the notification was sent + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const didChangeMsg = sentMessages.find( + (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles", + ); + expect(didChangeMsg).toBeDefined(); + expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json"); + expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed + }); + + it("publishDiagnostics are stored + returned", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Send diagnostics + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri: "file:///project/test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Test error", + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const store = client.getDiagnosticsStore(); + const formatted = store.format("file:///project/test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("Test error"); + }); + + it("shutdown kills the process", async () => { + const state = { killed: false }; + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + const killableSpawn: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + stdoutHolder.cb = cb; + }, + }, + pid: 123, + kill: () => { + state.killed = true; + }, + }); + + const { client } = makeClient({ spawn: killableSpawn }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + // Deliver the initialize response through the spawned process's own + // stdout (the real read path), so start() can complete the handshake. + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + + await startPromise; + + client.shutdown(); + expect(state.killed).toBe(true); + }); +}); diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts new file mode 100644 index 0000000..114b8eb --- /dev/null +++ b/packages/lsp/src/client.ts @@ -0,0 +1,366 @@ +/** + * Language-server client — wires codec + rpc + edges; runs the + * initialize handshake, honors server→client requests, runs the + * FileWatcher, and forwards matching disk changes. + */ + +import { DiagnosticsStore, type PublishDiagnosticsParams } from "./diagnostics.js"; +import { FrameDecoder } from "./framing.js"; +import { JsonRpcConnection, type WriteFn } from "./rpc.js"; +import { FileChangeType, WatchedFilesRegistry } from "./watched-files.js"; + +export interface SpawnedProcess { + readonly stdin: { readonly write: (bytes: Uint8Array) => void }; + readonly stdout: + | AsyncIterable<Uint8Array> + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; + readonly stderr?: + | AsyncIterable<Uint8Array> + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } + | undefined; + readonly pid: number | undefined; + readonly kill: () => void; +} + +export type SpawnProcess = ( + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, +) => SpawnedProcess; + +export interface FileWatcherHandle { + readonly close: () => void; +} + +export type FileWatcher = ( + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, +) => FileWatcherHandle; + +export interface FsAccess { + readonly readText: (path: string) => Promise<string>; + readonly exists: (path: string) => Promise<boolean>; +} + +export interface ClientCapabilities { + readonly window: { readonly workDoneProgress: boolean }; + readonly workspace: { + readonly configuration: boolean; + readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean }; + readonly diagnostics: { readonly refreshSupport: boolean }; + }; + readonly textDocument: { + readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean }; + readonly diagnostic: { + readonly dynamicRegistration: boolean; + readonly relatedDocumentSupport: boolean; + }; + readonly publishDiagnostics: { readonly versionSupport: boolean }; + }; +} + +export const CLIENT_CAPABILITIES: ClientCapabilities = { + window: { workDoneProgress: true }, + workspace: { + configuration: true, + didChangeWatchedFiles: { dynamicRegistration: true }, + diagnostics: { refreshSupport: false }, + }, + textDocument: { + synchronization: { didOpen: true, didChange: true }, + diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, + publishDiagnostics: { versionSupport: false }, + }, +}; + +export interface ClientDeps { + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly root: string; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly serverId: string; +} + +export type ClientState = "starting" | "connected" | "error" | "not-started"; + +export class LanguageServerClient { + readonly serverId: string; + readonly root: string; + private process: SpawnedProcess | null = null; + private rpc: JsonRpcConnection | null = null; + private decoder = new FrameDecoder(); + private diagnostics = new DiagnosticsStore(); + private watchedFiles = new WatchedFilesRegistry(); + private fileWatcherHandle: FileWatcherHandle | null = null; + private state: ClientState = "not-started"; + private stateError: string | undefined; + private deps: ClientDeps; + private openDocuments = new Map<string, number>(); + + constructor(deps: ClientDeps) { + this.deps = deps; + this.serverId = deps.serverId; + this.root = deps.root; + } + + getState(): ClientState { + return this.state; + } + + getStateError(): string | undefined { + return this.stateError; + } + + async start(): Promise<void> { + this.state = "starting"; + try { + const spawnOpts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> } = { + cwd: this.root, + }; + if (this.deps.env) { + (spawnOpts as { env?: Readonly<Record<string, string>> }).env = this.deps.env; + } + const proc = this.deps.spawn(this.deps.command as string[], spawnOpts); + this.process = proc; + + const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); + const rpc = new JsonRpcConnection(writeFn); + this.rpc = rpc; + + this.setupServerHandlers(rpc); + + const stdoutSource = proc.stdout; + if (Symbol.asyncIterator in stdoutSource) { + this.readFromAsyncIterable(stdoutSource as AsyncIterable<Uint8Array>); + } else { + this.readFromEventSource( + stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }, + ); + } + + await this.initialize(rpc); + this.state = "connected"; + } catch (err: unknown) { + this.state = "error"; + this.stateError = err instanceof Error ? err.message : String(err); + } + } + + private readFromAsyncIterable(source: AsyncIterable<Uint8Array>): void { + (async () => { + try { + for await (const chunk of source) { + this.handleBytes(chunk); + } + } catch { + // process exited + } + })(); + } + + private readFromEventSource(source: { + readonly on: (event: string, cb: (data: Uint8Array) => void) => void; + }): void { + source.on("data", (data: Uint8Array) => { + this.handleBytes(data); + }); + } + + private handleBytes(chunk: Uint8Array): void { + const messages = this.decoder.decode(chunk); + for (const msg of messages) { + this.rpc?.handleMessage(msg); + } + } + + private setupServerHandlers(rpc: JsonRpcConnection): void { + rpc.onNotification("textDocument/publishDiagnostics", (params) => { + this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams); + }); + + rpc.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + const init = this.deps.initialization ?? {}; + return items.map((item) => { + if (item.section) { + const keys = item.section.split("."); + let value: unknown = init; + for (const key of keys) { + if (value && typeof value === "object" && key in value) { + value = (value as Record<string, unknown>)[key]; + } else { + return undefined; + } + } + return value; + } + return init; + }); + }); + + rpc.onRequest("workspace/workspaceFolders", () => { + return [{ uri: `file://${this.root}`, name: this.root }]; + }); + + rpc.onRequest("window/workDoneProgress/create", () => null); + rpc.onRequest("workspace/diagnostic/refresh", () => null); + + rpc.onRequest("client/registerCapability", (params) => { + const { registrations } = params as { + readonly registrations: readonly { + readonly id: string; + readonly method: string; + readonly registerOptions?: unknown; + }[]; + }; + for (const reg of registrations) { + if (reg.method === "textDocument/diagnostic") { + // Store diagnostic registration (future use) + } else if (reg.method === "workspace/didChangeWatchedFiles") { + const opts = reg.registerOptions as + | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions + | undefined; + if (opts) { + this.watchedFiles.applyRegister({ + id: reg.id, + method: reg.method, + registerOptions: opts, + }); + } + } + } + return null; + }); + + rpc.onRequest("client/unregisterCapability", (params) => { + const { unregistrations } = params as { + readonly unregistrations: readonly { + readonly id: string; + readonly method: string; + }[]; + }; + for (const unreg of unregistrations) { + this.watchedFiles.applyUnregister(unreg); + } + return null; + }); + } + + private async initialize(rpc: JsonRpcConnection): Promise<void> { + const timeout = 45_000; + + const initPromise = rpc.sendRequest("initialize", { + processId: this.process?.pid ?? null, + rootUri: `file://${this.root}`, + workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }], + capabilities: CLIENT_CAPABILITIES, + }); + + const timeoutPromise = new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("Initialize timeout")), timeout); + }); + + await Promise.race([initPromise, timeoutPromise]); + + rpc.sendNotification("initialized", {}); + + if (this.deps.initialization) { + rpc.sendNotification("workspace/didChangeConfiguration", { + settings: this.deps.initialization, + }); + } + + this.startFileWatcher(); + } + + private startFileWatcher(): void { + const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`; + this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => { + const changeType = + event.type === "create" + ? FileChangeType.Created + : event.type === "delete" + ? FileChangeType.Deleted + : FileChangeType.Changed; + + const relativePath = event.path.startsWith(rootPrefix) + ? event.path.slice(rootPrefix.length) + : event.path.replace(/^\/+/, ""); + + if (this.watchedFiles.matches(relativePath)) { + this.rpc?.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri: `file://${event.path}`, type: changeType }], + }); + } + }); + } + + async open(filePath: string): Promise<void> { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + const version = (this.openDocuments.get(filePath) ?? 0) + 1; + this.openDocuments.set(filePath, version); + + try { + const text = await this.deps.fs.readText(filePath); + rpc.sendNotification("textDocument/didOpen", { + textDocument: { + uri: `file://${filePath}`, + languageId: "unknown", + version, + text, + }, + }); + } catch { + // file may not exist + } + } + + async waitForDiagnostics(filePath: string, timeoutMs = 10_000): Promise<string> { + await this.open(filePath); + return new Promise<string>((resolve) => { + const start = Date.now(); + const check = () => { + const formatted = this.diagnostics.format(`file://${filePath}`); + if (formatted) { + resolve(formatted); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(this.diagnostics.format(`file://${filePath}`) || ""); + return; + } + setTimeout(check, 100); + }; + check(); + }); + } + + getWatchedFilesRegistry(): WatchedFilesRegistry { + return this.watchedFiles; + } + + getDiagnosticsStore(): DiagnosticsStore { + return this.diagnostics; + } + + async request(method: string, params?: unknown): Promise<unknown> { + if (!this.rpc || this.state !== "connected") { + throw new Error("Client not connected"); + } + return this.rpc.sendRequest(method, params); + } + + shutdown(): void { + this.fileWatcherHandle?.close(); + this.fileWatcherHandle = null; + this.process?.kill(); + this.process = null; + this.rpc?.dispose(); + this.rpc = null; + this.state = "not-started"; + } +} diff --git a/packages/lsp/src/config.test.ts b/packages/lsp/src/config.test.ts new file mode 100644 index 0000000..1462490 --- /dev/null +++ b/packages/lsp/src/config.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { resolveServers } from "./config.js"; + +describe("config", () => { + it("built-in typescript resolves when tsconfig.json exists", async () => { + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: null, + exists: async (path) => path === "/project/tsconfig.json", + }); + + const ts = servers.find((s) => s.id === "typescript"); + expect(ts).toBeDefined(); + expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]); + expect(ts?.extensions).toContain(".ts"); + expect(ts?.rootMarkers).toContain("tsconfig.json"); + }); + + it(".dispatch/lsp.json servers resolve", async () => { + const config = JSON.stringify({ + servers: { + mylsp: { + command: ["my-lsp", "--stdio"], + extensions: [".ml"], + rootMarkers: ["Makefile"], + }, + }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("mylsp"); + expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]); + }); + + it("opencode.json lsp is used only as fallback", async () => { + const opencodeConfig = JSON.stringify({ + lsp: { + fallback: { + command: ["fallback-lsp"], + extensions: [".fb"], + }, + }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("fallback"); + }); + + it(".dispatch/lsp.json wins over opencode.json", async () => { + const dispatchConfig = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + const opencodeConfig = JSON.stringify({ + lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatchConfig, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("primary"); + }); + + it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => { + const config = JSON.stringify({ + servers: { + luau: { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + initialization: { + "luau-lsp": { + sourcemap: { + autogenerate: true, + rojoProjectFile: "default.project.json", + }, + }, + }, + }, + }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.sidecar).toBeDefined(); + expect(servers[0]?.sidecar?.command).toEqual([ + "rojo", + "sourcemap", + "default.project.json", + "--watch", + "-o", + "sourcemap.json", + ]); + }); +}); diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts new file mode 100644 index 0000000..f0c1f3f --- /dev/null +++ b/packages/lsp/src/config.ts @@ -0,0 +1,148 @@ +/** + * PURE config resolution — resolve language server configurations. + * + * Sources, in precedence order: + * 1. cwd/.dispatch/lsp.json servers + * 2. fallback cwd/opencode.json lsp + * 3. the built-in registry + * + * Sidecar auto-detect: if a server's initialization has luau-lsp sourcemap + * with autogenerate=true and a rojoProjectFile, attach a rojo sidecar. + */ + +export interface ResolvedServer { + readonly id: string; + readonly name: string; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly extensions: readonly string[]; + readonly rootMarkers: readonly string[]; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly sidecar?: { readonly command: readonly string[] } | undefined; +} + +export interface ServerConfig { + readonly id?: string | undefined; + readonly name?: string | undefined; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly extensions?: readonly string[] | undefined; + readonly rootMarkers?: readonly string[] | undefined; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly watch?: readonly string[] | undefined; +} + +export interface LspJsonConfig { + readonly servers?: Readonly<Record<string, ServerConfig>> | undefined; +} + +export interface OpencodeJsonConfig { + readonly lsp?: Readonly<Record<string, ServerConfig>> | undefined; +} + +export interface ResolveServersDeps { + readonly cwd: string; + readonly dispatchLspJson: string | null; + readonly opencodeJson: string | null; + readonly exists: (path: string) => Promise<boolean>; +} + +const BUILT_IN_REGISTRY: Record<string, ResolvedServer> = { + typescript: { + id: "typescript", + name: "TypeScript Language Server", + command: ["typescript-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], + rootMarkers: ["tsconfig.json", "package.json"], + }, +}; + +export async function resolveServers(deps: ResolveServersDeps): Promise<ResolvedServer[]> { + const result = new Map<string, ResolvedServer>(); + + if (deps.dispatchLspJson) { + try { + const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig; + if (config.servers) { + for (const [key, server] of Object.entries(config.servers)) { + const resolved = resolveServer(key, server); + result.set(resolved.id, resolved); + } + } + } catch { + // ignore parse errors + } + } + + if (result.size === 0 && deps.opencodeJson) { + try { + const config = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; + if (config.lsp) { + for (const [key, server] of Object.entries(config.lsp)) { + const resolved = resolveServer(key, server); + result.set(resolved.id, resolved); + } + } + } catch { + // ignore parse errors + } + } + + if (result.size === 0) { + for (const [id, server] of Object.entries(BUILT_IN_REGISTRY)) { + result.set(id, server); + } + } + + return [...result.values()]; +} + +function resolveServer(key: string, config: ServerConfig): ResolvedServer { + const id = config.id ?? key; + const name = config.name ?? id; + const extensions = config.extensions ?? []; + const rootMarkers = config.rootMarkers ?? []; + + let sidecar: { readonly command: readonly string[] } | undefined; + if (config.watch) { + sidecar = { command: config.watch }; + } else if (config.initialization) { + sidecar = detectSidecar(config.initialization); + } + + const result: ResolvedServer = { + id, + name, + command: config.command, + extensions, + rootMarkers, + }; + if (config.env) { + (result as { env?: Readonly<Record<string, string>> }).env = config.env; + } + if (config.initialization) { + (result as { initialization?: Readonly<Record<string, unknown>> }).initialization = + config.initialization; + } + if (sidecar) { + (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar; + } + return result; +} + +function detectSidecar( + init: Readonly<Record<string, unknown>>, +): { readonly command: readonly string[] } | undefined { + const luauLsp = init["luau-lsp"]; + if (!luauLsp || typeof luauLsp !== "object") return undefined; + const luau = luauLsp as Record<string, unknown>; + const sourcemap = luau.sourcemap; + if (!sourcemap || typeof sourcemap !== "object") return undefined; + const sm = sourcemap as Record<string, unknown>; + if (sm.autogenerate !== true) return undefined; + const rojoProjectFile = sm.rojoProjectFile; + if (typeof rojoProjectFile !== "string") return undefined; + return { + command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"], + }; +} diff --git a/packages/lsp/src/diagnostics.test.ts b/packages/lsp/src/diagnostics.test.ts new file mode 100644 index 0000000..9f2b6b4 --- /dev/null +++ b/packages/lsp/src/diagnostics.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { DiagnosticsStore } from "./diagnostics.js"; + +describe("diagnostics", () => { + it("formats diagnostics with severity, location, and message", () => { + const store = new DiagnosticsStore(); + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }, + severity: 1, + source: "typescript", + message: "Cannot find name 'hello'.", + }, + ], + }); + + const formatted = store.format("file:///test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("L1:6"); + expect(formatted).toContain("Cannot find name 'hello'."); + expect(formatted).toContain("[typescript]"); + }); + + it("merges push and pull diagnostics with deduplication", () => { + const store = new DiagnosticsStore(); + const diag = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Error", + }; + + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [diag], + }); + store.setPullDiagnostics("file:///test.ts", { + kind: "full", + items: [diag], + }); + + const merged = store.getMerged("file:///test.ts"); + expect(merged).toHaveLength(1); + }); + + it("returns empty string when no diagnostics exist", () => { + const store = new DiagnosticsStore(); + expect(store.format("file:///nonexistent.ts")).toBe(""); + }); +}); diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts new file mode 100644 index 0000000..ea18811 --- /dev/null +++ b/packages/lsp/src/diagnostics.ts @@ -0,0 +1,86 @@ +/** + * Diagnostics — merge push (textDocument/publishDiagnostics) + pull + * (textDocument/diagnostic) per file, dedupe, and format. + */ + +export interface Diagnostic { + readonly range: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; + readonly severity?: number; + readonly source?: string; + readonly message: string; + readonly code?: string | number; +} + +export interface PublishDiagnosticsParams { + readonly uri: string; + readonly diagnostics: readonly Diagnostic[]; +} + +export interface DocumentDiagnosticReport { + readonly kind: "full" | "unchanged"; + readonly items?: readonly Diagnostic[]; +} + +const severityNames: Record<number, string> = { + 1: "ERROR", + 2: "WARNING", + 3: "INFO", + 4: "HINT", +}; + +export class DiagnosticsStore { + private pushDiagnostics = new Map<string, readonly Diagnostic[]>(); + private pullDiagnostics = new Map<string, readonly Diagnostic[]>(); + + setPushDiagnostics(params: PublishDiagnosticsParams): void { + this.pushDiagnostics.set(params.uri, params.diagnostics); + } + + setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { + if (report.kind === "full" && report.items) { + this.pullDiagnostics.set(uri, report.items); + } + } + + getMerged(uri: string): readonly Diagnostic[] { + const push = this.pushDiagnostics.get(uri) ?? []; + const pull = this.pullDiagnostics.get(uri) ?? []; + return dedupeDiagnostics([...push, ...pull]); + } + + format(uri: string): string { + const diags = this.getMerged(uri); + if (diags.length === 0) return ""; + const lines: string[] = []; + for (const d of diags) { + const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN"; + const line = d.range.start.line + 1; + const col = d.range.start.character + 1; + const src = d.source ? ` [${d.source}]` : ""; + const code = d.code ? ` (${d.code})` : ""; + lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`); + } + return lines.join("\n"); + } +} + +function diagnosticKey(d: Diagnostic): string { + const r = d.range; + return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`; +} + +function dedupeDiagnostics(diags: readonly Diagnostic[]): readonly Diagnostic[] { + const seen = new Set<string>(); + const result: Diagnostic[] = []; + for (const d of diags) { + const key = diagnosticKey(d); + if (!seen.has(key)) { + seen.add(key); + result.push(d); + } + } + return result; +} diff --git a/packages/lsp/src/extension.ts b/packages/lsp/src/extension.ts new file mode 100644 index 0000000..486f66d --- /dev/null +++ b/packages/lsp/src/extension.ts @@ -0,0 +1,120 @@ +/** + * LSP extension — manifest + activate(host). + * + * Builds the manager with real adapters, registers the lsp tool and + * lspServiceHandle, and wires deactivate to manager.shutdownAll(). + */ + +import type { Extension, HostAPI, ServiceHandle } from "@dispatch/kernel"; +import { defineService } from "@dispatch/kernel"; +import type { SpawnedProcess } from "./client.js"; +import { LspManager } from "./manager.js"; +import { createLspTool } from "./tool.js"; +import type { LspServerStatus, LspService } from "./types.js"; + +export const lspServiceHandle: ServiceHandle<LspService> = defineService<LspService>("lsp"); + +function realSpawn( + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, +): SpawnedProcess { + const env: Record<string, string | undefined> = { ...process.env }; + if (opts.env) { + for (const [key, value] of Object.entries(opts.env)) { + env[key] = value; + } + } + const proc = Bun.spawn(command, { + cwd: opts.cwd, + env: env as Record<string, string>, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + pid: proc.pid, + kill: () => proc.kill(), + }; +} + +function realFileWatcher( + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, +): { readonly close: () => void } { + const { watch } = require("node:fs"); + const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => { + if (!filename) return; + const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`; + const type = eventType === "rename" ? "create" : "change"; + onEvent({ type, path: fullPath }); + }); + return { close: () => watcher.close() }; +} + +function realFs() { + return { + readText: async (path: string) => { + const file = Bun.file(path); + return file.text(); + }, + exists: async (path: string) => { + const file = Bun.file(path); + return file.exists(); + }, + }; +} + +export const extension: Extension = { + manifest: { + id: "lsp", + name: "Language Server Protocol", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { spawn: true, fs: true }, + contributes: { tools: ["lsp"], services: ["lsp"] }, + }, + activate(host: HostAPI) { + const logger = host.logger; + + const manager = new LspManager({ + spawn: realSpawn, + fileWatcher: realFileWatcher, + fs: realFs(), + logger: { + info: (msg, attrs) => + logger.info(msg, attrs as Record<string, string | number | boolean | null> | undefined), + warn: (msg, attrs) => + logger.warn(msg, attrs as Record<string, string | number | boolean | null> | undefined), + error: (msg, attrs) => logger.error(msg, attrs as Record<string, unknown> | undefined), + }, + }); + + const lspTool = createLspTool(manager); + host.defineTool(lspTool); + + const service: LspService = { + async status(cwd: string): Promise<readonly LspServerStatus[]> { + return manager.status(cwd); + }, + }; + host.provideService(lspServiceHandle, service); + + host.logger.info("LSP extension activated"); + + // Store manager for deactivate + (lspManagerStore as { manager: LspManager | null }).manager = manager; + }, + deactivate() { + const store = lspManagerStore as { manager: LspManager | null }; + store.manager?.shutdownAll(); + store.manager = null; + }, +}; + +// Module-scoped store for deactivate +const lspManagerStore: { manager: LspManager | null } = { manager: null }; diff --git a/packages/lsp/src/framing.test.ts b/packages/lsp/src/framing.test.ts new file mode 100644 index 0000000..7c51a16 --- /dev/null +++ b/packages/lsp/src/framing.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { encode, FrameDecoder } from "./framing.js"; + +describe("framing", () => { + it("encode/decode round-trips", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } }); + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("decoder reassembles a frame split across two chunks", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" }); + const encoded = encode(msg); + const mid = Math.floor(encoded.length / 2); + const chunk1 = encoded.slice(0, mid); + const chunk2 = encoded.slice(mid); + + const decoder = new FrameDecoder(); + const result1 = decoder.decode(chunk1); + expect(result1).toHaveLength(0); + + const result2 = decoder.decode(chunk2); + expect(result2).toHaveLength(1); + expect(result2[0]).toBe(msg); + }); + + it("decoder yields two messages from one chunk", () => { + const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" }); + const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" }); + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toHaveLength(2); + expect(messages[0]).toBe(msg1); + expect(messages[1]).toBe(msg2); + }); +}); diff --git a/packages/lsp/src/framing.ts b/packages/lsp/src/framing.ts new file mode 100644 index 0000000..3a8ab3a --- /dev/null +++ b/packages/lsp/src/framing.ts @@ -0,0 +1,67 @@ +/** + * LSP Content-Length framing codec. + * + * The LSP base protocol uses Content-Length headers to frame JSON messages. + * `encode` wraps a JSON message with headers; `FrameDecoder` reassembles + * complete messages from streaming byte chunks (handles partial frames and + * multiple frames per chunk). + */ + +const HEADER_SEP = "\r\n\r\n"; +const CONTENT_LENGTH_RE = /^Content-Length:\s*(\d+)/i; + +export function encode(msg: string): Uint8Array { + const body = new TextEncoder().encode(msg); + const header = `Content-Length: ${body.length}\r\n\r\n`; + const frame = new TextEncoder().encode(header); + const result = new Uint8Array(frame.length + body.length); + result.set(frame); + result.set(body, frame.length); + return result; +} + +export class FrameDecoder { + private buffer = ""; + private expectedLength: number | null = null; + private headerEnd = -1; + + /** + * Feed raw bytes into the decoder. Returns all complete JSON messages + * that can be extracted from the accumulated buffer. + */ + decode(chunk: Uint8Array): string[] { + this.buffer += new TextDecoder().decode(chunk); + const messages: string[] = []; + + while (true) { + if (this.expectedLength === null) { + const headerEnd = this.buffer.indexOf(HEADER_SEP); + if (headerEnd === -1) break; + + const headerPart = this.buffer.slice(0, headerEnd); + const match = CONTENT_LENGTH_RE.exec(headerPart); + if (!match?.[1]) { + this.buffer = this.buffer.slice(headerEnd + HEADER_SEP.length); + continue; + } + this.expectedLength = Number.parseInt(match[1], 10); + this.headerEnd = headerEnd; + } + + const bodyStart = this.headerEnd + HEADER_SEP.length; + const available = this.buffer.length - bodyStart; + + if (available >= this.expectedLength) { + const body = this.buffer.slice(bodyStart, bodyStart + this.expectedLength); + messages.push(body); + this.buffer = this.buffer.slice(bodyStart + this.expectedLength); + this.expectedLength = null; + this.headerEnd = -1; + } else { + break; + } + } + + return messages; + } +} diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts new file mode 100644 index 0000000..19f824b --- /dev/null +++ b/packages/lsp/src/index.ts @@ -0,0 +1,29 @@ +export type { + ClientDeps, + FileWatcher, + FileWatcherHandle, + FsAccess, + SpawnedProcess, + SpawnProcess, +} from "./client.js"; +export { LanguageServerClient } from "./client.js"; +export type { LspJsonConfig, OpencodeJsonConfig, ResolvedServer, ServerConfig } from "./config.js"; +export { resolveServers } from "./config.js"; +export type { + Diagnostic, + DocumentDiagnosticReport, + PublishDiagnosticsParams, +} from "./diagnostics.js"; +export { DiagnosticsStore } from "./diagnostics.js"; +export { extension, lspServiceHandle } from "./extension.js"; +export { encode, FrameDecoder } from "./framing.js"; +export { languageId } from "./language.js"; +export type { Logger, ManagerDeps } from "./manager.js"; +export { LspManager } from "./manager.js"; +export { findRoot } from "./root.js"; +export type { JsonRpcMessage, NotificationHandler, RequestHandler, WriteFn } from "./rpc.js"; +export { JsonRpcConnection } from "./rpc.js"; +export { createLspTool } from "./tool.js"; +export type { LspServerState, LspServerStatus, LspService } from "./types.js"; +export type { FileChangeTypeValue, FileSystemWatcher, Registration } from "./watched-files.js"; +export { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js"; diff --git a/packages/lsp/src/language.test.ts b/packages/lsp/src/language.test.ts new file mode 100644 index 0000000..3ec4b47 --- /dev/null +++ b/packages/lsp/src/language.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { languageId } from "./language.js"; + +describe("language", () => { + it("maps .ts to typescript", () => { + expect(languageId("file.ts")).toBe("typescript"); + }); + + it("maps .tsx to typescriptreact", () => { + expect(languageId("file.tsx")).toBe("typescriptreact"); + }); + + it("maps .js to javascript", () => { + expect(languageId("file.js")).toBe("javascript"); + }); + + it("maps .luau to luau", () => { + expect(languageId("file.luau")).toBe("luau"); + }); + + it("returns unknown for unrecognized extensions", () => { + expect(languageId("file.xyz")).toBe("unknown"); + }); + + it("returns unknown for files without extensions", () => { + expect(languageId("Makefile")).toBe("unknown"); + }); +}); diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts new file mode 100644 index 0000000..214294e --- /dev/null +++ b/packages/lsp/src/language.ts @@ -0,0 +1,36 @@ +/** + * Language ID mapping from file extensions. + */ + +const extensionMap: Record<string, string> = { + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".lua": "lua", + ".luau": "luau", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".md": "markdown", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".css": "css", + ".html": "html", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", +}; + +export function languageId(filePath: string): string { + const dotIdx = filePath.lastIndexOf("."); + if (dotIdx === -1) return "unknown"; + const ext = filePath.slice(dotIdx).toLowerCase(); + return extensionMap[ext] ?? "unknown"; +} diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts new file mode 100644 index 0000000..3e8e60e --- /dev/null +++ b/packages/lsp/src/manager.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import type { FileWatcher, FsAccess, SpawnedProcess, SpawnProcess } from "./client.js"; +import { encode } from "./framing.js"; +import { LspManager } from "./manager.js"; + +function makeAutoHandshakeSpawn(): SpawnProcess { + return () => { + let messageHandler: ((data: Uint8Array) => void) | null = null; + + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + // Parse the message to handle initialize handshake + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + // Send back initialize response + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 5); + } + // Ignore other messages + } catch { + // ignore parse errors + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 12345, + kill: () => {}, + }; + return proc; + }; +} + +function noopFileWatcher(): FileWatcher { + return () => ({ close: () => {} }); +} + +function fakeFs(files: Record<string, string> = {}): FsAccess { + return { + readText: async (path) => files[path] ?? "", + exists: async (path) => path in files, + }; +} + +describe("manager", () => { + it("status(cwd) lazy-spawns matching servers and reports connected", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp", "--stdio"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const statuses = await manager.status("/project"); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.id).toBe("test"); + expect(statuses[0]?.state).toBe("connected"); + expect(statuses[0]?.root).toBe("/project"); + }, 10000); + + it("concurrent status for the same root spawns one process", async () => { + let spawnCount = 0; + const countingSpawn: SpawnProcess = () => { + spawnCount++; + return makeAutoHandshakeSpawn()([], { cwd: "/project" }); + }; + + const manager = new LspManager({ + spawn: countingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); + + expect(s1).toHaveLength(1); + expect(s2).toHaveLength(1); + expect(spawnCount).toBe(1); + }, 10000); + + it("a failing spawn reports state:error and is not retried", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + + // Second call should not retry + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + }); + + it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { + let killed = false; + const trackableSpawn: SpawnProcess = () => { + const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); + return { + ...proc, + kill: () => { + killed = true; + }, + }; + }; + + const manager = new LspManager({ + spawn: trackableSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + await manager.status("/project"); + manager.shutdownAll(); + expect(killed).toBe(true); + }, 10000); + + it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/proj-a/.dispatch/lsp.json": JSON.stringify({ + servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, + }), + "/proj-b/opencode.json": JSON.stringify({ + lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, + }), + }), + }); + + const a = await manager.status("/proj-a"); + const b = await manager.status("/proj-b"); + expect(a.map((s) => s.id)).toEqual(["a"]); + expect(b.map((s) => s.id)).toEqual(["b"]); + }, 10000); +}); diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts new file mode 100644 index 0000000..7080c65 --- /dev/null +++ b/packages/lsp/src/manager.ts @@ -0,0 +1,215 @@ +/** + * Manager — lazy-spawn one client per (serverID, root); dedup concurrent + * spawns; track a broken set (no retry storm); status(cwd); shutdownAll(). + */ + +import { join } from "node:path"; +import { + type ClientDeps, + type FileWatcher, + type FsAccess, + LanguageServerClient, + type SpawnProcess, +} from "./client.js"; +import { type ResolvedServer, resolveServers } from "./config.js"; +import { findRoot } from "./root.js"; +import type { LspServerState, LspServerStatus } from "./types.js"; + +export type Logger = { + readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; + readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; + readonly error: (msg: string, attrs?: Record<string, unknown>) => void; +}; + +export interface ManagerDeps { + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly logger?: Logger | undefined; +} + +type ClientEntry = { + readonly client: LanguageServerClient; + readonly server: ResolvedServer; + readonly promise: Promise<void>; +}; + +export class LspManager { + private clients = new Map<string, ClientEntry>(); + private broken = new Set<string>(); + private spawning = new Map<string, Promise<void>>(); + private deps: ManagerDeps; + + constructor(deps: ManagerDeps) { + this.deps = deps; + } + + async status(cwd: string): Promise<readonly LspServerStatus[]> { + // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox + // project) gets its own .dispatch/lsp.json or opencode.json, not a global one. + const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json")); + const opencodeJson = await this.readOrNull(join(cwd, "opencode.json")); + const servers = await resolveServers({ + cwd, + dispatchLspJson, + opencodeJson, + exists: this.deps.fs.exists, + }); + + const results: LspServerStatus[] = []; + + for (const server of servers) { + const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists); + const key = `${server.id}:${root}`; + + if (this.broken.has(key)) { + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: "Previously failed to start", + }; + results.push(status); + continue; + } + + const existing = this.clients.get(key); + if (existing) { + const state = existing.client.getState(); + const stateError = existing.client.getStateError(); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + }; + if (stateError !== undefined) { + (status as { error?: string }).error = stateError; + } + results.push(status); + continue; + } + + try { + await this.spawnClient(server, root, key); + const entry = this.clients.get(key); + if (entry) { + const state = entry.client.getState(); + const stateError = entry.client.getStateError(); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + }; + if (stateError !== undefined) { + (status as { error?: string }).error = stateError; + } + results.push(status); + } + } catch (err: unknown) { + this.broken.add(key); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: err instanceof Error ? err.message : String(err), + }; + results.push(status); + } + } + + return results; + } + + getClient(serverId: string, root: string): LanguageServerClient | undefined { + const key = `${serverId}:${root}`; + return this.clients.get(key)?.client; + } + + /** Read a config file's contents, or null if it is absent/unreadable. */ + private async readOrNull(path: string): Promise<string | null> { + if (!(await this.deps.fs.exists(path))) return null; + try { + return await this.deps.fs.readText(path); + } catch { + return null; + } + } + + private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> { + const existingSpawn = this.spawning.get(key); + if (existingSpawn) return existingSpawn; + + const spawnPromise = this.doSpawn(server, root, key); + this.spawning.set(key, spawnPromise); + + try { + await spawnPromise; + } finally { + this.spawning.delete(key); + } + } + + private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> { + const clientDeps: ClientDeps = { + spawn: this.deps.spawn, + fileWatcher: this.deps.fileWatcher, + fs: this.deps.fs, + command: server.command, + root, + serverId: server.id, + }; + if (server.env) { + (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env; + } + if (server.initialization) { + (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization = + server.initialization; + } + + const client = new LanguageServerClient(clientDeps); + const entry: ClientEntry = { + client, + server, + promise: client.start(), + }; + + this.clients.set(key, entry); + await entry.promise; + + if (client.getState() === "error") { + this.broken.add(key); + this.deps.logger?.warn("LSP server failed to start", { + serverId: server.id, + root, + error: client.getStateError() ?? "unknown", + }); + } else { + this.deps.logger?.info("LSP server connected", { + serverId: server.id, + root, + }); + } + } + + shutdownAll(): void { + for (const [key, entry] of this.clients) { + entry.client.shutdown(); + this.deps.logger?.info("LSP server shutdown", { key }); + } + this.clients.clear(); + this.broken.clear(); + this.spawning.clear(); + } +} + +function mapState(state: LspServerState): LspServerState { + return state; +} diff --git a/packages/lsp/src/root.test.ts b/packages/lsp/src/root.test.ts new file mode 100644 index 0000000..ffe2e31 --- /dev/null +++ b/packages/lsp/src/root.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { findRoot } from "./root.js"; + +describe("root", () => { + it("findRoot returns nearest marker ancestor bounded by cwd", async () => { + const existingFiles = new Set([ + "/project/src/components/tsconfig.json", + "/project/tsconfig.json", + ]); + + const result = await findRoot( + "/project/src/components/widgets", + "/project", + ["tsconfig.json"], + async (path) => existingFiles.has(path), + ); + + expect(result).toBe("/project/src/components"); + }); + + it("falls back to cwd", async () => { + const result = await findRoot( + "/project/src/deep/nested", + "/project", + ["tsconfig.json"], + async () => false, + ); + + expect(result).toBe("/project"); + }); + + it("finds marker at start directory", async () => { + const existingFiles = new Set(["/project/tsconfig.json"]); + + const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); + + it("respects cwd boundary", async () => { + const existingFiles = new Set(["/tsconfig.json"]); + + const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); +}); diff --git a/packages/lsp/src/root.ts b/packages/lsp/src/root.ts new file mode 100644 index 0000000..fc7814e --- /dev/null +++ b/packages/lsp/src/root.ts @@ -0,0 +1,44 @@ +/** + * Root finder — nearest ancestor containing a marker file, bounded at cwd. + */ + +export async function findRoot( + startDir: string, + cwd: string, + markers: readonly string[], + exists: (path: string) => Promise<boolean>, +): Promise<string> { + const normalizedStart = normalizePath(startDir); + const normalizedCwd = normalizePath(cwd); + + let current = normalizedStart; + while (true) { + for (const marker of markers) { + const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`; + if (await exists(markerPath)) { + return current; + } + } + if (current === normalizedCwd || current === "/") { + return normalizedCwd; + } + const parent = getParent(current); + if (parent === current) return normalizedCwd; + current = parent; + } +} + +function normalizePath(p: string): string { + let normalized = p.replace(/\\/g, "/"); + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized || "/"; +} + +function getParent(p: string): string { + if (p === "/") return "/"; + const lastSlash = p.lastIndexOf("/"); + if (lastSlash <= 0) return "/"; + return p.slice(0, lastSlash) || "/"; +} diff --git a/packages/lsp/src/rpc.test.ts b/packages/lsp/src/rpc.test.ts new file mode 100644 index 0000000..a03870f --- /dev/null +++ b/packages/lsp/src/rpc.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { JsonRpcConnection } from "./rpc.js"; + +function makeConnection(): { conn: JsonRpcConnection; messages: string[] } { + const messages: string[] = []; + const conn = new JsonRpcConnection((bytes) => { + const decoded = new TextDecoder().decode(bytes); + // Extract JSON from the LSP-framed message + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd !== -1) { + messages.push(decoded.slice(headerEnd + 4)); + } + }); + return { conn, messages }; +} + +function frameResponse(id: number, result: unknown): string { + return JSON.stringify({ jsonrpc: "2.0", id, result }); +} + +describe("rpc", () => { + it("sendRequest resolves by matching id", async () => { + const { conn, messages } = makeConnection(); + + const promise = conn.sendRequest("test/method", { key: "value" }); + expect(messages).toHaveLength(1); + + const rawSent = messages[0]; + if (rawSent === undefined) throw new Error("expected a sent message"); + const sent = JSON.parse(rawSent); + expect(sent.method).toBe("test/method"); + expect(sent.params).toEqual({ key: "value" }); + expect(sent.id).toBe(1); + + conn.handleMessage(frameResponse(1, { ok: true })); + const result = await promise; + expect(result).toEqual({ ok: true }); + }); + + it("onNotification dispatches by method", () => { + const { conn } = makeConnection(); + let received: unknown = null; + conn.onNotification("test/notify", (params) => { + received = params; + }); + + conn.handleMessage( + JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }), + ); + expect(received).toEqual({ data: 42 }); + }); + + it("onRequest replies to a server-to-client request", async () => { + const { conn, messages } = makeConnection(); + + conn.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + return items.map(() => ({ setting: true })); + }); + + await conn.handleMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "workspace/configuration", + params: { items: [{ section: "test" }] }, + }), + ); + + // The response should be sent back + expect(messages).toHaveLength(1); + const rawResponse = messages[0]; + if (rawResponse === undefined) throw new Error("expected a response message"); + const response = JSON.parse(rawResponse); + expect(response.id).toBe(100); + expect(response.result).toEqual([{ setting: true }]); + }); +}); diff --git a/packages/lsp/src/rpc.ts b/packages/lsp/src/rpc.ts new file mode 100644 index 0000000..45adf42 --- /dev/null +++ b/packages/lsp/src/rpc.ts @@ -0,0 +1,132 @@ +/** + * JSON-RPC connection over an injected write function. + * + * Provides sendRequest (correlated by id), sendNotification, onRequest, + * and onNotification. The caller feeds decoded JSON messages via `handleMessage`. + */ + +import { encode } from "./framing.js"; + +export type WriteFn = (bytes: Uint8Array) => void; + +export interface PendingRequest { + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; +} + +export type RequestHandler = (params: unknown) => unknown | Promise<unknown>; +export type NotificationHandler = (params: unknown) => void; + +export interface JsonRpcMessage { + readonly jsonrpc: "2.0"; + readonly id?: number | string | undefined; + readonly method?: string | undefined; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: + | { readonly code: number; readonly message: string; readonly data?: unknown } + | undefined; +} + +export class JsonRpcConnection { + private nextId = 1; + private pending = new Map<number | string, PendingRequest>(); + private requestHandlers = new Map<string, RequestHandler>(); + private notificationHandlers = new Map<string, NotificationHandler>(); + private write: WriteFn; + + constructor(write: WriteFn) { + this.write = write; + } + + sendRequest(method: string, params?: unknown): Promise<unknown> { + const id = this.nextId++; + const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.sendMessage(msg); + }); + } + + sendNotification(method: string, params?: unknown): void { + const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; + this.sendMessage(msg); + } + + onRequest(method: string, handler: RequestHandler): void { + this.requestHandlers.set(method, handler); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + async handleMessage(json: string): Promise<void> { + const msg = JSON.parse(json) as JsonRpcMessage; + const { id, method } = msg; + + if (id !== undefined && method !== undefined) { + await this.handleIncomingRequest(id, method, msg.params); + } else if (method !== undefined) { + this.handleIncomingNotification(method, msg.params); + } else if (id !== undefined) { + this.handleResponse(id, msg); + } + } + + private sendMessage(msg: JsonRpcMessage): void { + this.write(encode(JSON.stringify(msg))); + } + + private handleResponse(id: number | string, msg: JsonRpcMessage): void { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (msg.error) { + entry.reject(new Error(msg.error.message)); + } else { + entry.resolve(msg.result); + } + } + + private async handleIncomingRequest( + id: number | string, + method: string, + params: unknown, + ): Promise<void> { + const handler = this.requestHandlers.get(method); + if (!handler) { + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + return; + } + try { + const result = await handler(params); + this.sendMessage({ jsonrpc: "2.0", id, result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32603, message }, + }); + } + } + + private handleIncomingNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (handler) { + handler(params); + } + } + + dispose(): void { + for (const entry of this.pending.values()) { + entry.reject(new Error("Connection closed")); + } + this.pending.clear(); + } +} diff --git a/packages/lsp/src/tool.test.ts b/packages/lsp/src/tool.test.ts new file mode 100644 index 0000000..03787ae --- /dev/null +++ b/packages/lsp/src/tool.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import type { LspManager } from "./manager.js"; +import { createLspTool } from "./tool.js"; + +function stubManager(overrides?: Partial<LspManager>): LspManager { + return { + status: async () => [], + getClient: () => undefined, + shutdownAll: () => {}, + ...overrides, + } as unknown as LspManager; +} + +describe("tool", () => { + it("diagnostics formats errors", async () => { + const tool = createLspTool(stubManager()); + const result = await tool.execute( + { operation: "diagnostics", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + expect(result.isError).toBe(true); + expect(result.content).toContain("No language server configured"); + }); + + it("position ops convert 1-based to 0-based", async () => { + let receivedPosition: { line: number; character: number } | null = null; + + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedPosition = (params as { position: { line: number; character: number } }).position; + return { contents: { value: "hover result" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; + + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); + + const result = await tool.execute( + { operation: "hover", path: "test.ts", line: 5, character: 10 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + + expect(receivedPosition).toEqual({ line: 4, character: 9 }); + expect(result.content).toBe("hover result"); + }); + + it("path resolved against ctx.cwd", async () => { + let receivedUri: string | null = null; + + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri; + return { contents: { value: "ok" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; + + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); + + await tool.execute( + { operation: "hover", path: "src/test.ts", line: 1, character: 1 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + + expect(receivedUri).toBe("file:///project/src/test.ts"); + }); + + it("position op without line/character returns isError", async () => { + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + }), + ); + + const result = await tool.execute( + { operation: "hover", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("requires both"); + }); +}); diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts new file mode 100644 index 0000000..bc4b41f --- /dev/null +++ b/packages/lsp/src/tool.ts @@ -0,0 +1,225 @@ +/** + * The lsp tool — model-facing tool contract. + * + * Operations: diagnostics, hover, definition, references, documentSymbol. + */ + +import { resolve } from "node:path"; +import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; +import type { LspManager } from "./manager.js"; + +type Operation = "diagnostics" | "hover" | "definition" | "references" | "documentSymbol"; + +const POSITION_OPS: ReadonlySet<string> = new Set(["hover", "definition", "references"]); + +interface ValidatedArgs { + readonly operation: Operation; + readonly path: string; + readonly line?: number | undefined; + readonly character?: number | undefined; +} + +function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs { + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record<string, unknown>; + + const rawOp = obj.operation; + if (typeof rawOp !== "string") { + return { error: 'Error: Missing "operation" parameter (must be a string).' }; + } + const validOps: ReadonlySet<string> = new Set([ + "diagnostics", + "hover", + "definition", + "references", + "documentSymbol", + ]); + if (!validOps.has(rawOp)) { + return { + error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`, + }; + } + const operation = rawOp as Operation; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.trim().length === 0) { + return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' }; + } + + let line: number | undefined; + let character: number | undefined; + + if (obj.line !== undefined) { + const n = Number(obj.line); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' }; + } + line = Math.floor(n); + } + + if (obj.character !== undefined) { + const n = Number(obj.character); + if (!Number.isFinite(n) || n < 1) { + return { + error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).', + }; + } + character = Math.floor(n); + } + + const result: ValidatedArgs = { operation, path: rawPath }; + if (line !== undefined) { + (result as { line?: number }).line = line; + } + if (character !== undefined) { + (result as { character?: number }).character = character; + } + return result; +} + +function resolveFilePath(filePath: string, cwd: string): string { + const resolved = resolve(cwd, filePath); + const normalizedCwd = resolve(cwd); + if (!resolved.startsWith(normalizedCwd)) { + return normalizedCwd; + } + return resolved; +} + +/** Convert validated 1-based line/character to an LSP 0-based position. */ +function toPosition( + line: number | undefined, + character: number | undefined, +): { readonly line: number; readonly character: number } { + if (line === undefined || character === undefined) { + throw new Error("Position operations require both line and character."); + } + return { line: line - 1, character: character - 1 }; +} + +export function createLspTool(manager: LspManager): ToolContract { + return { + name: "lsp", + description: + "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.", + parameters: { + type: "object", + properties: { + operation: { + type: "string", + enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"], + description: "The LSP operation to perform.", + }, + path: { + type: "string", + description: "File path relative to the workspace.", + }, + line: { + type: "number", + description: "Line number (1-based). Required for hover, definition, references.", + }, + character: { + type: "number", + description: "Character position (1-based). Required for hover, definition, references.", + }, + }, + required: ["operation", "path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { operation, path: filePath, line, character } = validated; + const cwd = ctx.cwd ?? process.cwd(); + const absolutePath = resolveFilePath(filePath, cwd); + + if (POSITION_OPS.has(operation)) { + if (line === undefined || character === undefined) { + return { + content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`, + isError: true, + }; + } + } + + try { + const statuses = await manager.status(cwd); + if (statuses.length === 0) { + return { content: "No language server configured for this workspace.", isError: true }; + } + + const connected = statuses.find((s) => s.state === "connected"); + if (!connected) { + const first = statuses[0]; + const detail = first + ? `"${first.name}" is not connected (state: ${first.state})` + : "is not connected"; + return { + content: `Language server ${detail}.`, + isError: true, + }; + } + + // Find the client for this server + const client = manager.getClient(connected.id, connected.root); + if (!client) { + return { content: "Language server client not available.", isError: true }; + } + + switch (operation) { + case "diagnostics": { + const diags = await client.waitForDiagnostics(absolutePath); + return { content: diags || "No diagnostics found." }; + } + case "hover": { + const result = await client.request("textDocument/hover", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No hover information available." }; + const hover = result as { readonly contents?: { readonly value?: string } | string }; + const content = + typeof hover.contents === "string" + ? hover.contents + : (hover.contents?.value ?? "No hover information available."); + return { content }; + } + case "definition": { + const result = await client.request("textDocument/definition", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No definition found." }; + return { content: JSON.stringify(result) }; + } + case "references": { + const result = await client.request("textDocument/references", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + context: { includeDeclaration: true }, + }); + if (!result) return { content: "No references found." }; + return { content: JSON.stringify(result) }; + } + case "documentSymbol": { + const result = await client.request("textDocument/documentSymbol", { + textDocument: { uri: `file://${absolutePath}` }, + }); + if (!result) return { content: "No symbols found." }; + return { content: JSON.stringify(result) }; + } + } + } catch (err: unknown) { + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; +} diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts new file mode 100644 index 0000000..f89f84d --- /dev/null +++ b/packages/lsp/src/types.ts @@ -0,0 +1,23 @@ +/** + * Shared types for the LSP extension. + */ + +export type LspServerState = "connected" | "starting" | "error" | "not-started"; + +export interface LspServerStatus { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: LspServerState; + readonly error?: string | undefined; +} + +export interface LspService { + /** + * Resolve the language servers configured for `cwd`, ensure each is spawned + + * initialized (lazy connect), and report live state. Never throws for a single + * server's failure — reflect it as state:"error" with a short `error`. + */ + status(cwd: string): Promise<readonly LspServerStatus[]>; +} diff --git a/packages/lsp/src/watched-files.test.ts b/packages/lsp/src/watched-files.test.ts new file mode 100644 index 0000000..4e598e1 --- /dev/null +++ b/packages/lsp/src/watched-files.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js"; + +describe("watched-files", () => { + it("register stores workspace/didChangeWatchedFiles watchers", () => { + const registry = new WatchedFilesRegistry(); + + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }], + }, + }); + + const watchers = registry.getAllWatchers(); + expect(watchers).toHaveLength(2); + expect(watchers[0]?.globPattern).toBe("**/*.luau"); + expect(watchers[1]?.globPattern).toBe("sourcemap.json"); + }); + + it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => { + const registry = new WatchedFilesRegistry(); + + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); + + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/nested/deep/file.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); + + expect(FileChangeType.Created).toBe(1); + expect(FileChangeType.Changed).toBe(2); + expect(FileChangeType.Deleted).toBe(3); + }); + + it("unregisterCapability stops forwarding", () => { + const registry = new WatchedFilesRegistry(); + + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); + + expect(registry.matches("src/main.luau")).toBe(true); + + registry.applyUnregister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + }); + + expect(registry.matches("src/main.luau")).toBe(false); + expect(registry.getAllWatchers()).toHaveLength(0); + }); + + it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => { + // **/*.luau + expect(globMatch("**/*.luau", "src/main.luau")).toBe(true); + expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true); + expect(globMatch("**/*.luau", "file.luau")).toBe(true); + expect(globMatch("**/*.luau", "src/main.ts")).toBe(false); + + // sourcemap.json (literal) + expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("sourcemap.json", "other.json")).toBe(false); + + // **/sourcemap.json + expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "other.json")).toBe(false); + }); +}); diff --git a/packages/lsp/src/watched-files.ts b/packages/lsp/src/watched-files.ts new file mode 100644 index 0000000..e23df89 --- /dev/null +++ b/packages/lsp/src/watched-files.ts @@ -0,0 +1,110 @@ +/** + * Watched-files registration state machine + glob matcher. + * + * Manages `workspace/didChangeWatchedFiles` registrations from the server. + * `applyRegister` stores watchers, `applyUnregister` removes them, and + * `matches(path)` tests a file path against all registered glob patterns. + * + * Glob matching supports double-star (any path segments), star (within a segment), + * and literals. + */ + +export interface FileSystemWatcher { + readonly globPattern: string; + readonly kind?: number; +} + +export interface DidChangeWatchedFilesRegistrationOptions { + readonly watchers: readonly FileSystemWatcher[]; +} + +export interface Registration { + readonly id: string; + readonly method: string; + readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions; +} + +export const FileChangeType = { + Created: 1, + Changed: 2, + Deleted: 3, +} as const; + +export type FileChangeTypeValue = (typeof FileChangeType)[keyof typeof FileChangeType]; + +export class WatchedFilesRegistry { + private watchers = new Map<string, readonly FileSystemWatcher[]>(); + + applyRegister(registration: Registration): void { + if (registration.method !== "workspace/didChangeWatchedFiles") return; + const opts = registration.registerOptions; + if (!opts?.watchers) return; + this.watchers.set(registration.id, opts.watchers); + } + + applyUnregister(unregistration: { readonly id: string; readonly method: string }): void { + if (unregistration.method !== "workspace/didChangeWatchedFiles") return; + this.watchers.delete(unregistration.id); + } + + matches(filePath: string): boolean { + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + if (globMatch(w.globPattern, filePath)) return true; + } + } + return false; + } + + getAllWatchers(): readonly FileSystemWatcher[] { + const result: FileSystemWatcher[] = []; + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + result.push(w); + } + } + return result; + } +} + +export function globMatch(pattern: string, filePath: string): boolean { + const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/"); + const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/"); + const regex = globToRegex(normalizedPattern); + return regex.test(normalizedPath); +} + +function globToRegex(glob: string): RegExp { + let regex = "^"; + let i = 0; + while (i < glob.length) { + const ch = glob[i] ?? ""; + if (ch === "*" && glob[i + 1] === "*") { + if (glob[i + 2] === "/") { + regex += "(?:.+/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } + } else if (ch === "*") { + regex += "[^/]*"; + i++; + } else if (ch === "?") { + regex += "[^/]"; + i++; + } else if (ch === ".") { + regex += "\\."; + i++; + } else { + regex += escapeRegex(ch); + i++; + } + } + regex += "$"; + return new RegExp(regex); +} + +function escapeRegex(ch: string): string { + return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/lsp/tsconfig.json b/packages/lsp/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/lsp/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index ba4912a..33deb15 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -3,8 +3,10 @@ import type { AgentEvent, ChatMessage, EventHookDescriptor, + Logger, ProviderContract, ProviderEvent, + ProviderStreamOptions, RunTurnInput, RunTurnResult, StoredChunk, @@ -24,12 +26,15 @@ import type { ToolAssembly } from "./tools-filter.js"; function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; readonly metricsData: Map<string, TurnMetrics[]>; + readonly cwdData: Map<string, string>; } { const data = new Map<string, ChatMessage[]>(); const metricsData = new Map<string, TurnMetrics[]>(); + const cwdData = new Map<string, string>(); return { data, metricsData, + cwdData, async append(conversationId, messages) { const existing = data.get(conversationId) ?? []; data.set(conversationId, [...existing, ...messages]); @@ -58,6 +63,12 @@ function createInMemoryStore(): ConversationStore & { async loadMetrics(conversationId) { return [...(metricsData.get(conversationId) ?? [])]; }, + async getCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdData.set(conversationId, cwd); + }, }; } @@ -518,6 +529,12 @@ describe("turn-sealed event", () => { async loadMetrics(conversationId) { return store.loadMetrics(conversationId); }, + async getCwd(conversationId) { + return store.getCwd(conversationId); + }, + async setCwd(conversationId, cwd) { + await store.setCwd(conversationId, cwd); + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -565,6 +582,10 @@ describe("turn-sealed event", () => { async loadMetrics() { return []; }, + async getCwd() { + return null; + }, + async setCwd() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -839,6 +860,10 @@ describe("turn metrics persistence", () => { async loadMetrics() { return []; }, + async getCwd() { + return null; + }, + async setCwd() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -1087,6 +1112,105 @@ describe("warm service", () => { } }); + it("warm forwards a `warm`-flagged logger so the send is captured as a span", async () => { + const store = createInMemoryStore(); + await store.append("conv-warm-log", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + let capturedOpts: ProviderStreamOptions | undefined; + const provider: ProviderContract = { + id: "p", + stream(_messages, _tools, opts) { + capturedOpts = opts; + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + })(); + }, + }; + + // Minimal Logger stub recording the child() correlation it was asked for. + let childArg: (Partial<{ conversationId: string }> & { attrs?: unknown }) | undefined; + const warmChild = { __warmChild: true } as unknown as Logger; + const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + span() { + throw new Error("warm should not open spans directly"); + }, + child(ctx: { conversationId?: string; attrs?: unknown }) { + childArg = ctx; + return warmChild; + }, + } as unknown as Logger; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + logger, + }; + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-log"); + + // The warm send must carry the logger so the provider opens a provider.request span. + expect(capturedOpts?.logger).toBe(warmChild); + // …and it must be flagged warm + correlated to the conversation, so it can be + // diffed against the real turn's request (the 0%-cache debugging workflow). + expect(childArg).toMatchObject({ + conversationId: "conv-warm-log", + attrs: { warm: true }, + }); + }); + + it("warm falls back to the conversation's stored cwd for tool assembly", async () => { + // A cwd-sensitive tools filter (e.g. skill discovery) must see the SAME cwd + // the real turn used, or the tools block diverges and the prompt cache misses. + // A manual reheat sends no cwd, so the warm must fall back to the stored cwd. + const store = createInMemoryStore(); + await store.append("conv-warm-cwd", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store.setCwd("conv-warm-cwd", "/home/tradam/projects/roblox"); + + let assemblyCwd: string | undefined = "UNSET"; + const provider: ProviderContract = { + id: "p", + stream() { + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + })(); + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: (assembly: ToolAssembly) => { + assemblyCwd = assembly.cwd; + return Promise.resolve(assembly); + }, + runTurn, + emit: () => {}, + }; + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + // No cwd in opts (the reheat case) → must use the stored cwd. + await warmService.warm("conv-warm-cwd"); + expect(assemblyCwd).toBe("/home/tradam/projects/roblox"); + }); + it("warm refuses while the conversation is generating", async () => { const store = createInMemoryStore(); let resolveRunTurn: (() => void) | undefined; @@ -1324,3 +1448,123 @@ describe("warm service", () => { expect(warmEmits).toHaveLength(0); }); }); + +describe("cwd persistence", () => { + it("uses the persisted cwd when the request omits cwd", async () => { + const store = createInMemoryStore(); + await store.setCwd("conv-persisted", "/persisted/dir"); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persisted", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/persisted/dir"); + }); + + it("persists the cwd when the request provides one (and a later cwd-less turn reuses it)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-new", + text: "first", + onEvent: () => {}, + cwd: "/new/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/new/dir"); + expect(store.cwdData.get("conv-persist-new")).toBe("/new/dir"); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-new", + text: "second", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBe("/new/dir"); + }); + + it("an explicit request cwd overrides the persisted cwd (and updates it)", async () => { + const store = createInMemoryStore(); + await store.setCwd("conv-override", "/old/dir"); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-override", + text: "override", + onEvent: () => {}, + cwd: "/new/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/new/dir"); + expect(store.cwdData.get("conv-override")).toBe("/new/dir"); + + await orchestrator.handleMessage({ + conversationId: "conv-override", + text: "reused", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBe("/new/dir"); + }); + + it("no cwd is threaded when neither request nor store has one", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-cwd-either", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBeUndefined(); + }); +}); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 6df92c8..e86729c 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -117,15 +117,25 @@ export function createSessionOrchestrator( const orchestrator: SessionOrchestrator = { async handleMessage({ conversationId, text, onEvent, signal, modelName, cwd }) { + activeConversations.add(conversationId); + + const effectiveCwd = + cwd !== undefined + ? cwd + : ((await deps.conversationStore.getCwd(conversationId)) ?? undefined); + const payload: TurnLifecyclePayload = { conversationId, - ...(cwd !== undefined ? { cwd } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(modelName !== undefined ? { modelName } : {}), }; deps.emit?.(turnStarted, payload); - activeConversations.add(conversationId); try { + if (cwd !== undefined) { + await deps.conversationStore.setCwd(conversationId, cwd); + } + const history = await deps.conversationStore.load(conversationId); const userMsg = buildUserMessage(text); const turnId = generateTurnId(); @@ -154,7 +164,7 @@ export function createSessionOrchestrator( const assembled = await deps.applyToolsFilter({ tools: baseTools, conversationId, - ...(cwd !== undefined ? { cwd } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); @@ -178,7 +188,7 @@ export function createSessionOrchestrator( : {}), ...(turnLogger !== undefined ? { logger: turnLogger } : {}), ...(signal !== undefined ? { signal } : {}), - ...(cwd !== undefined ? { cwd } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(deps.now !== undefined ? { now: deps.now } : {}), }; @@ -231,7 +241,14 @@ export function createWarmService( } const baseTools = deps.resolveTools(); - const cwd = opts?.cwd; + // Resolve cwd the SAME way handleMessage does (caller value → stored cwd). + // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the + // `load_skill` description per-cwd). If the warm assembles tools under a + // different cwd than the real turn, the tools block — the FIRST bytes of + // the prompt-cache prefix — diverges and the cache misses entirely (0%). + // A manual reheat sends no cwd, so without this fallback it would warm the + // wrong prefix. See notes/observability-design.md §3.1. + const cwd = opts?.cwd ?? (await deps.conversationStore.getCwd(conversationId)) ?? undefined; const assembled = await deps.applyToolsFilter({ tools: baseTools, conversationId, @@ -244,8 +261,18 @@ export function createWarmService( }; const messages = [...history, probeMsg]; - const providerOpts: ProviderStreamOptions | undefined = - modelOverride !== undefined ? { model: modelOverride, maxTokens: 1 } : { maxTokens: 1 }; + // Capture the warm send as a `provider.request` span, flagged `warm: true` + // so it can be diffed against the corresponding real turn's request (the + // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md + // §3.1). Without this the warm body is invisible and the cache bust is + // undebuggable. The child-bound `warm` attribute flows into the span the + // provider opens (kernel logger merges child attrs into span attributes). + const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); + const providerOpts: ProviderStreamOptions = { + maxTokens: 1, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(warmLogger !== undefined ? { logger: warmLogger } : {}), + }; let inputTokens = 0; let outputTokens = 0; diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 9b6e9b3..7ebd2c2 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts new file mode 100644 index 0000000..da7e1e0 --- /dev/null +++ b/packages/transport-contract/src/contract.types.test.ts @@ -0,0 +1,118 @@ +/** + * Compile-time assertions for transport-contract types. + * + * These are never executed — they exist solely to prove the exported types + * compile with conforming literals. If a shape changes, this file fails to + * typecheck. + */ + +import { describe, expect, it } from "vitest"; +import type { + CwdResponse, + LspServerInfo, + LspServerState, + LspStatusResponse, + SetCwdRequest, +} from "./index.js"; + +// ─── CwdResponse ───────────────────────────────────────────────────────────── + +const _cwdNull: CwdResponse = { + conversationId: "conv-1", + cwd: null, +}; + +const _cwdSet: CwdResponse = { + conversationId: "conv-2", + cwd: "/home/user/project", +}; + +// ─── SetCwdRequest ─────────────────────────────────────────────────────────── + +const _setCwd: SetCwdRequest = { + cwd: "/tmp/workspace", +}; + +// ─── LspServerState ────────────────────────────────────────────────────────── + +const _stateConnected: LspServerState = "connected"; +const _stateStarting: LspServerState = "starting"; +const _stateError: LspServerState = "error"; +const _stateNotStarted: LspServerState = "not-started"; + +// ─── LspServerInfo ─────────────────────────────────────────────────────────── + +const _serverOk: LspServerInfo = { + id: "typescript", + name: "TypeScript Language Server", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected", +}; + +const _serverErr: LspServerInfo = { + id: "luau-lsp", + name: "Luau LSP", + root: "/home/user/game", + extensions: [".luau"], + state: "error", + error: "Failed to start: binary not found", +}; + +// ─── LspStatusResponse ─────────────────────────────────────────────────────── + +const _lspNoCwd: LspStatusResponse = { + conversationId: "conv-3", + cwd: null, + servers: [], +}; + +const _lspWithServers: LspStatusResponse = { + conversationId: "conv-4", + cwd: "/home/user/project", + servers: [_serverOk, _serverErr], +}; + +// ─── Runtime smoke (vitest needs a suite) ──────────────────────────────────── + +describe("transport-contract types compile and are exported", () => { + it("CwdResponse: null cwd round-trips", () => { + expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null }); + }); + + it("CwdResponse: set cwd round-trips", () => { + expect(_cwdSet.cwd).toBe("/home/user/project"); + }); + + it("SetCwdRequest: carries cwd", () => { + expect(_setCwd.cwd).toBe("/tmp/workspace"); + }); + + it("LspServerState: all four variants are valid", () => { + const states: LspServerState[] = [ + _stateConnected, + _stateStarting, + _stateError, + _stateNotStarted, + ]; + expect(states).toHaveLength(4); + }); + + it("LspServerInfo: ok server has no error field", () => { + expect(_serverOk.state).toBe("connected"); + expect(_serverOk.error).toBeUndefined(); + }); + + it("LspServerInfo: error server carries error message", () => { + expect(_serverErr.state).toBe("error"); + expect(_serverErr.error).toBe("Failed to start: binary not found"); + }); + + it("LspStatusResponse: empty servers when cwd is null", () => { + expect(_lspNoCwd.servers).toEqual([]); + }); + + it("LspStatusResponse: populated servers when cwd is set", () => { + expect(_lspWithServers.servers).toHaveLength(2); + }); +}); diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 95111ae..f0e50f8 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -154,6 +154,49 @@ export interface ThroughputResponse { readonly models: readonly ThroughputModelStat[]; } +// ─── Per-conversation working directory (cwd) ───────────────────────────────── + +/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ +export interface CwdResponse { + readonly conversationId: string; + readonly cwd: string | null; +} + +/** Body of `PUT /conversations/:id/cwd`. */ +export interface SetCwdRequest { + readonly cwd: string; +} + +// ─── Per-conversation LSP status ────────────────────────────────────────────── + +/** The connection state of a single language server for a workspace. */ +export type LspServerState = "connected" | "starting" | "error" | "not-started"; + +/** One language server's status as reported to the frontend. */ +export interface LspServerInfo { + /** Stable server id, e.g. "typescript", "luau-lsp". */ + readonly id: string; + /** Human-readable display name. */ + readonly name: string; + /** The resolved workspace root the server is (or would be) rooted at (absolute). */ + readonly root: string; + /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ + readonly extensions: readonly string[]; + /** Current connection state. */ + readonly state: LspServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; +} + +/** Response of `GET /conversations/:id/lsp`. */ +export interface LspStatusResponse { + readonly conversationId: string; + /** The conversation's persisted cwd, or null if unset (then `servers` is empty). */ + readonly cwd: string | null; + /** The language servers configured for `cwd` and their live state. */ + readonly servers: readonly LspServerInfo[]; +} + /** * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING * request for a conversation (e.g. a frontend "warm now" button, or fast tests diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index d439fe4..2a3acdd 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -9,6 +9,7 @@ "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", + "@dispatch/lsp": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-contract": "workspace:*", diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 22b26fc..07f6777 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -13,6 +13,7 @@ import { createApp } from "./app.js"; import type { ConversationStore, CredentialStore, + LspService, SessionOrchestrator, WarmService, } from "./seam.js"; @@ -78,6 +79,7 @@ function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[] function createFakeConversationStore( store: Map<string, StoredChunk[]> = new Map(), metricsStore: Map<string, TurnMetrics[]> = new Map(), + cwdStore: Map<string, string> = new Map(), ): ConversationStore { return { async append() {}, @@ -93,6 +95,12 @@ function createFakeConversationStore( async loadMetrics(conversationId) { return metricsStore.get(conversationId) ?? []; }, + async getCwd(conversationId) { + return cwdStore.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdStore.set(conversationId, cwd); + }, }; } @@ -169,6 +177,23 @@ function createFakeWarmService( }; } +function createFakeLspService( + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: "connected" | "starting" | "error" | "not-started"; + readonly error?: string; + }[] = [], +): LspService { + return { + async status() { + return statuses; + }, + }; +} + const noopLogger = createFakeLogger(); describe("GET /health", () => { @@ -752,6 +777,10 @@ describe("GET /conversations/:id/metrics", () => { async loadMetrics() { throw new Error("storage exploded"); }, + async getCwd() { + return null; + }, + async setCwd() {}, }; const app = createApp({ conversationStore: brokenStore, @@ -1031,3 +1060,154 @@ describe("throughput recording + GET /metrics/throughput", () => { expect(res.status).toBe(400); }); }); + +describe("GET /conversations/:id/cwd", () => { + it("returns null when unset", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; cwd: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + }); +}); + +describe("PUT then GET /conversations/:id/cwd", () => { + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; cwd: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.cwd).toBe("/home/user/project"); + + const getRes = await app.request("/conversations/conv1/cwd"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; + expect(getBody.cwd).toBe("/home/user/project"); + }); +}); + +describe("PUT /conversations/:id/cwd", () => { + it("with missing cwd returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("cwd"); + }); + + it("with empty cwd returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "" }), + }); + expect(res.status).toBe(400); + }); +}); + +describe("GET /conversations/:id/lsp", () => { + it("returns empty servers when cwd is unset", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: createFakeLspService(), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + }); + + it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => { + const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const lspStatuses = [ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + { + id: "lua-lsp", + name: "Lua LSP", + root: "/home/user/project", + extensions: [".luau"], + state: "error" as const, + error: "spawn failed", + }, + ]; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: createFakeLspService(lspStatuses), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: string; + readonly error?: string; + }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/home/user/project"); + expect(body.servers).toHaveLength(2); + expect(body.servers[0]?.id).toBe("typescript"); + expect(body.servers[0]?.state).toBe("connected"); + expect(body.servers[0]?.error).toBeUndefined(); + expect(body.servers[1]?.id).toBe("lua-lsp"); + expect(body.servers[1]?.state).toBe("error"); + expect(body.servers[1]?.error).toBe("spawn failed"); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 84c7d20..7778bad 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -2,6 +2,9 @@ import type { AgentEvent, Logger } from "@dispatch/kernel"; import type { ConversationHistoryResponse, ConversationMetricsResponse, + CwdResponse, + LspServerInfo, + LspStatusResponse, ModelsResponse, ThroughputResponse, WarmResponse, @@ -21,6 +24,8 @@ import { import { type ConversationStore, type CredentialStore, + type LspServerStatus, + type LspService, type SessionOrchestrator, ThroughputQueryError, type ThroughputStore, @@ -32,6 +37,7 @@ export interface CreateServerOptions { readonly orchestrator: SessionOrchestrator; readonly credentialStore: CredentialStore; readonly warmService?: WarmService; + readonly lspService?: LspService; /** Optional — defaults to a no-op store (recording disabled, empty reports). */ readonly throughputStore?: ThroughputStore; readonly logger?: Logger; @@ -105,7 +111,7 @@ export function createApp(opts: CreateServerOptions): Hono { "*", cors({ origin: "*", - allowMethods: ["GET", "POST", "OPTIONS"], + allowMethods: ["GET", "POST", "PUT", "OPTIONS"], allowHeaders: ["Content-Type"], }), ); @@ -313,5 +319,87 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + app.get("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + try { + const cwd = await opts.conversationStore.getCwd(conversationId); + log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null }); + const body: CwdResponse = { conversationId, cwd }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: cwd read failure", { err }); + return c.json({ error: "Failed to read conversation cwd" }, 500); + } + }); + + app.put("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/cwd: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + if (typeof obj.cwd !== "string" || obj.cwd.length === 0) { + return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400); + } + + try { + await opts.conversationStore.setCwd(conversationId, obj.cwd); + log.info("conversations: cwd set", { conversationId }); + const response: CwdResponse = { conversationId, cwd: obj.cwd }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: cwd set failure", { err }); + return c.json({ error: "Failed to set conversation cwd" }, 500); + } + }); + + app.get("/conversations/:id/lsp", async (c) => { + const conversationId = c.req.param("id"); + try { + const cwd = await opts.conversationStore.getCwd(conversationId); + if (cwd === null) { + log.info("conversations: lsp status read (no cwd)", { conversationId }); + const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + if (opts.lspService === undefined) { + log.warn("conversations: lsp service not available", { conversationId }); + return c.json({ error: "LSP service not available" }, 503); + } + + const statuses = await opts.lspService.status(cwd); + const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => { + const info: LspServerInfo = { + id: s.id, + name: s.name, + root: s.root, + extensions: s.extensions, + state: s.state, + ...(s.error !== undefined ? { error: s.error } : {}), + }; + return info; + }); + log.info("conversations: lsp status read", { + conversationId, + cwd, + serverCount: servers.length, + }); + const body: LspStatusResponse = { conversationId, cwd, servers }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: lsp status failure", { err }); + return c.json({ error: "Failed to read LSP status" }, 500); + } + }); + return app; } diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 5274033..6c988a5 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -4,6 +4,7 @@ import { cacheWarmHandle, conversationStoreHandle, credentialStoreHandle, + lspServiceHandle, sessionOrchestratorHandle, throughputStoreHandle, } from "./seam.js"; @@ -14,13 +15,21 @@ export const manifest: Manifest = { version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", - dependsOn: ["conversation-store", "credential-store", "session-orchestrator", "throughput-store"], + dependsOn: [ + "conversation-store", + "credential-store", + "lsp", + "session-orchestrator", + "throughput-store", + ], capabilities: { network: true }, contributes: { routes: [ "/chat", "/chat/warm", "/conversations/:id", + "/conversations/:id/cwd", + "/conversations/:id/lsp", "/health", "/models", "/metrics/throughput", @@ -45,6 +54,7 @@ export function createTransportHttpExtension(): Extension & { const credentialStore = host.getService(credentialStoreHandle); const throughputStore = host.getService(throughputStoreHandle); const warmService = host.getService(cacheWarmHandle); + const lspService = host.getService(lspServiceHandle); const logger = host.logger; const app = createApp({ @@ -53,6 +63,7 @@ export function createTransportHttpExtension(): Extension & { credentialStore, throughputStore, warmService, + lspService, logger, }); diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index 64929fc..718aaef 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -19,6 +19,7 @@ export { export type { ConversationStore, CredentialStore, + LspService, SessionOrchestrator, WarmService, } from "./seam.js"; @@ -26,5 +27,6 @@ export { cacheWarmHandle, conversationStoreHandle, credentialStoreHandle, + lspServiceHandle, sessionOrchestratorHandle, } from "./seam.js"; diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index e89370e..43c9d4d 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -2,6 +2,8 @@ export type { ConversationStore } from "@dispatch/conversation-store"; export { conversationStoreHandle } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; +export type { LspServerStatus, LspService } from "@dispatch/lsp"; +export { lspServiceHandle } from "@dispatch/lsp"; export type { SessionOrchestrator, WarmService } from "@dispatch/session-orchestrator"; export { cacheWarmHandle, sessionOrchestratorHandle } from "@dispatch/session-orchestrator"; export type { ThroughputStore } from "@dispatch/throughput-store"; diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index 5b4c6aa..b43469f 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { createTransportHttpExtension } from "./index.js"; -import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; +import type { + ConversationStore, + CredentialStore, + LspService, + SessionOrchestrator, +} from "./seam.js"; function fakeLogger(): Logger { return { @@ -41,6 +46,10 @@ function fakeConversationStore(): ConversationStore { async loadMetrics() { return []; }, + async getCwd() { + return null; + }, + async setCwd() {}, }; } @@ -61,6 +70,14 @@ function fakeCredentialStore(): CredentialStore { }; } +function fakeLspService(): LspService { + return { + async status() { + return []; + }, + }; +} + function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess { return { get<T>(key: string): T | undefined { @@ -76,6 +93,7 @@ const SERVICES = new Map<string, unknown>([ ["conversation-store/store", fakeConversationStore()], ["session-orchestrator/orchestrator", fakeOrchestrator()], ["credential-store/registry", fakeCredentialStore()], + ["lsp", fakeLspService()], ]); function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI { diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json index fc29c8c..fd3f3ea 100644 --- a/packages/transport-http/tsconfig.json +++ b/packages/transport-http/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../conversation-store" }, { "path": "../credential-store" }, { "path": "../kernel" }, + { "path": "../lsp" }, { "path": "../session-orchestrator" }, { "path": "../throughput-store" }, { "path": "../transport-contract" } |
