From ff7193f32fc467125dd48795d2d45975a025a72d Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 26 Aug 2026 12:15:45 -0500 Subject: [PATCH] fix(logging): suppress cursor-sdk shell-parser tree-sitter warn from TUI @cursor/sdk's bundled shell-parser emits a one-shot console.warn ("shell-parser: tree-sitter natives are unavailable...") when its vendored tree-sitter natives fail to load (e.g. under Bun). opencode renders plugin stderr into the prompt, so the diagnostic appeared visually even though it is benign (shell analysis degrades to parsingFailed). Extend the existing console.log interceptor pattern to console.warn on both transports: - in-process: installCursorLogInterceptor now wraps console.warn; known SDK warning prefixes route through pluginLog("warn") to opencode's app.log instead of stderr - sidecar: agent-host.mjs wraps console.warn and forwards matched lines as {ev:"log", level:"warn"} over the JSONL protocol Adds tests for both paths; unrelated console.warn calls still pass through unchanged. --- src/provider/cursor-log-intercept.ts | 59 ++++++++++++++---- src/sidecar/agent-host.mjs | 40 +++++++++--- test/cursor-log-intercept.test.ts | 36 ++++++++++- test/fixtures/fake-cursor-sdk.mjs | 14 ++++- test/sidecar.test.ts | 91 +++++++++++++++++++++------- 5 files changed, 194 insertions(+), 46 deletions(-) diff --git a/src/provider/cursor-log-intercept.ts b/src/provider/cursor-log-intercept.ts index 9a7347e..80822dd 100644 --- a/src/provider/cursor-log-intercept.ts +++ b/src/provider/cursor-log-intercept.ts @@ -23,6 +23,20 @@ function stripAnsi(input: string): string { const RULE_LOAD_PATTERN = /^\d{2}:\d{2}:\d{2}\.\d{3}\s+INFO\s+(LocalCursorRulesService|AgentSkillsCursorRulesService|CursorPluginsAgentSkillsService) load completed(?:\s+ctx=\S+)?\s+meta=\{([^}]*)\}\s*$/; +/** + * One-shot `console.warn` diagnostics emitted at `@cursor/sdk` module load. + * Currently exactly one known line (vendored tree-sitter natives missing, + * shell parsing degrades to `parsingFailed`) — matched by prefix so future + * SDK builds appending detail still get captured. + */ +const SDK_WARNING_PREFIXES = [ + "shell-parser: tree-sitter natives are unavailable in this artifact", +]; + +function matchesKnownSdkWarning(line: string): boolean { + return SDK_WARNING_PREFIXES.some((prefix) => line.startsWith(prefix)); +} + /** Parses the `meta={key: value, ...}` tail into a plain numeric object. */ export function parseCursorLogMeta(raw: string): Record { const out: Record = {}; @@ -41,7 +55,9 @@ export interface ParsedCursorRuleLog { } /** Matches one line against the known Cursor rules/skills load-completion shape. */ -export function parseCursorRuleLoadLine(line: string): ParsedCursorRuleLog | undefined { +export function parseCursorRuleLoadLine( + line: string, +): ParsedCursorRuleLog | undefined { const match = RULE_LOAD_PATTERN.exec(stripAnsi(line)); if (!match) return undefined; const [, service, meta] = match; @@ -50,15 +66,18 @@ export function parseCursorRuleLoadLine(line: string): ParsedCursorRuleLog | und } let installed = false; -let original: typeof console.log | undefined; +let originalLog: typeof console.log | undefined; +let originalWarn: typeof console.warn | undefined; /** - * Installs a narrowly-scoped `console.log` interceptor that recognizes only - * the known Cursor rules/skills "load completed" messages (see - * {@link parseCursorRuleLoadLine}) and re-emits them as structured opencode - * logs via {@link pluginLog}. Every other `console.log` call — including - * anything else the SDK or the host process writes — passes through - * unchanged. + * Installs narrowly-scoped `console.log`/`console.warn` interceptors. On + * `console.log`, recognizes only the known Cursor rules/skills "load + * completed" messages (see {@link parseCursorRuleLoadLine}) and re-emits + * them as structured opencode logs via {@link pluginLog}. On `console.warn`, + * recognizes known one-shot SDK load diagnostics (see + * {@link SDK_WARNING_PREFIXES}) and routes them the same way. Every other + * `console.log`/`console.warn` call — including anything else the SDK or the + * host process writes — passes through unchanged. * * Only relevant to the in-process transport, where the SDK runs inside this * process and writes directly to the shared global `console`. The sidecar @@ -69,8 +88,8 @@ let original: typeof console.log | undefined; */ export function installCursorLogInterceptor(): void { if (installed) return; - original = console.log.bind(console); - const passthrough = original; + originalLog = console.log.bind(console); + const logPassthrough = originalLog; console.log = (...args: unknown[]) => { if (args.length === 1 && typeof args[0] === "string") { const parsed = parseCursorRuleLoadLine(args[0]); @@ -79,14 +98,28 @@ export function installCursorLogInterceptor(): void { return; } } - passthrough(...(args as Parameters)); + logPassthrough(...(args as Parameters)); + }; + originalWarn = console.warn.bind(console); + const warnPassthrough = originalWarn; + console.warn = (...args: unknown[]) => { + if (args.length === 1 && typeof args[0] === "string") { + const line = stripAnsi(args[0]); + if (matchesKnownSdkWarning(line)) { + pluginLog("warn", line); + return; + } + } + warnPassthrough(...(args as Parameters)); }; installed = true; } /** Test hook. */ export function resetCursorLogInterceptor(): void { - if (original) console.log = original; - original = undefined; + if (originalLog) console.log = originalLog; + if (originalWarn) console.warn = originalWarn; + originalLog = undefined; + originalWarn = undefined; installed = false; } diff --git a/src/sidecar/agent-host.mjs b/src/sidecar/agent-host.mjs index 43edf8a..aca5f2b 100644 --- a/src/sidecar/agent-host.mjs +++ b/src/sidecar/agent-host.mjs @@ -27,7 +27,12 @@ function serializeError(err) { const out = { name: err.name, message: err.message }; for (const k of ["status", "code", "isRetryable", "helpUrl"]) { const v = err[k]; - if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") out[k] = v; + if ( + typeof v === "number" || + typeof v === "string" || + typeof v === "boolean" + ) + out[k] = v; } return out; } @@ -42,16 +47,23 @@ function write(payload) { const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; // `@cursor/sdk`'s bundled local-exec runtime writes its rules/skills -// load-completion diagnostics straight to `console.log` (no public logger -// hook exists to redirect it — see src/provider/cursor-log-intercept.ts, -// which applies the identical pattern for the in-process transport). This -// process's own JSONL protocol never uses console.log (only -// process.stdout.write via write() above), so console.log here is entirely -// free for the SDK's use: recognized lines are forwarded to the parent as a -// structured "log" event instead of being written as raw, unparseable text. +// load-completion diagnostics straight to `console.log`, and its shell-parser +// emits a one-shot "tree-sitter natives unavailable" diagnostic via +// `console.warn` (no public logger hook exists to redirect either — see +// src/provider/cursor-log-intercept.ts, which applies the identical pattern +// for the in-process transport). This process's own JSONL protocol never uses +// console.log/console.warn (only process.stdout.write via write() above), so +// they are entirely free for the SDK's use: recognized lines are forwarded +// to the parent as a structured "log" event instead of being written as raw, +// unparseable text. const RULE_LOAD_PATTERN = /^\d{2}:\d{2}:\d{2}\.\d{3}\s+INFO\s+(LocalCursorRulesService|AgentSkillsCursorRulesService|CursorPluginsAgentSkillsService) load completed(?:\s+ctx=\S+)?\s+meta=\{([^}]*)\}\s*$/; +// One-shot SDK load diagnostics recognized on console.warn (prefix-matched). +const SDK_WARNING_PREFIXES = [ + "shell-parser: tree-sitter natives are unavailable in this artifact", +]; + function parseLogMeta(raw) { const out = {}; for (const part of raw.split(",")) { @@ -81,6 +93,18 @@ console.log = (...args) => { originalConsoleLog(...args); }; +const originalConsoleWarn = console.warn.bind(console); +console.warn = (...args) => { + if (args.length === 1 && typeof args[0] === "string") { + const line = args[0].replace(ANSI_PATTERN, ""); + if (SDK_WARNING_PREFIXES.some((prefix) => line.startsWith(prefix))) { + write({ ev: "log", level: "warn", message: line }); + return; + } + } + originalConsoleWarn(...args); +}; + let sdkPromise; function loadSdk() { // OPENCODE_CURSOR_SDK_PATH lets tests substitute a fake SDK module. diff --git a/test/cursor-log-intercept.test.ts b/test/cursor-log-intercept.test.ts index 96098fb..3703536 100644 --- a/test/cursor-log-intercept.test.ts +++ b/test/cursor-log-intercept.test.ts @@ -50,8 +50,12 @@ describe("parseCursorRuleLoadLine", () => { }); it("returns undefined for unrelated log lines", () => { - expect(parseCursorRuleLoadLine("some unrelated cursor sdk output")).toBeUndefined(); - expect(parseCursorRuleLoadLine("Plugins reload completed: 3 plugins loaded")).toBeUndefined(); + expect( + parseCursorRuleLoadLine("some unrelated cursor sdk output"), + ).toBeUndefined(); + expect( + parseCursorRuleLoadLine("Plugins reload completed: 3 plugins loaded"), + ).toBeUndefined(); }); }); @@ -86,6 +90,34 @@ describe("installCursorLogInterceptor", () => { passthrough.mockRestore(); }); + it("routes known SDK console.warn diagnostics through pluginLog instead of stderr", () => { + const log = vi.fn().mockResolvedValue(undefined); + setLogBridge({ client: { app: { log } } } as never); + + const passthrough = vi.spyOn(console, "warn").mockImplementation(() => {}); + installCursorLogInterceptor(); + + console.warn( + "shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed", + ); + console.warn("unrelated warning"); + + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith({ + body: { + service: "opencode-cursor", + level: "warn", + message: + "shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed", + }, + }); + + resetCursorLogInterceptor(); + expect(passthrough).toHaveBeenCalledTimes(1); + expect(passthrough).toHaveBeenCalledWith("unrelated warning"); + passthrough.mockRestore(); + }); + it("is idempotent across repeated installs", () => { installCursorLogInterceptor(); const first = console.log; diff --git a/test/fixtures/fake-cursor-sdk.mjs b/test/fixtures/fake-cursor-sdk.mjs index a20a18c..25eae27 100644 --- a/test/fixtures/fake-cursor-sdk.mjs +++ b/test/fixtures/fake-cursor-sdk.mjs @@ -14,6 +14,10 @@ * src/sidecar/agent-host.mjs / src/provider/cursor-log-intercept.ts), plus * one unrelated console.log line, to verify the sidecar's log interception * forwards only the recognized lines and passes everything else through. + * + * `options.emitShellParserWarn` -> Agent.create/resume writes the shell-parser + * "tree-sitter natives unavailable" diagnostic to console.warn, as the real + * @cursor/sdk does on first shell parse, plus one unrelated console.warn. */ function makeAgent(agentId, options) { @@ -26,6 +30,12 @@ function makeAgent(agentId, options) { ); console.log("some unrelated cursor sdk output"); } + if (options?.emitShellParserWarn) { + console.warn( + "shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed", + ); + console.warn("some unrelated cursor sdk warning"); + } return { agentId, model: options?.model, @@ -45,7 +55,9 @@ function makeAgent(agentId, options) { err.helpUrl = "https://example.com/rate-limits"; throw err; } - sendOptions?.onDelta?.({ update: { type: "text-delta", text: `echo:${text}` } }); + sendOptions?.onDelta?.({ + update: { type: "text-delta", text: `echo:${text}` }, + }); if (text === "hang") { let resolveWait; const waited = new Promise((resolve) => { diff --git a/test/sidecar.test.ts b/test/sidecar.test.ts index a238778..8115f28 100644 --- a/test/sidecar.test.ts +++ b/test/sidecar.test.ts @@ -2,13 +2,21 @@ import { afterEach, describe, expect, it } from "vitest"; import { fileURLToPath } from "node:url"; import { SidecarClient } from "../src/provider/sidecar-client.js"; -const SCRIPT = fileURLToPath(new URL("../src/sidecar/agent-host.mjs", import.meta.url)); -const FAKE_SDK = fileURLToPath(new URL("./fixtures/fake-cursor-sdk.mjs", import.meta.url)); +const SCRIPT = fileURLToPath( + new URL("../src/sidecar/agent-host.mjs", import.meta.url), +); +const FAKE_SDK = fileURLToPath( + new URL("./fixtures/fake-cursor-sdk.mjs", import.meta.url), +); const clients: SidecarClient[] = []; function makeClient( - onLog?: (level: "debug" | "info" | "warn" | "error", message: string, meta?: Record) => void, + onLog?: ( + level: "debug" | "info" | "warn" | "error", + message: string, + meta?: Record, + ) => void, ): SidecarClient { const client = new SidecarClient({ scriptPath: SCRIPT, @@ -23,7 +31,11 @@ afterEach(() => { for (const client of clients.splice(0)) client.dispose(); }); -const CREATE_OPTIONS = { apiKey: "k", model: { id: "m" }, local: { cwd: "/tmp" } }; +const CREATE_OPTIONS = { + apiKey: "k", + model: { id: "m" }, + local: { cwd: "/tmp" }, +}; describe("SidecarClient", () => { it("creates an agent in the child and streams a turn back", async () => { @@ -34,7 +46,10 @@ describe("SidecarClient", () => { const updates: Array<{ type: string }> = []; const run = await agent.send( { type: "user", text: "hi" }, - { mode: "agent", onDelta: ({ update }) => updates.push(update as { type: string }) }, + { + mode: "agent", + onDelta: ({ update }) => updates.push(update as { type: string }), + }, ); const result = await run.wait(); @@ -51,15 +66,17 @@ describe("SidecarClient", () => { it("preserves error names across the process boundary", async () => { const client = makeClient(); // Resume failure name drives session-pool's create fallback. - await expect(client.resumeAgent("missing", CREATE_OPTIONS)).rejects.toMatchObject({ + await expect( + client.resumeAgent("missing", CREATE_OPTIONS), + ).rejects.toMatchObject({ name: "AgentNotFoundError", }); // Busy failure name drives agent-events' local.force retry. const agent = await client.createAgent(CREATE_OPTIONS); - await expect(agent.send({ type: "user", text: "busy" }, { mode: "agent" })).rejects.toMatchObject( - { name: "AgentBusyError" }, - ); + await expect( + agent.send({ type: "user", text: "busy" }, { mode: "agent" }), + ).rejects.toMatchObject({ name: "AgentBusyError" }); // And the retry path (local.force) goes through cleanly. const run = await agent.send( { type: "user", text: "busy" }, @@ -71,16 +88,16 @@ describe("SidecarClient", () => { it("preserves error classification fields across the process boundary", async () => { const client = makeClient(); const agent = await client.createAgent(CREATE_OPTIONS); - await expect(agent.send({ type: "user", text: "rich" }, { mode: "agent" })).rejects.toMatchObject( - { - name: "RateLimitError", - message: "rate limited", - status: 429, - code: "rate_limited", - isRetryable: true, - helpUrl: "https://example.com/rate-limits", - }, - ); + await expect( + agent.send({ type: "user", text: "rich" }, { mode: "agent" }), + ).rejects.toMatchObject({ + name: "RateLimitError", + message: "rate limited", + status: 429, + code: "rate_limited", + isRetryable: true, + helpUrl: "https://example.com/rate-limits", + }); }); it("multiplexes concurrent sends over one child", async () => { @@ -101,13 +118,20 @@ describe("SidecarClient", () => { it("cancel() reaches the child and resolves the hung run", async () => { const client = makeClient(); const agent = await client.createAgent(CREATE_OPTIONS); - const run = await agent.send({ type: "user", text: "hang" }, { mode: "agent" }); + const run = await agent.send( + { type: "user", text: "hang" }, + { mode: "agent" }, + ); await run.cancel(); await expect(run.wait()).resolves.toMatchObject({ status: "cancelled" }); }); it("forwards recognized Cursor rules/skills log lines via onLog, and drops everything else", async () => { - const logs: Array<{ level: string; message: string; meta?: Record }> = []; + const logs: Array<{ + level: string; + message: string; + meta?: Record; + }> = []; const client = makeClient((level, message, meta) => { logs.push({ level, message, meta }); }); @@ -127,10 +151,33 @@ describe("SidecarClient", () => { ]); }); + it("forwards recognized SDK console.warn diagnostics via onLog, and drops everything else", async () => { + const logs: Array<{ + level: string; + message: string; + meta?: Record; + }> = []; + const client = makeClient((level, message, meta) => { + logs.push({ level, message, meta }); + }); + await client.createAgent({ ...CREATE_OPTIONS, emitShellParserWarn: true }); + + expect(logs).toEqual([ + { + level: "warn", + message: + "shell-parser: tree-sitter natives are unavailable in this artifact; shell command analysis degrades to parsingFailed", + }, + ]); + }); + it("rejects in-flight requests when the client is disposed", async () => { const client = makeClient(); const agent = await client.createAgent(CREATE_OPTIONS); - const run = await agent.send({ type: "user", text: "hang" }, { mode: "agent" }); + const run = await agent.send( + { type: "user", text: "hang" }, + { mode: "agent" }, + ); const waited = run.wait(); client.dispose(); await expect(waited).rejects.toThrow(/sidecar/i);