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);