From 7a32039d6836daf67caae040c062e5b7176a66fa Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Wed, 11 Feb 2026 00:26:33 +1100 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20V1=20stabilization=20=E2=80=94=20to?= =?UTF-8?q?ol-loop=20guard,=20schema=20compat,=20auth=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tool-loop-guard with fingerprint-based repeat detection to prevent infinite tool call cycles in the provider boundary - Add tool-schema-compat layer for normalizing tool definitions across SDK/MCP/CLI sources into consistent function-call format - Harden auth flow with better error messages and retry UX - Add CLI model-discovery entry points - Update runtime-interception with guard integration - Strengthen proxy tool-loop with error classification - Add comprehensive tests for all new modules --- CHANGELOG.md | 24 ++ README.md | 5 +- cmd/installer/tasks.go | 2 +- docs/RELEASE_NOTES.md | 17 + .../pr19-pr20-v1-stabilization-plan.md | 125 +++++++ package.json | 14 +- src/auth.ts | 10 +- src/cli/discover.ts | 23 +- src/cli/model-discovery.ts | 50 +++ src/cli/opencode-cursor.ts | 316 ++++++++++++++++++ src/models/config.ts | 4 +- src/provider/runtime-interception.ts | 114 ++++++- src/provider/tool-loop-guard.ts | 252 ++++++++++++++ src/provider/tool-schema-compat.ts | 297 ++++++++++++++++ src/proxy/tool-loop.ts | 55 ++- .../opencode-loop.integration.test.ts | 113 +++++++ tests/unit/auth.test.ts | 90 +++-- tests/unit/cli/model-discovery.test.ts | 30 ++ .../provider-runtime-interception.test.ts | 104 +++++- tests/unit/provider-tool-loop-guard.test.ts | 99 ++++++ .../unit/provider-tool-schema-compat.test.ts | 155 +++++++++ tests/unit/proxy/tool-loop.test.ts | 17 + 22 files changed, 1852 insertions(+), 64 deletions(-) create mode 100644 docs/implementation/pr19-pr20-v1-stabilization-plan.md create mode 100644 src/cli/model-discovery.ts create mode 100644 src/cli/opencode-cursor.ts create mode 100644 src/provider/tool-loop-guard.ts create mode 100644 src/provider/tool-schema-compat.ts create mode 100644 tests/unit/cli/model-discovery.test.ts create mode 100644 tests/unit/provider-tool-loop-guard.test.ts create mode 100644 tests/unit/provider-tool-schema-compat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9137456..b2f1ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.2] - 2026-02-09 + +### Added +- OpenCode-owned tool loop adapter for OpenAI-style `tool_calls` responses (`src/proxy/tool-loop.ts`) +- Focused integration coverage for request-1/request-2 tool loop continuity (`tests/integration/opencode-loop.integration.test.ts`) +- CI test split scripts: `test:ci:unit` and `test:ci:integration` +- GitHub Actions job summaries for unit and integration suites +- Packaging CLI entrypoint `open-cursor` for npm/global installs (`src/cli/opencode-cursor.ts`) +- Model discovery parser utility for CLI install/sync workflows (`src/cli/model-discovery.ts`) + +### Changed +- CI workflow split into separate `unit` and `integration` jobs +- Integration CI defaults to OpenCode-owned loop mode (`CURSOR_ACP_TOOL_LOOP_MODE=opencode`) +- npm package metadata now targets publish/install as `open-cursor` +- Build now emits CLI artifacts for package bins (`dist/opencode-cursor.js`, `dist/discover.js`) + +### Fixed +- Node proxy fallback after `EADDRINUSE` now recreates the server before dynamic port bind +- Streaming termination guards prevent duplicate flush/output after intercepted tool call +- Auth unit tests now clean all candidate auth paths to avoid environment-dependent flakes +- Provider config generator no longer hardcodes a local filesystem npm path +- Added auth home-path override (`CURSOR_ACP_HOME_DIR`) for deterministic auth path resolution in tests/automation +- Added proxy reuse toggle (`CURSOR_ACP_REUSE_EXISTING_PROXY`) to avoid accidentally attaching to unrelated local proxy servers + ## [2.1.0] - 2026-02-07 ### Added diff --git a/README.md b/README.md index d97f9a9..6c25923 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ Integration CI defaults to OpenCode-owned loop mode: - `CURSOR_ACP_TOOL_LOOP_MODE=opencode` - `CURSOR_ACP_PROVIDER_BOUNDARY=v1` - `CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK=false` +- `CURSOR_ACP_TOOL_LOOP_MAX_REPEAT=3` - `CURSOR_ACP_ENABLE_OPENCODE_TOOLS=true` - `CURSOR_ACP_FORWARD_TOOL_CALLS=false` - `CURSOR_ACP_EMIT_TOOL_UPDATES=false` @@ -329,10 +330,12 @@ Provider-boundary rollout: - `CURSOR_ACP_PROVIDER_BOUNDARY=legacy` - Original provider/runtime boundary behavior - `CURSOR_ACP_PROVIDER_BOUNDARY=v1` - New shared boundary/interception path (recommended) - `CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK=true` - Emergency fallback from `v1` to `legacy` for the current request only +- `CURSOR_ACP_TOOL_LOOP_MAX_REPEAT=3` - Max repeated failing tool-call fingerprints before guard termination (or fallback when enabled) Auto-fallback trigger conditions: - Only active when `CURSOR_ACP_PROVIDER_BOUNDARY=v1` -- Triggered only when `v1` boundary extraction throws during tool-call interception +- Triggered when `v1` boundary extraction throws during tool-call interception +- Triggered when the tool-loop guard threshold is reached (same tool + arg shape + error class) - Does not trigger for normal cases like disallowed tools or no tool call - Does not trigger for unrelated runtime errors (for example, tool mapper/tool execution failures) diff --git a/cmd/installer/tasks.go b/cmd/installer/tasks.go index 5d41669..82eb129 100644 --- a/cmd/installer/tasks.go +++ b/cmd/installer/tasks.go @@ -344,7 +344,7 @@ func validateConfig(m *model) error { func verifyPlugin(m *model) error { // Try to load plugin with node to catch syntax/import errors - pluginPath := filepath.Join(m.projectDir, "dist", "index.js") + pluginPath := filepath.Join(m.projectDir, "dist", "plugin-entry.js") cmd := exec.Command("node", "-e", fmt.Sprintf(`require("%s")`, pluginPath)) if err := cmd.Run(); err != nil { return fmt.Errorf("plugin failed to load: %w", err) diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index d190506..664c45c 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,22 @@ # Release Notes +## v2.1.2 - OpenCode Tool Loop + CI Split + +### Highlights + +- Added OpenCode-owned multi-turn tool loop support by intercepting allowed tool calls and returning OpenAI-compatible `tool_calls` responses. +- Added integration tests for stream/non-stream interception, request-2 continuity with `role:"tool"`, and passthrough behavior. +- Split CI into separate unit and integration jobs, each with a concise run summary in GitHub Actions. +- Added npm-ready CLI packaging with `open-cursor` install/sync/status commands. +- Updated package metadata and build outputs for publishable npm bins. + +### Quality / Stability + +- Fixed Node proxy fallback bind path when default port is occupied. +- Added streaming termination guards to avoid duplicate flush and post-termination output. +- Stabilized auth unit tests by cleaning all candidate auth locations. +- Removed hardcoded local-path provider npm reference from generated provider config. + ## v2.0.0 - ACP Implementation ### New Features diff --git a/docs/implementation/pr19-pr20-v1-stabilization-plan.md b/docs/implementation/pr19-pr20-v1-stabilization-plan.md new file mode 100644 index 0000000..6cf1bbe --- /dev/null +++ b/docs/implementation/pr19-pr20-v1-stabilization-plan.md @@ -0,0 +1,125 @@ +# PR19/PR20 Implementation Plan (Auto Model, Production-First) + +## Status (Tuesday, February 10, 2026) + +- PR #17 merged into `main` at `2026-02-10T07:23:53Z`. +- PR #18 merged into `main` at `2026-02-10T07:26:53Z`. +- Baseline production behavior (model `cursor-acp/auto`): + - `v1` intercepts tool calls but repeatedly fails on `edit` argument/schema mismatch. + - `legacy` and `v1+autofallback` execute more useful calls (`todowrite`/`read`) but still show long tool-loop runs on the travel prompt. + +## Goal + +Make `v1` reliably compatible with OpenCode tool schemas in production, while preserving legacy fallback safety. + +## PR #19: v1 Schema Compatibility + Argument Normalization + +### Scope + +1. Add a compatibility layer for intercepted `tool_call` arguments. +2. Normalize model-generated argument variants into OpenCode tool schema shape. +3. Define deterministic behavior when normalized args still fail schema validation. +4. Clarify schema source for OpenCode-owned tools (including `todowrite`). + +### Implementation + +1. Add `src/provider/tool-schema-compat.ts`. +2. Add generic key alias normalization: + - `filePath|file|target_file` -> `path` + - `contents|text|streamContent` -> `content` + - `oldString` -> `old_string` + - `newString` -> `new_string` +3. Add alias collision rule: + - If canonical key already exists, aliases do not overwrite it. + - Aliases that collide are dropped and logged. +4. Add tool-specific normalization (no lossy semantic rewrite by default): + - `edit`: normalize key names only. Do not silently rewrite to `write`. + - `todowrite`: normalize status values (`todo|pending` -> `pending`, `in-progress` -> `in_progress`, `done` -> `completed`), default `priority=medium` when missing. +5. Schema source and ownership: + - Build runtime `toolSchemaMap` from request `body.tools[]` in `src/plugin.ts`. + - `todowrite` is treated as OpenCode-owned (remote) schema, not part of local default tools. +6. Validation behavior after normalization: + - If schema exists and args validate: intercept with normalized args. + - If schema exists and args still fail: do not rewrite semantics; forward the normalized call to OpenCode and rely on native tool validation error for model self-repair. + - Log structured compat error (`tool`, `missing`, `unexpected`, `repairHint`) for loop-guard consumption. +7. Wire compat into v1 interception path only in `src/provider/runtime-interception.ts`. +8. Add debug logs for `tool`, `originalArgKeys`, `normalizedArgKeys`, `collisionKeys`, `validationOk`, `repairHint`. + +### Explicit Safety Decision + +- `edit -> write` rewrite is disabled in PR #19 to avoid destructive semantic drift. +- Optional rewrite (if ever added) must be explicitly env-gated and only when file does not exist. + +### Tests + +1. `tests/unit/provider-tool-schema-compat.test.ts` +2. Extend `tests/unit/provider-runtime-interception.test.ts` for v1 normalization/validation paths. +3. Add tests for alias collisions and canonical precedence. +4. Extend `tests/integration/opencode-loop.integration.test.ts` with invalid `edit` args + model repair loop scenario (no rewrite). + +### Acceptance + +1. Travel prompt no longer loops on `edit` type/path errors in v1. +2. No regression in legacy behavior. +3. Unit + integration suites pass. + +## PR #20: Loop Guard + Controlled Auto-Fallback + Production Hardening + +### Scope + +1. Prevent infinite repeated tool-call failures. +2. Add explicit per-request guard plumbing used by both v1 and legacy handlers. +3. Add optional emergency fallback from v1 to legacy on repeated failures. +4. Define exact termination ownership and chunk format in stream/non-stream handlers. +5. Improve production diagnostics for faster incident triage. + +### Implementation + +1. Add `src/provider/tool-loop-guard.ts` with per-request state. +2. Extend `HandleToolLoopEventBaseOptions` in `src/provider/runtime-interception.ts` with `toolLoopGuard`. +3. Guard fingerprint: + - `tool + normalizedArgShape + errorClass`. + - `errorClass` derived from prior `role:"tool"` result content in request messages when available; fallback `unknown`. +4. Add per-request guard threshold (env): + - `CURSOR_ACP_TOOL_LOOP_MAX_REPEAT` (default `3`). +5. Thread guard through Bun/Node stream handlers in `src/plugin.ts`: + - Create one guard per incoming chat request. + - Pass guard into every `handleToolLoopEventWithFallback` call. +6. On threshold breach: + - If `CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK=true` and boundary is v1: + - call `boundaryContext.activateLegacyFallback("toolLoopGuard", error)`; + - continue with legacy for subsequent events in the same request. + - Else: + - return terminal error signal from runtime interception; + - plugin stream driver emits terminal assistant chunk using `createChatCompletionChunk(..., done=true)` and `[DONE]`. + - non-stream driver returns `createChatCompletionResponse` with explicit error text. +7. Add structured logs: + - `loopGuardTriggered`, `fingerprint`, `repeatCount`, `fallbackActivated`. +8. Document env flags and behavior in `README.md`. + +### Ownership Clarification + +- Runtime interception decides `allow/fallback/terminate`. +- `src/plugin.ts` owns SSE/non-stream emission and stream termination mechanics. + +### Tests + +1. Unit tests for loop guard counting and reset semantics. +2. Integration test for repeated invalid `edit` calls: + - `v1` with no fallback -> terminal error chunk. + - `v1` with fallback -> switch to legacy after threshold. +3. Parity tests in both modes (`v1`, `legacy`) for core scenarios. +4. Test that fallback still only auto-triggers on configured guard condition or boundary extraction failures. + +### Acceptance + +1. No unbounded loop on repeated invalid calls. +2. Fallback behavior is deterministic and gated by env. +3. Production run completes with actionable output or explicit terminal error. + +## Execution Sequence + +1. Branch `feat/pr19-v1-schema-compat` from updated `main`; open PR #19. +2. Validate production matrix (`auto` only) and CI. +3. Branch `feat/pr20-loop-guard-fallback` from PR #19; open PR #20. +4. Validate matrix again, then merge #19 followed by #20. diff --git a/package.json b/package.json index b7f03a4..9e01119 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,22 @@ { - "name": "@nomadcxx/opencode-cursor", - "version": "2.1.1", + "name": "open-cursor", + "version": "2.1.2", "description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.", "type": "module", "main": "dist/index.js", "scripts": { - "build": "bun build ./src/index.ts ./src/plugin-entry.ts --outdir ./dist --target node", - "dev": "bun build ./src/index.ts ./src/plugin-entry.ts --outdir ./dist --target node --watch", + "build": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node", + "dev": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node --watch", "test": "bun test", "test:unit": "bun test tests/unit", "test:integration": "bun test tests/integration", - "test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/competitive/edge.test.ts", + "test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/competitive/edge.test.ts", "test:ci:integration": "bun test tests/integration/comprehensive.test.ts tests/integration/tools-router.integration.test.ts tests/integration/stream-router.integration.test.ts tests/integration/opencode-loop.integration.test.ts", "discover": "bun run src/cli/discover.ts", "prepublishOnly": "bun run build" }, "bin": { + "open-cursor": "./dist/cli/opencode-cursor.js", "cursor-discover": "./dist/cli/discover.js" }, "exports": { @@ -38,9 +39,6 @@ "bun-types": "^1.1.0" }, "license": "ISC", - "publishConfig": { - "access": "public" - }, "repository": { "type": "git", "url": "https://github.com/Nomadcxx/opencode-cursor.git" diff --git a/src/auth.ts b/src/auth.ts index a0225c6..776ea2c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -21,6 +21,14 @@ export interface AuthResult { error?: string; } +function getHomeDir(): string { + const override = process.env.CURSOR_ACP_HOME_DIR; + if (override && override.length > 0) { + return override; + } + return homedir(); +} + export async function pollForAuthFile( timeoutMs: number = AUTH_POLL_TIMEOUT, intervalMs: number = AUTH_POLL_INTERVAL @@ -215,7 +223,7 @@ export function verifyCursorAuth(): boolean { * - Linux: ~/.config/cursor/ (XDG), XDG_CONFIG_HOME/cursor/, ~/.cursor/ */ export function getPossibleAuthPaths(): string[] { - const home = homedir(); + const home = getHomeDir(); const paths: string[] = []; const isDarwin = platform() === "darwin"; diff --git a/src/cli/discover.ts b/src/cli/discover.ts index be7802b..c39a4f3 100644 --- a/src/cli/discover.ts +++ b/src/cli/discover.ts @@ -1,15 +1,21 @@ -#!/usr/bin/env bun -import { ModelDiscoveryService } from "../models/discovery.js"; -import { ConfigUpdater } from "../models/config.js"; +#!/usr/bin/env node import { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import { homedir } from "os"; +import { + discoverModelsFromCursorAgent, + fallbackModels, +} from "./model-discovery.js"; async function main() { console.log("Discovering Cursor models..."); - - const service = new ModelDiscoveryService(); - const models = await service.discover(); + let models = fallbackModels(); + try { + models = discoverModelsFromCursorAgent(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Warning: cursor-agent model discovery failed, using fallback list (${message})`); + } console.log(`Found ${models.length} models:`); for (const model of models) { @@ -17,7 +23,6 @@ async function main() { } // Update config - const updater = new ConfigUpdater(); const configPath = join(homedir(), ".config/opencode/opencode.json"); if (!existsSync(configPath)) { @@ -29,7 +34,7 @@ async function main() { // Update cursor-acp provider models if (existingConfig.provider?.["cursor-acp"]) { - const formatted = updater.formatModels(models); + const formatted = Object.fromEntries(models.map((model) => [model.id, { name: model.name }])); existingConfig.provider["cursor-acp"].models = { ...existingConfig.provider["cursor-acp"].models, ...formatted @@ -45,4 +50,4 @@ async function main() { console.log("Done!"); } -main().catch(console.error); \ No newline at end of file +main().catch(console.error); diff --git a/src/cli/model-discovery.ts b/src/cli/model-discovery.ts new file mode 100644 index 0000000..b8144a9 --- /dev/null +++ b/src/cli/model-discovery.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "child_process"; +import { stripAnsi } from "../utils/errors.js"; + +export type DiscoveredModel = { + id: string; + name: string; +}; + +export function parseCursorModelsOutput(output: string): DiscoveredModel[] { + const clean = stripAnsi(output); + const models: DiscoveredModel[] = []; + const seen = new Set(); + + for (const line of clean.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match( + /^([a-zA-Z0-9._-]+)\s+-\s+(.+?)(?:\s+\((?:current|default)\))*\s*$/, + ); + if (!match) continue; + + const id = match[1]; + if (seen.has(id)) continue; + seen.add(id); + models.push({ id, name: match[2].trim() }); + } + + return models; +} + +export function discoverModelsFromCursorAgent(): DiscoveredModel[] { + const raw = execFileSync("cursor-agent", ["models"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const models = parseCursorModelsOutput(raw); + if (models.length === 0) { + throw new Error("No models parsed from cursor-agent output"); + } + return models; +} + +export function fallbackModels(): DiscoveredModel[] { + return [ + { id: "auto", name: "Auto" }, + { id: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, + { id: "opus-4.6", name: "Claude 4.6 Opus" }, + { id: "gpt-5.2", name: "GPT-5.2" }, + ]; +} diff --git a/src/cli/opencode-cursor.ts b/src/cli/opencode-cursor.ts new file mode 100644 index 0000000..22a75a4 --- /dev/null +++ b/src/cli/opencode-cursor.ts @@ -0,0 +1,316 @@ +#!/usr/bin/env node + +import { execFileSync } from "child_process"; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { homedir } from "os"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { + discoverModelsFromCursorAgent, + fallbackModels, +} from "./model-discovery.js"; + +type Command = "install" | "sync-models" | "uninstall" | "status" | "help"; + +type Options = { + config?: string; + pluginDir?: string; + baseUrl?: string; + copy?: boolean; + skipModels?: boolean; + noBackup?: boolean; +}; + +const PROVIDER_ID = "cursor-acp"; +const DEFAULT_BASE_URL = "http://127.0.0.1:32124/v1"; + +function printHelp() { + console.log(`opencode-cursor + +Usage: + opencode-cursor install [--config ] [--plugin-dir ] [--base-url ] [--copy] [--skip-models] [--no-backup] + opencode-cursor sync-models [--config ] [--no-backup] + opencode-cursor uninstall [--config ] [--plugin-dir ] [--no-backup] + opencode-cursor status [--config ] [--plugin-dir ] + opencode-cursor help +`); +} + +function parseArgs(argv: string[]): { command: Command; options: Options } { + const [commandRaw, ...rest] = argv; + const command = normalizeCommand(commandRaw); + const options: Options = {}; + + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]; + if (arg === "--copy") { + options.copy = true; + } else if (arg === "--skip-models") { + options.skipModels = true; + } else if (arg === "--no-backup") { + options.noBackup = true; + } else if (arg === "--config" && rest[i + 1]) { + options.config = rest[i + 1]; + i += 1; + } else if (arg === "--plugin-dir" && rest[i + 1]) { + options.pluginDir = rest[i + 1]; + i += 1; + } else if (arg === "--base-url" && rest[i + 1]) { + options.baseUrl = rest[i + 1]; + i += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return { command, options }; +} + +function normalizeCommand(value: string | undefined): Command { + switch ((value || "install").toLowerCase()) { + case "install": + case "sync-models": + case "uninstall": + case "status": + case "help": + return value ? (value.toLowerCase() as Command) : "install"; + default: + throw new Error(`Unknown command: ${value}`); + } +} + +function getConfigHome(): string { + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg && xdg.length > 0) return xdg; + return join(homedir(), ".config"); +} + +function resolvePaths(options: Options) { + const opencodeDir = join(getConfigHome(), "opencode"); + const configPath = resolve(options.config || join(opencodeDir, "opencode.json")); + const pluginDir = resolve(options.pluginDir || join(opencodeDir, "plugin")); + const pluginPath = join(pluginDir, `${PROVIDER_ID}.js`); + return { opencodeDir, configPath, pluginDir, pluginPath }; +} + +function resolvePluginSource(): string { + const currentFile = fileURLToPath(import.meta.url); + const currentDir = dirname(currentFile); + const candidates = [ + join(currentDir, "plugin-entry.js"), + join(currentDir, "..", "plugin-entry.js"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + throw new Error("Unable to locate plugin-entry.js next to CLI distribution files"); +} + +function readConfig(configPath: string): any { + if (!existsSync(configPath)) { + return { plugin: [], provider: {} }; + } + const raw = readFileSync(configPath, "utf8"); + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON in config: ${configPath} (${String(error)})`); + } +} + +function writeConfig(configPath: string, config: any, noBackup: boolean) { + mkdirSync(dirname(configPath), { recursive: true }); + if (!noBackup && existsSync(configPath)) { + const backupPath = `${configPath}.bak.${new Date().toISOString().replace(/[:]/g, "-")}`; + copyFileSync(configPath, backupPath); + console.log(`Backup written: ${backupPath}`); + } + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +function ensureProvider(config: any, baseUrl: string) { + config.plugin = Array.isArray(config.plugin) ? config.plugin : []; + if (!config.plugin.includes(PROVIDER_ID)) { + config.plugin.push(PROVIDER_ID); + } + + config.provider = config.provider && typeof config.provider === "object" ? config.provider : {}; + const current = config.provider[PROVIDER_ID] && typeof config.provider[PROVIDER_ID] === "object" + ? config.provider[PROVIDER_ID] + : {}; + const options = current.options && typeof current.options === "object" ? current.options : {}; + const models = current.models && typeof current.models === "object" ? current.models : {}; + + config.provider[PROVIDER_ID] = { + ...current, + name: "Cursor", + npm: "@ai-sdk/openai-compatible", + options: { + ...options, + baseURL: baseUrl, + }, + models, + }; +} + +function ensurePluginLink(pluginSource: string, pluginPath: string, copyMode: boolean) { + mkdirSync(dirname(pluginPath), { recursive: true }); + rmSync(pluginPath, { force: true }); + if (copyMode) { + copyFileSync(pluginSource, pluginPath); + return; + } + symlinkSync(pluginSource, pluginPath); +} + +function discoverModelsSafe() { + try { + return discoverModelsFromCursorAgent(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Warning: cursor-agent models failed; using fallback models (${message})`); + return fallbackModels(); + } +} + +function installAiSdk(opencodeDir: string) { + try { + execFileSync("bun", ["install", "@ai-sdk/openai-compatible"], { + cwd: opencodeDir, + stdio: "inherit", + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Warning: failed to install @ai-sdk/openai-compatible via bun (${message})`); + } +} + +function commandInstall(options: Options) { + const { opencodeDir, configPath, pluginPath } = resolvePaths(options); + const baseUrl = options.baseUrl || DEFAULT_BASE_URL; + const copyMode = options.copy === true; + const pluginSource = resolvePluginSource(); + + mkdirSync(opencodeDir, { recursive: true }); + ensurePluginLink(pluginSource, pluginPath, copyMode); + const config = readConfig(configPath); + ensureProvider(config, baseUrl); + + if (!options.skipModels) { + const models = discoverModelsSafe(); + for (const model of models) { + config.provider[PROVIDER_ID].models[model.id] = { name: model.name }; + } + console.log(`Models synced: ${models.length}`); + } + + writeConfig(configPath, config, options.noBackup === true); + installAiSdk(opencodeDir); + + console.log(`Installed ${PROVIDER_ID}`); + console.log(`Plugin path: ${pluginPath}${copyMode ? " (copy)" : " (symlink)"}`); + console.log(`Config path: ${configPath}`); +} + +function commandSyncModels(options: Options) { + const { configPath } = resolvePaths(options); + const config = readConfig(configPath); + ensureProvider(config, options.baseUrl || DEFAULT_BASE_URL); + + const models = discoverModelsSafe(); + for (const model of models) { + config.provider[PROVIDER_ID].models[model.id] = { name: model.name }; + } + + writeConfig(configPath, config, options.noBackup === true); + console.log(`Models synced: ${models.length}`); + console.log(`Config path: ${configPath}`); +} + +function commandUninstall(options: Options) { + const { configPath, pluginPath } = resolvePaths(options); + rmSync(pluginPath, { force: true }); + + if (existsSync(configPath)) { + const config = readConfig(configPath); + if (Array.isArray(config.plugin)) { + config.plugin = config.plugin.filter((name: string) => name !== PROVIDER_ID); + } + if (config.provider && typeof config.provider === "object") { + delete config.provider[PROVIDER_ID]; + } + writeConfig(configPath, config, options.noBackup === true); + } + + console.log(`Removed plugin link: ${pluginPath}`); + console.log(`Removed provider "${PROVIDER_ID}" from ${configPath}`); +} + +function commandStatus(options: Options) { + const { configPath, pluginPath } = resolvePaths(options); + const pluginExists = existsSync(pluginPath); + const pluginType = pluginExists ? (lstatSync(pluginPath).isSymbolicLink() ? "symlink" : "file") : "missing"; + + let providerExists = false; + let pluginEnabled = false; + if (existsSync(configPath)) { + const config = readConfig(configPath); + providerExists = Boolean(config.provider?.[PROVIDER_ID]); + pluginEnabled = Array.isArray(config.plugin) && config.plugin.includes(PROVIDER_ID); + } + + console.log(`Plugin file: ${pluginPath} (${pluginType})`); + console.log(`Provider in config: ${providerExists ? "yes" : "no"}`); + console.log(`Plugin enabled in config: ${pluginEnabled ? "yes" : "no"}`); + console.log(`Config path: ${configPath}`); +} + +function main() { + let parsed: { command: Command; options: Options }; + try { + parsed = parseArgs(process.argv.slice(2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + printHelp(); + process.exit(1); + return; + } + + try { + switch (parsed.command) { + case "install": + commandInstall(parsed.options); + return; + case "sync-models": + commandSyncModels(parsed.options); + return; + case "uninstall": + commandUninstall(parsed.options); + return; + case "status": + commandStatus(parsed.options); + return; + case "help": + printHelp(); + return; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } +} + +main(); diff --git a/src/models/config.ts b/src/models/config.ts index d07c3eb..159406d 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -52,7 +52,7 @@ export class ConfigUpdater { baseURL: string ): OpenCodeProviderConfig { return { - npm: "file:///home/nomadx/opencode-cursor", + npm: "@ai-sdk/openai-compatible", name: "Cursor Agent Provider", options: { baseURL, @@ -61,4 +61,4 @@ export class ConfigUpdater { models: this.formatModels(models) }; } -} \ No newline at end of file +} diff --git a/src/provider/runtime-interception.ts b/src/provider/runtime-interception.ts index 1b98d21..875e883 100644 --- a/src/provider/runtime-interception.ts +++ b/src/provider/runtime-interception.ts @@ -2,13 +2,20 @@ import type { ToolUpdate, ToolMapper } from "../acp/tools.js"; import { extractOpenAiToolCall, type OpenAiToolCall } from "../proxy/tool-loop.js"; import type { StreamJsonToolCallEvent } from "../streaming/types.js"; import type { ToolRouter } from "../tools/router.js"; +import { createLogger } from "../utils/logger.js"; +import { applyToolSchemaCompat } from "./tool-schema-compat.js"; +import type { ToolLoopGuard } from "./tool-loop-guard.js"; import type { ProviderBoundaryMode, ToolLoopMode } from "./boundary.js"; import type { ProviderBoundary } from "./boundary.js"; +const log = createLogger("provider:runtime-interception"); + interface HandleToolLoopEventBaseOptions { event: StreamJsonToolCallEvent; toolLoopMode: ToolLoopMode; allowedToolNames: Set; + toolSchemaMap: Map; + toolLoopGuard: ToolLoopGuard; toolMapper: ToolMapper; toolSessionId: string; shouldEmitToolUpdates: boolean; @@ -37,6 +44,17 @@ export interface HandleToolLoopEventWithFallbackOptions export interface HandleToolLoopEventResult { intercepted: boolean; skipConverter: boolean; + terminate?: ToolLoopTermination; +} + +export interface ToolLoopTermination { + reason: "loop_guard"; + message: string; + tool: string; + fingerprint: string; + repeatCount: number; + maxRepeat: number; + errorClass: string; } export class ToolBoundaryExtractionError extends Error { @@ -55,6 +73,8 @@ export async function handleToolLoopEventLegacy( event, toolLoopMode, allowedToolNames, + toolSchemaMap: _toolSchemaMap, + toolLoopGuard, toolMapper, toolSessionId, shouldEmitToolUpdates, @@ -67,6 +87,19 @@ export async function handleToolLoopEventLegacy( onInterceptedToolCall, } = options; + const interceptedToolCall = + toolLoopMode === "opencode" + ? extractOpenAiToolCall(event as any, allowedToolNames) + : null; + if (interceptedToolCall) { + const termination = evaluateToolLoopGuard(toolLoopGuard, interceptedToolCall); + if (termination) { + return { intercepted: false, skipConverter: true, terminate: termination }; + } + await onInterceptedToolCall(interceptedToolCall); + return { intercepted: true, skipConverter: true }; + } + const updates = await toolMapper.mapCursorEventToAcp( event, event.session_id ?? toolSessionId, @@ -78,15 +111,6 @@ export async function handleToolLoopEventLegacy( } } - const interceptedToolCall = - toolLoopMode === "opencode" - ? extractOpenAiToolCall(event as any, allowedToolNames) - : null; - if (interceptedToolCall) { - await onInterceptedToolCall(interceptedToolCall); - return { intercepted: true, skipConverter: true }; - } - if (toolRouter && proxyExecuteToolCalls) { const toolResult = await toolRouter.handleToolCall(event as any, responseMeta); if (toolResult) { @@ -108,6 +132,8 @@ export async function handleToolLoopEventV1( boundary, toolLoopMode, allowedToolNames, + toolSchemaMap, + toolLoopGuard, toolMapper, toolSessionId, shouldEmitToolUpdates, @@ -131,6 +157,29 @@ export async function handleToolLoopEventV1( throw new ToolBoundaryExtractionError("Boundary tool extraction failed", error); } if (interceptedToolCall) { + const compat = applyToolSchemaCompat(interceptedToolCall, toolSchemaMap); + interceptedToolCall = compat.toolCall; + log.debug("Applied tool schema compatibility", { + tool: interceptedToolCall.function.name, + originalArgKeys: compat.originalArgKeys, + normalizedArgKeys: compat.normalizedArgKeys, + collisionKeys: compat.collisionKeys, + validationOk: compat.validation.ok, + }); + if (compat.validation.hasSchema && !compat.validation.ok) { + log.warn("Tool schema compatibility validation failed", { + tool: interceptedToolCall.function.name, + missing: compat.validation.missing, + unexpected: compat.validation.unexpected, + typeErrors: compat.validation.typeErrors, + repairHint: compat.validation.repairHint, + }); + } + + const termination = evaluateToolLoopGuard(toolLoopGuard, interceptedToolCall); + if (termination) { + return { intercepted: false, skipConverter: true, terminate: termination }; + } await onInterceptedToolCall(interceptedToolCall); return { intercepted: true, skipConverter: true }; } @@ -174,7 +223,18 @@ export async function handleToolLoopEventWithFallback( } try { - return await handleToolLoopEventV1(shared); + const result = await handleToolLoopEventV1(shared); + if ( + result.terminate + && autoFallbackToLegacy + && boundaryMode === "v1" + && result.terminate.reason === "loop_guard" + ) { + shared.toolLoopGuard.resetFingerprint(result.terminate.fingerprint); + onFallbackToLegacy?.(new Error(`loop guard: ${result.terminate.fingerprint}`)); + return handleToolLoopEventLegacy(shared); + } + return result; } catch (error) { if ( !autoFallbackToLegacy @@ -187,3 +247,37 @@ export async function handleToolLoopEventWithFallback( return handleToolLoopEventLegacy(shared); } } + +function evaluateToolLoopGuard( + toolLoopGuard: ToolLoopGuard, + toolCall: OpenAiToolCall, +): ToolLoopTermination | null { + const decision = toolLoopGuard.evaluate(toolCall); + if (!decision.tracked) { + return null; + } + if (!decision.triggered) { + return null; + } + + log.warn("Tool loop guard triggered", { + tool: toolCall.function.name, + fingerprint: decision.fingerprint, + repeatCount: decision.repeatCount, + maxRepeat: decision.maxRepeat, + errorClass: decision.errorClass, + }); + + return { + reason: "loop_guard", + message: + `Tool loop guard stopped repeated failing calls to "${toolCall.function.name}" ` + + `after ${decision.repeatCount} attempts (limit ${decision.maxRepeat}). ` + + "Adjust tool arguments and retry.", + tool: toolCall.function.name, + fingerprint: decision.fingerprint, + repeatCount: decision.repeatCount, + maxRepeat: decision.maxRepeat, + errorClass: decision.errorClass, + }; +} diff --git a/src/provider/tool-loop-guard.ts b/src/provider/tool-loop-guard.ts new file mode 100644 index 0000000..fecdcbd --- /dev/null +++ b/src/provider/tool-loop-guard.ts @@ -0,0 +1,252 @@ +import type { OpenAiToolCall } from "../proxy/tool-loop.js"; + +type ToolLoopErrorClass = + | "validation" + | "not_found" + | "permission" + | "timeout" + | "tool_error" + | "success" + | "unknown"; + +export interface ToolLoopGuardDecision { + fingerprint: string; + repeatCount: number; + maxRepeat: number; + errorClass: ToolLoopErrorClass; + triggered: boolean; + tracked: boolean; +} + +export interface ToolLoopGuard { + evaluate(toolCall: OpenAiToolCall): ToolLoopGuardDecision; + resetFingerprint(fingerprint: string): void; +} + +export function parseToolLoopMaxRepeat( + value: string | undefined, +): { value: number; valid: boolean } { + if (value === undefined) { + return { value: 3, valid: true }; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) { + return { value: 3, valid: false }; + } + return { value: Math.floor(parsed), valid: true }; +} + +export function createToolLoopGuard( + messages: Array, + maxRepeat: number, +): ToolLoopGuard { + const { byCallId, latest, initialCounts } = indexToolLoopHistory(messages); + const counts = new Map(initialCounts); + + return { + evaluate(toolCall) { + const errorClass = byCallId.get(toolCall.id) ?? latest ?? "unknown"; + const argShape = deriveArgumentShape(toolCall.function.arguments); + const fingerprint = `${toolCall.function.name}|${argShape}|${errorClass}`; + + if (errorClass === "success") { + return { + fingerprint, + repeatCount: 0, + maxRepeat, + errorClass, + triggered: false, + tracked: false, + }; + } + + const repeatCount = (counts.get(fingerprint) ?? 0) + 1; + counts.set(fingerprint, repeatCount); + return { + fingerprint, + repeatCount, + maxRepeat, + errorClass, + triggered: repeatCount > maxRepeat, + tracked: true, + }; + }, + + resetFingerprint(fingerprint) { + counts.delete(fingerprint); + }, + }; +} + +function indexToolResultErrorClasses(messages: Array): { + byCallId: Map; + latest: ToolLoopErrorClass | null; +} { + const byCallId = new Map(); + let latest: ToolLoopErrorClass | null = null; + + for (const message of messages) { + if (!isRecord(message) || message.role !== "tool") { + continue; + } + + const errorClass = classifyToolResult(message.content); + latest = errorClass; + + const callId = + typeof message.tool_call_id === "string" && message.tool_call_id.length > 0 + ? message.tool_call_id + : null; + if (callId) { + byCallId.set(callId, errorClass); + } + } + + return { byCallId, latest }; +} + +function indexToolLoopHistory(messages: Array): { + byCallId: Map; + latest: ToolLoopErrorClass | null; + initialCounts: Map; +} { + const { byCallId, latest } = indexToolResultErrorClasses(messages); + const initialCounts = new Map(); + const assistantCalls = extractAssistantToolCalls(messages); + + for (const call of assistantCalls) { + const errorClass = byCallId.get(call.id) ?? latest ?? "unknown"; + if (errorClass === "success") { + continue; + } + const fingerprint = `${call.name}|${call.argShape}|${errorClass}`; + initialCounts.set(fingerprint, (initialCounts.get(fingerprint) ?? 0) + 1); + } + + return { byCallId, latest, initialCounts }; +} + +function classifyToolResult(content: unknown): ToolLoopErrorClass { + const text = toLowerText(content); + if (!text) { + return "unknown"; + } + + if (containsAny(text, ["missing required", "missing", "invalid", "schema", "unexpected", "type error"])) { + return "validation"; + } + if (containsAny(text, ["enoent", "not found", "no such file"])) { + return "not_found"; + } + if (containsAny(text, ["permission denied", "eacces", "forbidden"])) { + return "permission"; + } + if (containsAny(text, ["timeout", "timed out"])) { + return "timeout"; + } + if (containsAny(text, ["success", "completed", "\"ok\":true", "\"success\":true"])) { + return "success"; + } + if (containsAny(text, ["error", "failed", "\"is_error\":true", "\"success\":false"])) { + return "tool_error"; + } + + return "unknown"; +} + +function deriveArgumentShape(rawArguments: string): string { + try { + const parsed = JSON.parse(rawArguments); + return JSON.stringify(shapeOf(parsed)); + } catch { + return "invalid_json"; + } +} + +function extractAssistantToolCalls(messages: Array): Array<{ + id: string; + name: string; + argShape: string; +}> { + const calls: Array<{ id: string; name: string; argShape: string }> = []; + for (const message of messages) { + if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.tool_calls)) { + continue; + } + for (const call of message.tool_calls) { + if (!isRecord(call)) { + continue; + } + const id = typeof call.id === "string" ? call.id : ""; + const fn = isRecord(call.function) ? call.function : null; + const name = fn && typeof fn.name === "string" ? fn.name : ""; + const rawArguments = + fn && typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn?.arguments ?? {}); + if (!id || !name) { + continue; + } + calls.push({ + id, + name, + argShape: deriveArgumentShape(rawArguments), + }); + } + } + return calls; +} + +function shapeOf(value: unknown): unknown { + if (Array.isArray(value)) { + if (value.length === 0) { + return ["empty"]; + } + return [shapeOf(value[0])]; + } + if (isRecord(value)) { + const shaped: Record = {}; + for (const key of Object.keys(value).sort()) { + shaped[key] = shapeOf(value[key]); + } + return shaped; + } + if (value === null) { + return "null"; + } + return typeof value; +} + +function toLowerText(content: unknown): string { + const rendered = renderContent(content); + return rendered.trim().toLowerCase(); +} + +function renderContent(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") { + return part; + } + if (isRecord(part) && typeof part.text === "string") { + return part.text; + } + return JSON.stringify(part); + }) + .join(" "); + } + if (content === null || content === undefined) { + return ""; + } + return JSON.stringify(content); +} + +function containsAny(text: string, patterns: string[]): boolean { + return patterns.some((pattern) => text.includes(pattern)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/provider/tool-schema-compat.ts b/src/provider/tool-schema-compat.ts new file mode 100644 index 0000000..5d2b113 --- /dev/null +++ b/src/provider/tool-schema-compat.ts @@ -0,0 +1,297 @@ +import type { OpenAiToolCall } from "../proxy/tool-loop.js"; + +type JsonRecord = Record; + +const ARG_KEY_ALIASES = new Map([ + ["filepath", "path"], + ["file", "path"], + ["targetfile", "path"], + ["contents", "content"], + ["text", "content"], + ["streamcontent", "content"], + ["oldstring", "old_string"], + ["newstring", "new_string"], +]); + +export interface ToolSchemaValidationResult { + hasSchema: boolean; + ok: boolean; + missing: string[]; + unexpected: string[]; + typeErrors: string[]; + repairHint?: string; +} + +export interface ToolSchemaCompatResult { + toolCall: OpenAiToolCall; + normalizedArgs: JsonRecord; + originalArgKeys: string[]; + normalizedArgKeys: string[]; + collisionKeys: string[]; + validation: ToolSchemaValidationResult; +} + +export function buildToolSchemaMap(tools: Array): Map { + const schemas = new Map(); + for (const rawTool of tools) { + const tool = isRecord(rawTool) ? rawTool : null; + if (!tool) { + continue; + } + const fn = isRecord(tool.function) ? tool.function : tool; + const name = typeof fn.name === "string" ? fn.name.trim() : ""; + if (!name) { + continue; + } + if (fn.parameters !== undefined) { + schemas.set(name, fn.parameters); + } + } + return schemas; +} + +export function applyToolSchemaCompat( + toolCall: OpenAiToolCall, + toolSchemaMap: Map, +): ToolSchemaCompatResult { + const parsedArgs = parseArguments(toolCall.function.arguments); + const originalArgKeys = Object.keys(parsedArgs); + const { normalizedArgs, collisionKeys } = normalizeArgumentKeys(parsedArgs); + const toolSpecificArgs = normalizeToolSpecificArgs(toolCall.function.name, normalizedArgs); + const validation = validateToolArguments( + toolCall.function.name, + toolSpecificArgs, + toolSchemaMap.get(toolCall.function.name), + ); + + const normalizedToolCall: OpenAiToolCall = { + ...toolCall, + function: { + ...toolCall.function, + arguments: JSON.stringify(toolSpecificArgs), + }, + }; + + return { + toolCall: normalizedToolCall, + normalizedArgs: toolSpecificArgs, + originalArgKeys, + normalizedArgKeys: Object.keys(toolSpecificArgs), + collisionKeys, + validation, + }; +} + +function parseArguments(rawArguments: string): JsonRecord { + try { + const parsed = JSON.parse(rawArguments); + if (isRecord(parsed)) { + return parsed; + } + return { value: parsed }; + } catch { + return { value: rawArguments }; + } +} + +function normalizeArgumentKeys(args: JsonRecord): { + normalizedArgs: JsonRecord; + collisionKeys: string[]; +} { + const normalizedArgs: JsonRecord = { ...args }; + const collisionKeys: string[] = []; + + for (const [rawKey, rawValue] of Object.entries(args)) { + const canonicalKey = resolveCanonicalArgKey(rawKey); + if (!canonicalKey || canonicalKey === rawKey) { + continue; + } + + const canonicalInOriginal = hasOwn(args, canonicalKey); + const canonicalInNormalized = hasOwn(normalizedArgs, canonicalKey); + if (canonicalInOriginal || canonicalInNormalized) { + collisionKeys.push(rawKey); + delete normalizedArgs[rawKey]; + continue; + } + + normalizedArgs[canonicalKey] = rawValue; + delete normalizedArgs[rawKey]; + } + + return { normalizedArgs, collisionKeys }; +} + +function resolveCanonicalArgKey(rawKey: string): string | null { + const token = rawKey.toLowerCase().replace(/[^a-z0-9]/g, ""); + return ARG_KEY_ALIASES.get(token) ?? null; +} + +function normalizeToolSpecificArgs(toolName: string, args: JsonRecord): JsonRecord { + if (toolName.toLowerCase() !== "todowrite") { + return args; + } + + if (!Array.isArray(args.todos)) { + return args; + } + + const todos = args.todos.map((entry) => { + if (!isRecord(entry)) { + return entry; + } + + const todo: JsonRecord = { ...entry }; + if (typeof todo.status === "string") { + todo.status = normalizeTodoStatus(todo.status); + } + if ( + todo.priority === undefined + || todo.priority === null + || (typeof todo.priority === "string" && todo.priority.trim().length === 0) + ) { + todo.priority = "medium"; + } + return todo; + }); + + return { + ...args, + todos, + }; +} + +function normalizeTodoStatus(status: string): string { + const normalized = status.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (normalized === "todo" || normalized === "pending") { + return "pending"; + } + if (normalized === "inprogress" || normalized === "in_progress") { + return "in_progress"; + } + if (normalized === "done" || normalized === "complete" || normalized === "completed") { + return "completed"; + } + return status; +} + +function validateToolArguments( + toolName: string, + args: JsonRecord, + schema: unknown, +): ToolSchemaValidationResult { + if (!isRecord(schema)) { + return { + hasSchema: false, + ok: true, + missing: [], + unexpected: [], + typeErrors: [], + }; + } + + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = Array.isArray(schema.required) + ? schema.required.filter((value): value is string => typeof value === "string") + : []; + const missing = required.filter((key) => !hasOwn(args, key)); + + const allowAdditional = schema.additionalProperties !== false; + const propertyNames = new Set(Object.keys(properties)); + const unexpected = allowAdditional + ? [] + : Object.keys(args).filter((key) => !propertyNames.has(key)); + + const typeErrors: string[] = []; + for (const [key, value] of Object.entries(args)) { + const propertySchema = properties[key]; + if (!isRecord(propertySchema)) { + continue; + } + if (!matchesType(value, propertySchema.type)) { + if (propertySchema.type !== undefined) { + typeErrors.push(`${key}: expected ${String(propertySchema.type)}`); + } + continue; + } + if ( + Array.isArray(propertySchema.enum) + && !propertySchema.enum.some((candidate) => Object.is(candidate, value)) + ) { + typeErrors.push(`${key}: expected enum ${JSON.stringify(propertySchema.enum)}`); + } + } + + const ok = missing.length === 0 && unexpected.length === 0 && typeErrors.length === 0; + return { + hasSchema: true, + ok, + missing, + unexpected, + typeErrors, + repairHint: ok ? undefined : buildRepairHint(toolName, missing, unexpected, typeErrors), + }; +} + +function buildRepairHint( + toolName: string, + missing: string[], + unexpected: string[], + typeErrors: string[], +): string { + const hints: string[] = []; + if (missing.length > 0) { + hints.push(`missing required: ${missing.join(", ")}`); + } + if (unexpected.length > 0) { + hints.push(`remove unsupported fields: ${unexpected.join(", ")}`); + } + if (typeErrors.length > 0) { + hints.push(`fix type errors: ${typeErrors.join("; ")}`); + } + if ( + toolName.toLowerCase() === "edit" + && (missing.includes("old_string") || missing.includes("new_string")) + ) { + hints.push("edit requires path, old_string, and new_string"); + } + return hints.join(" | "); +} + +function matchesType(value: unknown, schemaType: unknown): boolean { + if (schemaType === undefined) { + return true; + } + if (Array.isArray(schemaType)) { + return schemaType.some((entry) => matchesType(value, entry)); + } + if (typeof schemaType !== "string") { + return true; + } + switch (schemaType) { + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "object": + return isRecord(value); + case "array": + return Array.isArray(value); + case "null": + return value === null; + default: + return true; + } +} + +function hasOwn(record: JsonRecord, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/proxy/tool-loop.ts b/src/proxy/tool-loop.ts index 1e5f8c6..b78cf0b 100644 --- a/src/proxy/tool-loop.ts +++ b/src/proxy/tool-loop.ts @@ -15,6 +15,21 @@ export interface ToolLoopMeta { model: string; } +const TOOL_NAME_ALIASES = new Map([ + // todo write aliases + ["updatetodos", "todowrite"], + ["updatetodostoolcall", "todowrite"], + ["todowrite", "todowrite"], + ["todowritetoolcall", "todowrite"], + ["writetodos", "todowrite"], + ["todowritefn", "todowrite"], + // todo read aliases + ["readtodos", "todoread"], + ["readtodostoolcall", "todoread"], + ["todoread", "todoread"], + ["todoreadtoolcall", "todoread"], +]); + export function extractAllowedToolNames(tools: Array): Set { const names = new Set(); for (const tool of tools) { @@ -35,7 +50,12 @@ export function extractOpenAiToolCall( } const { name, args } = extractToolNameAndArgs(event); - if (!name || !allowedToolNames.has(name)) { + if (!name) { + return null; + } + + const resolvedName = resolveAllowedToolName(name, allowedToolNames); + if (!resolvedName) { return null; } @@ -44,7 +64,7 @@ export function extractOpenAiToolCall( id: callId, type: "function", function: { - name, + name: resolvedName, arguments: toOpenAiArguments(args), }, }; @@ -138,6 +158,37 @@ function normalizeToolName(raw: string): string { return raw; } +function resolveAllowedToolName(name: string, allowedToolNames: Set): string | null { + if (allowedToolNames.has(name)) { + return name; + } + + const normalizedName = normalizeAliasKey(name); + for (const allowedName of allowedToolNames) { + if (normalizeAliasKey(allowedName) === normalizedName) { + return allowedName; + } + } + + const aliasedCanonical = TOOL_NAME_ALIASES.get(normalizedName); + if (!aliasedCanonical) { + return null; + } + + const canonicalNormalized = normalizeAliasKey(aliasedCanonical); + for (const allowedName of allowedToolNames) { + if (normalizeAliasKey(allowedName) === canonicalNormalized) { + return allowedName; + } + } + + return null; +} + +function normalizeAliasKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + function toOpenAiArguments(args: unknown): string { if (args === undefined) { return "{}"; diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index aab991a..5986897 100644 --- a/tests/integration/opencode-loop.integration.test.ts +++ b/tests/integration/opencode-loop.integration.test.ts @@ -33,6 +33,24 @@ const TODO_WRITE_TOOL = { }, }; +const EDIT_TOOL = { + type: "function", + function: { + name: "edit", + description: "Edit a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + old_string: { type: "string" }, + new_string: { type: "string" }, + }, + required: ["path", "old_string", "new_string"], + additionalProperties: false, + }, + }, +}; + const MOCK_CURSOR_AGENT = `#!/usr/bin/env node const fs = require("fs"); @@ -125,6 +143,26 @@ process.stdin.on("end", () => { }, }, ]; + } else if (scenario === "tool-edit-invalid") { + events = [ + { + type: "tool_call", + call_id: "c1", + tool_call: { + editToolCall: { + args: { path: "TODO.md", content: "full rewrite" }, + }, + }, + }, + { + type: "assistant", + timestamp_ms: now + 1, + message: { + role: "assistant", + content: [{ type: "text", text: "edit fallback text" }], + }, + }, + ]; } else { events = [ { @@ -189,6 +227,7 @@ describe("OpenCode-owned tool loop integration", () => { let originalToolsEnabled: string | undefined; let originalReuseExistingProxy: string | undefined; let originalProviderBoundary: string | undefined; + let originalToolLoopMaxRepeat: string | undefined; let mockDir = ""; let promptFile = ""; let argsFile = ""; @@ -200,6 +239,7 @@ describe("OpenCode-owned tool loop integration", () => { originalToolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS; originalReuseExistingProxy = process.env.CURSOR_ACP_REUSE_EXISTING_PROXY; originalProviderBoundary = process.env.CURSOR_ACP_PROVIDER_BOUNDARY; + originalToolLoopMaxRepeat = process.env.CURSOR_ACP_TOOL_LOOP_MAX_REPEAT; mockDir = mkdtempSync(join(tmpdir(), "cursor-agent-mock-")); promptFile = join(mockDir, "prompt.txt"); argsFile = join(mockDir, "args.json"); @@ -213,6 +253,7 @@ describe("OpenCode-owned tool loop integration", () => { process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS = "true"; process.env.CURSOR_ACP_REUSE_EXISTING_PROXY = "false"; process.env.CURSOR_ACP_PROVIDER_BOUNDARY = "v1"; + process.env.CURSOR_ACP_TOOL_LOOP_MAX_REPEAT = "1"; process.env.MOCK_CURSOR_PROMPT_FILE = ""; process.env.MOCK_CURSOR_ARGS_FILE = ""; process.env.MOCK_CURSOR_SCENARIO = "assistant-text"; @@ -263,6 +304,11 @@ describe("OpenCode-owned tool loop integration", () => { } else { process.env.CURSOR_ACP_PROVIDER_BOUNDARY = originalProviderBoundary; } + if (originalToolLoopMaxRepeat === undefined) { + delete process.env.CURSOR_ACP_TOOL_LOOP_MAX_REPEAT; + } else { + process.env.CURSOR_ACP_TOOL_LOOP_MAX_REPEAT = originalToolLoopMaxRepeat; + } delete process.env.MOCK_CURSOR_PROMPT_FILE; delete process.env.MOCK_CURSOR_ARGS_FILE; delete process.env.MOCK_CURSOR_SCENARIO; @@ -331,6 +377,73 @@ describe("OpenCode-owned tool loop integration", () => { expect(json.choices?.[0]?.finish_reason).toBe("tool_calls"); }); + it("does not rewrite edit tool calls to write when args are invalid", async () => { + process.env.MOCK_CURSOR_SCENARIO = "tool-edit-invalid"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: false, + tools: [EDIT_TOOL], + messages: [{ role: "user", content: "Edit TODO.md" }], + }); + + const json: any = await response.json(); + expect(json.choices?.[0]?.message?.tool_calls?.[0]?.function?.name).toBe("edit"); + expect(json.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments).toContain("\"content\""); + expect(json.choices?.[0]?.finish_reason).toBe("tool_calls"); + }); + + it("returns a terminal assistant error chunk when repeated invalid calls exceed loop guard threshold", async () => { + process.env.MOCK_CURSOR_SCENARIO = "tool-edit-invalid"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: true, + tools: [EDIT_TOOL], + messages: [ + { role: "user", content: "Edit TODO.md" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "c1", + type: "function", + function: { + name: "edit", + arguments: "{\"path\":\"TODO.md\",\"content\":\"full rewrite\"}", + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "c1", + content: "Invalid arguments: missing required fields old_string,new_string", + }, + ], + }); + + const body = await response.text(); + const dataLines = parseSseData(body); + const chunks = parseJsonChunks(dataLines); + + const assistantContent = chunks + .map((chunk) => chunk.choices?.[0]?.delta?.content) + .find((value): value is string => typeof value === "string"); + expect(assistantContent).toContain("Tool loop guard stopped repeated failing calls to \"edit\""); + + const toolDelta = chunks.find((chunk) => chunk.choices?.[0]?.delta?.tool_calls?.length); + expect(toolDelta).toBeUndefined(); + + const finishReasons = chunks.map((chunk) => chunk.choices?.[0]?.finish_reason).filter(Boolean); + expect(finishReasons).toContain("stop"); + expect(finishReasons).not.toContain("tool_calls"); + expect(dataLines[dataLines.length - 1]).toBe("[DONE]"); + }); + it("continues on second turn with role tool result and includes TOOL_RESULT in prompt", async () => { process.env.MOCK_CURSOR_SCENARIO = "assistant-text"; process.env.MOCK_CURSOR_PROMPT_FILE = promptFile; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index f6cc356..d774656 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,43 +1,70 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"; -import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "fs"; -import { homedir } from "os"; +import { existsSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs"; import { join } from "path"; +import { tmpdir } from "os"; import { pollForAuthFile, verifyCursorAuth, getAuthFilePath, getPossibleAuthPaths } from "../../src/auth"; const TEST_TIMEOUT = 10000; -const TEST_AUTH_DIR = join(homedir(), ".config", "cursor"); -const TEST_CLI_CONFIG_DIR = join(homedir(), ".cursor"); -const TEST_CONFIG_AUTH_FILE = join(TEST_AUTH_DIR, "auth.json"); -const TEST_CONFIG_CLI_CONFIG_FILE = join(TEST_AUTH_DIR, "cli-config.json"); -const TEST_CURSOR_CLI_CONFIG_FILE = join(TEST_CLI_CONFIG_DIR, "cli-config.json"); -const TEST_CURSOR_AUTH_FILE = join(TEST_CLI_CONFIG_DIR, "auth.json"); +const ORIGINAL_CURSOR_ACP_HOME_DIR = process.env.CURSOR_ACP_HOME_DIR; +const ORIGINAL_HOME = process.env.HOME; const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME; +let testHome = ""; + +function authPaths() { + const home = testHome; + const testAuthDir = join(home, ".config", "cursor"); + const testCliConfigDir = join(home, ".cursor"); + return { + testAuthDir, + testCliConfigDir, + testConfigAuthFile: join(testAuthDir, "auth.json"), + testConfigCliConfigFile: join(testAuthDir, "cli-config.json"), + testCursorCliConfigFile: join(testCliConfigDir, "cli-config.json"), + testCursorAuthFile: join(testCliConfigDir, "auth.json"), + }; +} describe("Auth Module", () => { const cleanupAuthFiles = () => { + const paths = authPaths(); const files = [ - TEST_CONFIG_AUTH_FILE, - TEST_CONFIG_CLI_CONFIG_FILE, - TEST_CURSOR_CLI_CONFIG_FILE, - TEST_CURSOR_AUTH_FILE, + paths.testConfigAuthFile, + paths.testConfigCliConfigFile, + paths.testCursorCliConfigFile, + paths.testCursorAuthFile, ]; for (const file of files) { - if (existsSync(file)) { - unlinkSync(file); - } + rmSync(file, { force: true }); } }; beforeEach(() => { - process.env.XDG_CONFIG_HOME = join(homedir(), ".config"); + testHome = mkdtempSync(join(tmpdir(), "cursor-auth-test-")); + process.env.HOME = testHome; + process.env.XDG_CONFIG_HOME = join(testHome, ".config"); + process.env.CURSOR_ACP_HOME_DIR = testHome; cleanupAuthFiles(); }); afterEach(() => { cleanupAuthFiles(); + if (testHome) { + rmSync(testHome, { recursive: true, force: true }); + testHome = ""; + } }); afterAll(() => { + if (ORIGINAL_CURSOR_ACP_HOME_DIR === undefined) { + delete process.env.CURSOR_ACP_HOME_DIR; + } else { + process.env.CURSOR_ACP_HOME_DIR = ORIGINAL_CURSOR_ACP_HOME_DIR; + } + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } if (ORIGINAL_XDG_CONFIG_HOME === undefined) { delete process.env.XDG_CONFIG_HOME; return; @@ -76,20 +103,22 @@ describe("Auth Module", () => { }); it("should return true when auth file exists", () => { - if (!existsSync(TEST_AUTH_DIR)) { - mkdirSync(TEST_AUTH_DIR, { recursive: true }); + const paths = authPaths(); + if (!existsSync(paths.testAuthDir)) { + mkdirSync(paths.testAuthDir, { recursive: true }); } - writeFileSync(TEST_CONFIG_AUTH_FILE, JSON.stringify({ token: "test" })); + writeFileSync(paths.testConfigAuthFile, JSON.stringify({ token: "test" })); const result = verifyCursorAuth(); expect(result).toBe(true); }); it("should return true when cli-config.json exists", () => { - if (!existsSync(TEST_CLI_CONFIG_DIR)) { - mkdirSync(TEST_CLI_CONFIG_DIR, { recursive: true }); + const paths = authPaths(); + if (!existsSync(paths.testCliConfigDir)) { + mkdirSync(paths.testCliConfigDir, { recursive: true }); } - writeFileSync(TEST_CURSOR_CLI_CONFIG_FILE, JSON.stringify({ accessToken: "test" })); + writeFileSync(paths.testCursorCliConfigFile, JSON.stringify({ accessToken: "test" })); const result = verifyCursorAuth(); expect(result).toBe(true); @@ -98,10 +127,11 @@ describe("Auth Module", () => { describe("pollForAuthFile", () => { it("should return true when auth file already exists", async () => { - if (!existsSync(TEST_AUTH_DIR)) { - mkdirSync(TEST_AUTH_DIR, { recursive: true }); + const paths = authPaths(); + if (!existsSync(paths.testAuthDir)) { + mkdirSync(paths.testAuthDir, { recursive: true }); } - writeFileSync(TEST_CONFIG_AUTH_FILE, JSON.stringify({ token: "test" })); + writeFileSync(paths.testConfigAuthFile, JSON.stringify({ token: "test" })); const result = await pollForAuthFile(1000, 100); expect(result).toBe(true); @@ -116,10 +146,11 @@ describe("Auth Module", () => { const pollPromise = pollForAuthFile(2000, 100); setTimeout(() => { - if (!existsSync(TEST_AUTH_DIR)) { - mkdirSync(TEST_AUTH_DIR, { recursive: true }); + const paths = authPaths(); + if (!existsSync(paths.testAuthDir)) { + mkdirSync(paths.testAuthDir, { recursive: true }); } - writeFileSync(TEST_CONFIG_AUTH_FILE, JSON.stringify({ token: "test" })); + writeFileSync(paths.testConfigAuthFile, JSON.stringify({ token: "test" })); }, 300); const result = await pollPromise; @@ -139,11 +170,12 @@ describe("Auth Module", () => { it("should respect custom interval", async () => { let checkCount = 0; const originalExistsSync = existsSync; + const paths = authPaths(); mock.module("fs", () => ({ ...require("fs"), existsSync: (path: string) => { - if (path === TEST_CONFIG_AUTH_FILE) { + if (path === paths.testConfigAuthFile) { checkCount++; } return originalExistsSync(path); diff --git a/tests/unit/cli/model-discovery.test.ts b/tests/unit/cli/model-discovery.test.ts new file mode 100644 index 0000000..5b2cae5 --- /dev/null +++ b/tests/unit/cli/model-discovery.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { parseCursorModelsOutput } from "../../../src/cli/model-discovery.js"; + +describe("cli/model-discovery", () => { + it("parses model ids and names from cursor-agent output", () => { + const output = ` +auto - Auto (current) (default) +sonnet-4.5 - Claude 4.5 Sonnet +gpt-5.2 - GPT-5.2 +`; + + const models = parseCursorModelsOutput(output); + expect(models).toEqual([ + { id: "auto", name: "Auto" }, + { id: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, + { id: "gpt-5.2", name: "GPT-5.2" }, + ]); + }); + + it("ignores noise and de-duplicates ids", () => { + const output = ` +\u001b[32mauto - Auto (current)\u001b[0m +Tip: run cursor-agent login +auto - Auto +`; + + const models = parseCursorModelsOutput(output); + expect(models).toEqual([{ id: "auto", name: "Auto" }]); + }); +}); diff --git a/tests/unit/provider-runtime-interception.test.ts b/tests/unit/provider-runtime-interception.test.ts index 0049196..a16550b 100644 --- a/tests/unit/provider-runtime-interception.test.ts +++ b/tests/unit/provider-runtime-interception.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { createProviderBoundary } from "../../src/provider/boundary"; +import { createToolLoopGuard } from "../../src/provider/tool-loop-guard"; import { handleToolLoopEventLegacy, handleToolLoopEventV1, @@ -27,6 +28,8 @@ function createBaseOptions(overrides: Partial = {}): EventOptions event, toolLoopMode: "opencode", allowedToolNames: new Set(["read"]), + toolSchemaMap: new Map(), + toolLoopGuard: createToolLoopGuard([], 3), toolMapper: { mapCursorEventToAcp: async () => updates, } as any, @@ -85,6 +88,8 @@ describe("provider runtime interception parity", () => { event, toolLoopMode: "proxy-exec", allowedToolNames: new Set(["read"]), + toolSchemaMap: new Map(), + toolLoopGuard: createToolLoopGuard([], 3), toolMapper: { mapCursorEventToAcp: async () => [{ toolCallId: "u1", status: "pending" }], } as any, @@ -157,7 +162,7 @@ describe("provider runtime interception fallback", () => { }); expect(fallbackCalled).toBe(true); - expect(mapperCalls).toBe(1); + expect(mapperCalls).toBe(0); expect(interceptedName).toBe("read"); expect(result).toEqual({ intercepted: true, skipConverter: true }); }); @@ -195,4 +200,101 @@ describe("provider runtime interception fallback", () => { expect(result).toEqual({ intercepted: true, skipConverter: true }); }); + + it("normalizes v1 arguments using schema compatibility before intercept", async () => { + let interceptedArgs = ""; + const result = await handleToolLoopEventV1({ + ...createBaseOptions({ + event: { + type: "tool_call", + call_id: "c3", + tool_call: { + writeToolCall: { + args: { filePath: "foo.txt", contents: "hello" }, + }, + }, + } as any, + allowedToolNames: new Set(["write"]), + toolSchemaMap: new Map([ + [ + "write", + { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + ], + ]), + onInterceptedToolCall: async (toolCall) => { + interceptedArgs = toolCall.function.arguments; + }, + }), + boundary: createProviderBoundary("v1", "cursor-acp"), + }); + + expect(result).toEqual({ intercepted: true, skipConverter: true }); + expect(interceptedArgs).toContain("\"path\":\"foo.txt\""); + expect(interceptedArgs).toContain("\"content\":\"hello\""); + }); + + it("returns terminal result when loop guard threshold is reached without fallback", async () => { + const guard = createToolLoopGuard( + [{ role: "tool", tool_call_id: "c1", content: "invalid schema: missing path" }], + 1, + ); + guard.evaluate({ + id: "c1", + type: "function", + function: { name: "read", arguments: "{\"path\":\"foo.txt\"}" }, + }); + + const result = await handleToolLoopEventWithFallback({ + ...createBaseOptions({ + toolLoopGuard: guard, + }), + boundary: createProviderBoundary("v1", "cursor-acp"), + boundaryMode: "v1", + autoFallbackToLegacy: false, + }); + + expect(result.intercepted).toBe(false); + expect(result.skipConverter).toBe(true); + expect(result.terminate?.reason).toBe("loop_guard"); + }); + + it("falls back to legacy when loop guard threshold is reached and auto-fallback is enabled", async () => { + let fallbackCalled = false; + let interceptedName = ""; + const guard = createToolLoopGuard( + [{ role: "tool", tool_call_id: "c1", content: "invalid schema: missing path" }], + 1, + ); + guard.evaluate({ + id: "c1", + type: "function", + function: { name: "read", arguments: "{\"path\":\"foo.txt\"}" }, + }); + + const result = await handleToolLoopEventWithFallback({ + ...createBaseOptions({ + toolLoopGuard: guard, + onInterceptedToolCall: async (toolCall) => { + interceptedName = toolCall.function.name; + }, + }), + boundary: createProviderBoundary("v1", "cursor-acp"), + boundaryMode: "v1", + autoFallbackToLegacy: true, + onFallbackToLegacy: () => { + fallbackCalled = true; + }, + }); + + expect(fallbackCalled).toBe(true); + expect(interceptedName).toBe("read"); + expect(result).toEqual({ intercepted: true, skipConverter: true }); + }); }); diff --git a/tests/unit/provider-tool-loop-guard.test.ts b/tests/unit/provider-tool-loop-guard.test.ts new file mode 100644 index 0000000..874a972 --- /dev/null +++ b/tests/unit/provider-tool-loop-guard.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "bun:test"; +import { + createToolLoopGuard, + parseToolLoopMaxRepeat, +} from "../../src/provider/tool-loop-guard"; + +describe("tool loop guard", () => { + it("parses max repeat env with default fallback", () => { + expect(parseToolLoopMaxRepeat(undefined)).toEqual({ value: 3, valid: true }); + expect(parseToolLoopMaxRepeat("4")).toEqual({ value: 4, valid: true }); + expect(parseToolLoopMaxRepeat("0")).toEqual({ value: 3, valid: false }); + expect(parseToolLoopMaxRepeat("abc")).toEqual({ value: 3, valid: false }); + }); + + it("tracks repeated failures using fingerprint and triggers after threshold", () => { + const guard = createToolLoopGuard( + [ + { + role: "tool", + tool_call_id: "c1", + content: "Invalid arguments: missing required field path", + }, + ], + 2, + ); + + const call = { + id: "c1", + type: "function" as const, + function: { + name: "read", + arguments: JSON.stringify({ path: "foo.txt" }), + }, + }; + + const first = guard.evaluate(call); + const second = guard.evaluate(call); + const third = guard.evaluate(call); + + expect(first.triggered).toBe(false); + expect(second.triggered).toBe(false); + expect(third.triggered).toBe(true); + expect(third.repeatCount).toBe(3); + }); + + it("does not track successful tool results", () => { + const guard = createToolLoopGuard( + [ + { + role: "tool", + tool_call_id: "c1", + content: "{\"success\":true}", + }, + ], + 2, + ); + + const decision = guard.evaluate({ + id: "c1", + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ path: "foo.txt" }), + }, + }); + + expect(decision.tracked).toBe(false); + expect(decision.triggered).toBe(false); + }); + + it("resets fingerprint counts", () => { + const guard = createToolLoopGuard( + [ + { + role: "tool", + content: "invalid schema", + }, + ], + 1, + ); + + const call = { + id: "cx", + type: "function" as const, + function: { + name: "edit", + arguments: JSON.stringify({ path: "foo.txt", content: "bar" }), + }, + }; + + const first = guard.evaluate(call); + const second = guard.evaluate(call); + expect(second.triggered).toBe(true); + + guard.resetFingerprint(first.fingerprint); + const third = guard.evaluate(call); + expect(third.triggered).toBe(false); + }); +}); diff --git a/tests/unit/provider-tool-schema-compat.test.ts b/tests/unit/provider-tool-schema-compat.test.ts new file mode 100644 index 0000000..f960127 --- /dev/null +++ b/tests/unit/provider-tool-schema-compat.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "bun:test"; +import { + applyToolSchemaCompat, + buildToolSchemaMap, +} from "../../src/provider/tool-schema-compat"; + +describe("tool schema compatibility", () => { + it("normalizes common argument aliases to canonical keys", () => { + const result = applyToolSchemaCompat( + { + id: "c1", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ + filePath: "/tmp/a.txt", + contents: "hello", + }), + }, + }, + new Map([ + [ + "write", + { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + ], + ]), + ); + + expect(result.normalizedArgs.path).toBe("/tmp/a.txt"); + expect(result.normalizedArgs.content).toBe("hello"); + expect(result.normalizedArgs.filePath).toBeUndefined(); + expect(result.normalizedArgs.contents).toBeUndefined(); + expect(result.validation.ok).toBe(true); + }); + + it("keeps canonical keys when aliases collide", () => { + const result = applyToolSchemaCompat( + { + id: "c1", + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ + path: "/canonical.txt", + filePath: "/alias.txt", + }), + }, + }, + new Map(), + ); + + expect(result.normalizedArgs.path).toBe("/canonical.txt"); + expect(result.normalizedArgs.filePath).toBeUndefined(); + expect(result.collisionKeys).toContain("filePath"); + }); + + it("normalizes todowrite statuses and default priority", () => { + const result = applyToolSchemaCompat( + { + id: "c1", + type: "function", + function: { + name: "todowrite", + arguments: JSON.stringify({ + todos: [ + { content: "Book flights", status: "todo" }, + { content: "Reserve hotel", status: "in-progress", priority: "high" }, + { content: "Buy adapter", status: "done" }, + ], + }), + }, + }, + new Map(), + ); + + const todos = result.normalizedArgs.todos as Array; + expect(todos[0].status).toBe("pending"); + expect(todos[0].priority).toBe("medium"); + expect(todos[1].status).toBe("in_progress"); + expect(todos[1].priority).toBe("high"); + expect(todos[2].status).toBe("completed"); + expect(todos[2].priority).toBe("medium"); + }); + + it("keeps edit semantics and surfaces validation hints for missing fields", () => { + const result = applyToolSchemaCompat( + { + id: "c1", + type: "function", + function: { + name: "edit", + arguments: JSON.stringify({ + path: "/tmp/todo.md", + content: "new full content", + }), + }, + }, + new Map([ + [ + "edit", + { + type: "object", + properties: { + path: { type: "string" }, + old_string: { type: "string" }, + new_string: { type: "string" }, + }, + required: ["path", "old_string", "new_string"], + additionalProperties: false, + }, + ], + ]), + ); + + const args = JSON.parse(result.toolCall.function.arguments); + expect(args.content).toBe("new full content"); + expect(result.validation.ok).toBe(false); + expect(result.validation.missing).toEqual(["old_string", "new_string"]); + expect(result.validation.repairHint).toContain("edit requires path, old_string, and new_string"); + }); + + it("builds schema map from request tools", () => { + const map = buildToolSchemaMap([ + { + type: "function", + function: { + name: "read", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + { + name: "todowrite", + parameters: { + type: "object", + properties: { todos: { type: "array" } }, + required: ["todos"], + }, + }, + ]); + + expect(map.has("read")).toBe(true); + expect(map.has("todowrite")).toBe(true); + }); +}); diff --git a/tests/unit/proxy/tool-loop.test.ts b/tests/unit/proxy/tool-loop.test.ts index 6f7993f..250dd10 100644 --- a/tests/unit/proxy/tool-loop.test.ts +++ b/tests/unit/proxy/tool-loop.test.ts @@ -77,6 +77,23 @@ describe("proxy/tool-loop", () => { expect(call).toBeNull(); }); + it("maps updateTodos alias to allowed todowrite tool name", () => { + const event: any = { + type: "tool_call", + call_id: "call_4", + name: "updateTodos", + tool_call: { + updateTodos: { + args: { todos: [{ content: "Book flights", status: "pending" }] }, + }, + }, + }; + + const call = extractOpenAiToolCall(event, new Set(["todowrite"])); + expect(call).not.toBeNull(); + expect(call?.function.name).toBe("todowrite"); + }); + it("builds valid non-stream tool call response", () => { const response = createToolCallCompletionResponse( { id: "resp-1", created: 123, model: "cursor-acp/auto" }, From 6b0b0771780febbb1a30dea139b2235e58d0c7c8 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Wed, 11 Feb 2026 00:26:43 +1100 Subject: [PATCH 2/4] feat: error classification with isRecoverableError and parse logging - Extend ParsedError with recoverable boolean field - Add isRecoverableError() for retry decision logic - Network errors recoverable, auth/quota/model errors fatal - Unknown errors with timeout/ETIMEDOUT classified as recoverable - Add debug-level logging for failed NDJSON parse in streaming parser - Add optional errorType to ExecutionResult for executor awareness - 25 new unit tests covering all error type classifications --- src/streaming/parser.ts | 4 ++ src/tools/core/types.ts | 1 + src/utils/errors.ts | 14 ++++ tests/unit/errors.test.ts | 141 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 tests/unit/errors.test.ts diff --git a/src/streaming/parser.ts b/src/streaming/parser.ts index d76a7e8..e842453 100644 --- a/src/streaming/parser.ts +++ b/src/streaming/parser.ts @@ -1,4 +1,7 @@ import type { StreamJsonEvent } from "./types.js"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("streaming:parser"); export const parseStreamJsonLine = (line: string): StreamJsonEvent | null => { const trimmed = line.trim(); @@ -13,6 +16,7 @@ export const parseStreamJsonLine = (line: string): StreamJsonEvent | null => { } return parsed as StreamJsonEvent; } catch { + log.debug("Failed to parse NDJSON line", { line: trimmed.substring(0, 100) }); return null; } }; diff --git a/src/tools/core/types.ts b/src/tools/core/types.ts index 1315895..68e58bf 100644 --- a/src/tools/core/types.ts +++ b/src/tools/core/types.ts @@ -2,6 +2,7 @@ export interface ExecutionResult { status: "success" | "error"; output?: string; error?: string; + errorType?: "recoverable" | "fatal"; } export interface IToolExecutor { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 22dd3ba..202e25f 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -4,6 +4,7 @@ export type ErrorType = "quota" | "auth" | "network" | "model" | "unknown"; export interface ParsedError { type: ErrorType; + recoverable: boolean; message: string; userMessage: string; details: Record; @@ -38,6 +39,7 @@ export function parseAgentError(stderr: string | unknown): ParsedError { return { type: "quota", + recoverable: false, message: clean, userMessage: "You've hit your Cursor usage limit", details, @@ -49,6 +51,7 @@ export function parseAgentError(stderr: string | unknown): ParsedError { if (clean.includes("not logged in") || clean.includes("auth") || clean.includes("unauthorized")) { return { type: "auth", + recoverable: false, message: clean, userMessage: "Not authenticated with Cursor", details: {}, @@ -60,6 +63,7 @@ export function parseAgentError(stderr: string | unknown): ParsedError { if (clean.includes("ECONNREFUSED") || clean.includes("network") || clean.includes("fetch failed")) { return { type: "network", + recoverable: true, message: clean, userMessage: "Connection to Cursor failed", details: {}, @@ -79,6 +83,7 @@ export function parseAgentError(stderr: string | unknown): ParsedError { return { type: "model", + recoverable: false, message: clean, userMessage: modelMatch ? `Model '${modelMatch[1]}' not available` : "Requested model not available", details, @@ -87,14 +92,23 @@ export function parseAgentError(stderr: string | unknown): ParsedError { } // Unknown error + const recoverable = clean.includes("timeout") || clean.includes("ETIMEDOUT"); return { type: "unknown", + recoverable, message: clean, userMessage: clean.substring(0, 200) || "An error occurred", details: {}, }; } +/** + * Check if an error is recoverable (worth retrying). + */ +export function isRecoverableError(error: ParsedError): boolean { + return error.recoverable; +} + /** * Format parsed error for user display */ diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts new file mode 100644 index 0000000..ddc59a9 --- /dev/null +++ b/tests/unit/errors.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; + +import { + parseAgentError, + isRecoverableError, + formatErrorForUser, + stripAnsi, +} from "../../src/utils/errors.js"; + +describe("parseAgentError", () => { + it("classifies quota errors as non-recoverable", () => { + const err = parseAgentError("You've hit your usage limit for claude-3.5-sonnet"); + expect(err.type).toBe("quota"); + expect(err.recoverable).toBe(false); + expect(err.userMessage).toContain("usage limit"); + }); + + it("classifies auth errors as non-recoverable", () => { + const err = parseAgentError("Error: not logged in"); + expect(err.type).toBe("auth"); + expect(err.recoverable).toBe(false); + }); + + it("classifies network errors as recoverable", () => { + const err = parseAgentError("Error: fetch failed ECONNREFUSED"); + expect(err.type).toBe("network"); + expect(err.recoverable).toBe(true); + }); + + it("classifies model errors as non-recoverable", () => { + const err = parseAgentError("Cannot use this model: gpt-5"); + expect(err.type).toBe("model"); + expect(err.recoverable).toBe(false); + }); + + it("classifies unknown timeout errors as recoverable", () => { + const err = parseAgentError("request timeout after 30s"); + expect(err.type).toBe("unknown"); + expect(err.recoverable).toBe(true); + }); + + it("classifies unknown ETIMEDOUT errors as recoverable", () => { + const err = parseAgentError("connect ETIMEDOUT 1.2.3.4:443"); + expect(err.type).toBe("unknown"); + expect(err.recoverable).toBe(true); + }); + + it("classifies generic unknown errors as non-recoverable", () => { + const err = parseAgentError("something went wrong"); + expect(err.type).toBe("unknown"); + expect(err.recoverable).toBe(false); + }); + + it("extracts quota details when present", () => { + const err = parseAgentError( + "You've hit your usage limit. You saved $5.50. Reset on 02/15/2026. Continue with claude.", + ); + expect(err.type).toBe("quota"); + expect(err.details.savings).toBe("$5.50"); + expect(err.details.resetDate).toBe("02/15/2026"); + }); + + it("extracts model details when present", () => { + const err = parseAgentError( + "Cannot use this model: gpt-5. Available models: auto, claude-3.5-sonnet, gpt-4o", + ); + expect(err.details.requested).toBe("gpt-5"); + expect(err.details.available).toBeDefined(); + }); + + it("handles non-string input", () => { + const err = parseAgentError(null); + expect(err.type).toBe("unknown"); + expect(err.recoverable).toBe(false); + }); + + it("handles empty string", () => { + const err = parseAgentError(""); + expect(err.type).toBe("unknown"); + expect(err.userMessage).toBe("An error occurred"); + }); +}); + +describe("isRecoverableError", () => { + it("returns true for network errors", () => { + const err = parseAgentError("ECONNREFUSED 127.0.0.1:443"); + expect(isRecoverableError(err)).toBe(true); + }); + + it("returns false for auth errors", () => { + const err = parseAgentError("not logged in"); + expect(isRecoverableError(err)).toBe(false); + }); + + it("returns false for quota errors", () => { + const err = parseAgentError("hit your usage limit"); + expect(isRecoverableError(err)).toBe(false); + }); + + it("returns false for model errors", () => { + const err = parseAgentError("model not found"); + expect(isRecoverableError(err)).toBe(false); + }); + + it("returns true for timeout in unknown category", () => { + const err = parseAgentError("operation timeout"); + expect(isRecoverableError(err)).toBe(true); + }); + + it("returns false for generic unknown errors", () => { + const err = parseAgentError("segfault"); + expect(isRecoverableError(err)).toBe(false); + }); +}); + +describe("formatErrorForUser", () => { + it("formats basic error", () => { + const err = parseAgentError("not logged in"); + const msg = formatErrorForUser(err); + expect(msg).toContain("cursor-acp error"); + expect(msg).toContain("Not authenticated"); + expect(msg).toContain("Suggestion:"); + }); + + it("formats error with details", () => { + const err = parseAgentError("Cannot use this model: gpt-5"); + const msg = formatErrorForUser(err); + expect(msg).toContain("gpt-5"); + }); +}); + +describe("stripAnsi", () => { + it("strips ANSI codes", () => { + expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red"); + }); + + it("handles non-string input", () => { + expect(stripAnsi(42 as any)).toBe("42"); + expect(stripAnsi(null as any)).toBe(""); + }); +}); From 0bef1cebe4cbb953b9654abd59aa36ca72a3e1d8 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Wed, 11 Feb 2026 00:26:55 +1100 Subject: [PATCH 3/4] feat: request performance instrumentation with 5-phase timing - Add RequestPerf class for lightweight phase timing - Instrument Bun and Node.js streaming paths with 5 markers: request:start, spawn, first-token, tool-call, request:done - Add tool execution duration logging in ToolRouter - Timing summary logged at debug level via summarize() - 6 new unit tests for perf tracker --- src/plugin.ts | 392 ++++++++++++++++++++++++++++++---------- src/tools/router.ts | 6 +- src/utils/perf.ts | 44 +++++ tests/unit/perf.test.ts | 60 ++++++ 4 files changed, 407 insertions(+), 95 deletions(-) create mode 100644 src/utils/perf.ts create mode 100644 tests/unit/perf.test.ts diff --git a/src/plugin.ts b/src/plugin.ts index 854428e..6cb75c7 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -11,11 +11,11 @@ import { StreamToSseConverter, formatSseDone } from "./streaming/openai-sse.js"; import { parseStreamJsonLine } from "./streaming/parser.js"; import { extractText, extractThinking, isAssistantText, isThinking } from "./streaming/types.js"; import { createLogger } from "./utils/logger"; +import { RequestPerf } from "./utils/perf"; import { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors"; import { buildPromptFromMessages } from "./proxy/prompt-builder.js"; import { extractAllowedToolNames, - extractOpenAiToolCall, type OpenAiToolCall, } from "./proxy/tool-loop.js"; import { OpenCodeToolDiscovery } from "./tools/discovery.js"; @@ -39,6 +39,12 @@ import { type ToolOptionResolution, } from "./provider/boundary.js"; import { handleToolLoopEventWithFallback } from "./provider/runtime-interception.js"; +import { buildToolSchemaMap } from "./provider/tool-schema-compat.js"; +import { + createToolLoopGuard, + parseToolLoopMaxRepeat, + type ToolLoopGuard, +} from "./provider/tool-loop-guard.js"; const log = createLogger("plugin"); @@ -88,6 +94,11 @@ const PROVIDER_BOUNDARY = : createProviderBoundary(PROVIDER_BOUNDARY_MODE, CURSOR_PROVIDER_ID); const ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK = process.env.CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK === "true"; +const TOOL_LOOP_MAX_REPEAT_RAW = process.env.CURSOR_ACP_TOOL_LOOP_MAX_REPEAT; +const { + value: TOOL_LOOP_MAX_REPEAT, + valid: TOOL_LOOP_MAX_REPEAT_VALID, +} = parseToolLoopMaxRepeat(TOOL_LOOP_MAX_REPEAT_RAW); const { proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS, suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS, @@ -257,38 +268,71 @@ function createBoundaryRuntimeContext(scope: string) { }; } -function findFirstAllowedToolCallInOutput( +async function findFirstAllowedToolCallInOutput( output: string, - allowedToolNames: Set, - toolLoopMode: ToolLoopMode, - boundary: ProviderBoundary, -): OpenAiToolCall | null { - if (allowedToolNames.size === 0 || !output) { - return null; + options: { + toolLoopMode: ToolLoopMode; + allowedToolNames: Set; + toolSchemaMap: Map; + toolLoopGuard: ToolLoopGuard; + boundaryContext: ReturnType; + responseMeta: { id: string; created: number; model: string }; + }, +): Promise<{ toolCall: OpenAiToolCall | null; terminationMessage: string | null }> { + if (options.allowedToolNames.size === 0 || !output) { + return { toolCall: null, terminationMessage: null }; } + const toolMapper = new ToolMapper(); + const toolSessionId = options.responseMeta.id; + for (const line of output.split("\n")) { const event = parseStreamJsonLine(line); if (!event || event.type !== "tool_call") { continue; } - const toolCall = - boundary.mode === "legacy" - ? toolLoopMode === "opencode" - ? extractOpenAiToolCall(event as any, allowedToolNames) - : null - : boundary.maybeExtractToolCall( - event as any, - allowedToolNames, - toolLoopMode, - ); - if (toolCall) { - return toolCall; + let interceptedToolCall: OpenAiToolCall | null = null; + const result = await handleToolLoopEventWithFallback({ + event: event as any, + boundary: options.boundaryContext.getBoundary(), + boundaryMode: options.boundaryContext.getBoundary().mode, + autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK, + toolLoopMode: options.toolLoopMode, + allowedToolNames: options.allowedToolNames, + toolSchemaMap: options.toolSchemaMap, + toolLoopGuard: options.toolLoopGuard, + toolMapper, + toolSessionId, + shouldEmitToolUpdates: false, + proxyExecuteToolCalls: false, + suppressConverterToolEvents: false, + responseMeta: options.responseMeta, + onToolUpdate: () => {}, + onToolResult: () => {}, + onInterceptedToolCall: (toolCall) => { + interceptedToolCall = toolCall; + }, + onFallbackToLegacy: (error) => { + options.boundaryContext.activateLegacyFallback("findFirstAllowedToolCallInOutput", error); + }, + }); + + if (result.terminate) { + return { + toolCall: null, + terminationMessage: result.terminate.message, + }; + } + if (result.intercepted && interceptedToolCall) { + return { + toolCall: interceptedToolCall, + terminationMessage: null, + }; } } - return null; + return { toolCall: null, terminationMessage: null }; } async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: ToolRouter): Promise { @@ -360,17 +404,27 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: }); } - const body: any = await req.json().catch(() => ({})); - const messages: Array = Array.isArray(body?.messages) ? body.messages : []; - const stream = body?.stream === true; - const tools = Array.isArray(body?.tools) ? body.tools : []; - const allowedToolNames = extractAllowedToolNames(tools); - const boundaryContext = createBoundaryRuntimeContext("bun-handler"); + log.debug("Proxy request (bun)", { method: req.method, path: url.pathname }); + const body: any = await req.json().catch(() => ({})); + const messages: Array = Array.isArray(body?.messages) ? body.messages : []; + const stream = body?.stream === true; + const tools = Array.isArray(body?.tools) ? body.tools : []; + const allowedToolNames = extractAllowedToolNames(tools); + const toolSchemaMap = buildToolSchemaMap(tools); + const toolLoopGuard = createToolLoopGuard(messages, TOOL_LOOP_MAX_REPEAT); + const boundaryContext = createBoundaryRuntimeContext("bun-handler"); const prompt = buildPromptFromMessages(messages, tools); const model = boundaryContext.run("normalizeRuntimeModel", (boundary) => boundary.normalizeRuntimeModel(body?.model), ); + log.debug("Proxy chat request (bun)", { + stream, + model, + messages: messages.length, + tools: tools.length, + promptChars: prompt.length, + }); const bunAny = globalThis as any; if (!bunAny.Bun?.spawn) { @@ -415,29 +469,41 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const stdout = (stdoutText || "").trim(); const stderr = (stderrText || "").trim(); - const toolCall = boundaryContext.run( - "findFirstAllowedToolCallInOutput", - (boundary) => - findFirstAllowedToolCallInOutput( - stdout, - allowedToolNames, - TOOL_LOOP_MODE, - boundary, - ), - ); - if (toolCall) { + const exitCode = child.exitCode; + log.debug("cursor-agent completed (bun non-stream)", { + exitCode, + stdoutChars: stdout.length, + stderrChars: stderr.length, + }); + const meta = { + id: `cursor-acp-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model, + }; + const intercepted = await findFirstAllowedToolCallInOutput(stdout, { + toolLoopMode: TOOL_LOOP_MODE, + allowedToolNames, + toolSchemaMap, + toolLoopGuard, + boundaryContext, + responseMeta: meta, + }); + if (intercepted.terminationMessage) { + const payload = createChatCompletionResponse(model, intercepted.terminationMessage); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (intercepted.toolCall) { log.debug("Intercepted OpenCode tool call (non-stream)", { - name: toolCall.function.name, - callId: toolCall.id, + name: intercepted.toolCall.function.name, + callId: intercepted.toolCall.id, }); - const meta = { - id: `cursor-acp-${Date.now()}`, - created: Math.floor(Date.now() / 1000), - model, - }; const payload = boundaryContext.run( "createNonStreamToolCallResponse", - (boundary) => boundary.createNonStreamToolCallResponse(meta, toolCall), + (boundary) => boundary.createNonStreamToolCallResponse(meta, intercepted.toolCall), ); return new Response(JSON.stringify(payload), { status: 200, @@ -445,12 +511,18 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: }); } - // cursor-agent sometimes returns non-zero even with usable stdout. - // Treat stdout as success unless we have explicit stderr. - if (child.exitCode !== 0 && stderr.length > 0) { - const parsed = parseAgentError(stderr); + if (exitCode !== 0) { + const errSource = + stderr + || stdout + || `cursor-agent exited with code ${String(exitCode ?? "unknown")} and no output`; + const parsed = parseAgentError(errSource); const userError = formatErrorForUser(parsed); - log.error("cursor-cli failed", { type: parsed.type, message: parsed.message }); + log.error("cursor-cli failed", { + type: parsed.type, + message: parsed.message, + code: exitCode, + }); // Return error as chat completion so user always sees it const errorPayload = createChatCompletionResponse(model, userError); return new Response(JSON.stringify(errorPayload), { @@ -475,12 +547,15 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const encoder = new TextEncoder(); const id = `cursor-acp-${Date.now()}`; const created = Math.floor(Date.now() / 1000); + const perf = new RequestPerf(id); const toolMapper = new ToolMapper(); const toolSessionId = id; + perf.mark("spawn"); const sse = new ReadableStream({ async start(controller) { let streamTerminated = false; + let firstTokenReceived = false; try { const reader = (child.stdout as ReadableStream).getReader(); const converter = new StreamToSseConverter(model, { id, created }); @@ -506,12 +581,27 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; + const emitTerminalAssistantErrorAndTerminate = (message: string) => { + if (streamTerminated) { + return; + } + const errChunk = createChatCompletionChunk(id, created, model, message, true); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`)); + controller.enqueue(encoder.encode(formatSseDone())); + streamTerminated = true; + try { + child.kill(); + } catch { + // ignore + } + }; while (true) { if (streamTerminated) break; const { value, done } = await reader.read(); if (done) break; if (!value || value.length === 0) continue; + if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; } for (const line of lineBuffer.push(value)) { if (streamTerminated) break; @@ -521,6 +611,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } if (event.type === "tool_call") { + perf.mark("tool-call"); const result = await handleToolLoopEventWithFallback({ event: event as any, boundary: boundaryContext.getBoundary(), @@ -528,6 +619,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK, toolLoopMode: TOOL_LOOP_MODE, allowedToolNames, + toolSchemaMap, + toolLoopGuard, toolMapper, toolSessionId, shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, @@ -548,6 +641,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: boundaryContext.activateLegacyFallback("handleToolLoopEvent", error); }, }); + if (result.terminate) { + emitTerminalAssistantErrorAndTerminate(result.terminate.message); + break; + } if (result.intercepted) { break; } @@ -579,6 +676,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK, toolLoopMode: TOOL_LOOP_MODE, allowedToolNames, + toolSchemaMap, + toolLoopGuard, toolMapper, toolSessionId, shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, @@ -599,6 +698,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: boundaryContext.activateLegacyFallback("handleToolLoopEvent.flush", error); }, }); + if (result.terminate) { + emitTerminalAssistantErrorAndTerminate(result.terminate.message); + break; + } if (result.intercepted) { break; } @@ -616,19 +719,29 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: if (child.exitCode !== 0) { const stderrText = await new Response(child.stderr).text(); - const parsed = parseAgentError(stderrText); + const errSource = (stderrText || "").trim() + || `cursor-agent exited with code ${String(child.exitCode ?? "unknown")} and no output`; + const parsed = parseAgentError(errSource); const msg = formatErrorForUser(parsed); - log.error("cursor-cli streaming failed", { type: parsed.type }); + log.error("cursor-cli streaming failed", { + type: parsed.type, + code: child.exitCode, + }); const errChunk = createChatCompletionChunk(id, created, model, msg, true); controller.enqueue(encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`)); controller.enqueue(encoder.encode(formatSseDone())); return; } + log.debug("cursor-agent completed (bun stream)", { + exitCode: child.exitCode, + }); const doneChunk = createChatCompletionChunk(id, created, model, "", true); controller.enqueue(encoder.encode(`data: ${JSON.stringify(doneChunk)}\n\n`)); controller.enqueue(encoder.encode(formatSseDone())); } finally { + perf.mark("request:done"); + perf.summarize(); controller.close(); } }, @@ -712,6 +825,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: return; } + log.debug("Proxy request (node)", { method: req.method, path: url.pathname }); let body = ""; for await (const chunk of req) { body += chunk; @@ -722,12 +836,21 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const stream = bodyData?.stream === true; const tools = Array.isArray(bodyData?.tools) ? bodyData.tools : []; const allowedToolNames = extractAllowedToolNames(tools); + const toolSchemaMap = buildToolSchemaMap(tools); + const toolLoopGuard = createToolLoopGuard(messages, TOOL_LOOP_MAX_REPEAT); const boundaryContext = createBoundaryRuntimeContext("node-handler"); const prompt = buildPromptFromMessages(messages, tools); const model = boundaryContext.run("normalizeRuntimeModel", (boundary) => boundary.normalizeRuntimeModel(bodyData?.model), ); + log.debug("Proxy chat request (node)", { + stream, + model, + messages: messages.length, + tools: tools.length, + promptChars: prompt.length, + }); const cmd = [ "cursor-agent", @@ -753,6 +876,12 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: if (!stream) { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; + let spawnErrorText: string | null = null; + + child.on("error", (error: any) => { + spawnErrorText = String(error?.message || error); + log.error("Failed to spawn cursor-agent", { error: spawnErrorText, model }); + }); child.stdout.on("data", (chunk) => stdoutChunks.push(chunk)); child.stderr.on("data", (chunk) => stderrChunks.push(chunk)); @@ -760,29 +889,40 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: child.on("close", async (code) => { const stdout = Buffer.concat(stdoutChunks).toString().trim(); const stderr = Buffer.concat(stderrChunks).toString().trim(); - const toolCall = boundaryContext.run( - "findFirstAllowedToolCallInOutput", - (boundary) => - findFirstAllowedToolCallInOutput( - stdout, - allowedToolNames, - TOOL_LOOP_MODE, - boundary, - ), - ); - if (toolCall) { + log.debug("cursor-agent completed (node non-stream)", { + code, + stdoutChars: stdout.length, + stderrChars: stderr.length, + spawnError: spawnErrorText != null, + }); + const meta = { + id: `cursor-acp-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model, + }; + const intercepted = await findFirstAllowedToolCallInOutput(stdout, { + toolLoopMode: TOOL_LOOP_MODE, + allowedToolNames, + toolSchemaMap, + toolLoopGuard, + boundaryContext, + responseMeta: meta, + }); + if (intercepted.terminationMessage) { + const terminationResponse = createChatCompletionResponse(model, intercepted.terminationMessage); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(terminationResponse)); + return; + } + + if (intercepted.toolCall) { log.debug("Intercepted OpenCode tool call (non-stream)", { - name: toolCall.function.name, - callId: toolCall.id, + name: intercepted.toolCall.function.name, + callId: intercepted.toolCall.id, }); - const meta = { - id: `cursor-acp-${Date.now()}`, - created: Math.floor(Date.now() / 1000), - model, - }; const payload = boundaryContext.run( "createNonStreamToolCallResponse", - (boundary) => boundary.createNonStreamToolCallResponse(meta, toolCall), + (boundary) => boundary.createNonStreamToolCallResponse(meta, intercepted.toolCall), ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(payload)); @@ -791,10 +931,19 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const completion = extractCompletionFromStream(stdout); - if (code !== 0 && stderr.length > 0) { - const parsed = parseAgentError(stderr); + if (code !== 0 || spawnErrorText) { + const errSource = + stderr + || stdout + || spawnErrorText + || `cursor-agent exited with code ${String(code ?? "unknown")} and no output`; + const parsed = parseAgentError(errSource); const userError = formatErrorForUser(parsed); - log.error("cursor-cli failed", { type: parsed.type, message: parsed.message }); + log.error("cursor-cli failed", { + type: parsed.type, + message: parsed.message, + code, + }); // Return error as chat completion so user always sees it const errorResponse = createChatCompletionResponse(model, userError); res.writeHead(200, { "Content-Type": "application/json" }); @@ -821,12 +970,33 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const id = `cursor-acp-${Date.now()}`; const created = Math.floor(Date.now() / 1000); + const perf = new RequestPerf(id); + perf.mark("spawn"); const converter = new StreamToSseConverter(model, { id, created }); const lineBuffer = new LineBuffer(); const toolMapper = new ToolMapper(); const toolSessionId = id; + const stderrChunks: Buffer[] = []; let streamTerminated = false; + let firstTokenReceived = false; + child.stderr.on("data", (chunk) => { + stderrChunks.push(Buffer.from(chunk)); + }); + child.on("error", (error: any) => { + if (streamTerminated || res.writableEnded) { + return; + } + const errSource = String(error?.message || error); + log.error("Failed to spawn cursor-agent (stream)", { error: errSource, model }); + const parsed = parseAgentError(errSource); + const msg = formatErrorForUser(parsed); + const errChunk = createChatCompletionChunk(id, created, model, msg, true); + res.write(`data: ${JSON.stringify(errChunk)}\n\n`); + res.write(formatSseDone()); + streamTerminated = true; + res.end(); + }); const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => { if (streamTerminated || res.writableEnded) { return; @@ -852,11 +1022,27 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; + const emitTerminalAssistantErrorAndTerminate = (message: string) => { + if (streamTerminated || res.writableEnded) { + return; + } + const errChunk = createChatCompletionChunk(id, created, model, message, true); + res.write(`data: ${JSON.stringify(errChunk)}\n\n`); + res.write(formatSseDone()); + streamTerminated = true; + res.end(); + try { + child.kill(); + } catch { + // ignore + } + }; child.stdout.on("data", async (chunk) => { if (streamTerminated || res.writableEnded) { return; } + if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; } for (const line of lineBuffer.push(chunk)) { if (streamTerminated || res.writableEnded) { break; @@ -867,6 +1053,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } if (event.type === "tool_call") { + perf.mark("tool-call"); const result = await handleToolLoopEventWithFallback({ event: event as any, boundary: boundaryContext.getBoundary(), @@ -874,6 +1061,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK, toolLoopMode: TOOL_LOOP_MODE, allowedToolNames, + toolSchemaMap, + toolLoopGuard, toolMapper, toolSessionId, shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, @@ -894,6 +1083,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: boundaryContext.activateLegacyFallback("handleToolLoopEvent", error); }, }); + if (result.terminate) { + emitTerminalAssistantErrorAndTerminate(result.terminate.message); + break; + } if (result.intercepted) { break; } @@ -932,6 +1125,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK, toolLoopMode: TOOL_LOOP_MODE, allowedToolNames, + toolSchemaMap, + toolLoopGuard, toolMapper, toolSessionId, shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, @@ -952,6 +1147,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: boundaryContext.activateLegacyFallback("handleToolLoopEvent.close", error); }, }); + if (result.terminate) { + emitTerminalAssistantErrorAndTerminate(result.terminate.message); + break; + } if (result.intercepted) { break; } @@ -971,26 +1170,25 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: return; } + perf.mark("request:done"); + perf.summarize(); + const stderrText = Buffer.concat(stderrChunks).toString().trim(); + log.debug("cursor-agent completed (node stream)", { + code, + stderrChars: stderrText.length, + }); if (code !== 0) { - child.stderr.on("data", (chunk) => { - if (streamTerminated || res.writableEnded) { - return; - } - const errChunk = { - id, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { content: `cursor-agent failed: ${chunk.toString()}` }, - finish_reason: "stop", - }, - ], - }; - res.write(`data: ${JSON.stringify(errChunk)}\n\n`); - }); + const errSource = + stderrText + || `cursor-agent exited with code ${String(code ?? "unknown")} and no output`; + const parsed = parseAgentError(errSource); + const msg = formatErrorForUser(parsed); + const errChunk = createChatCompletionChunk(id, created, model, msg, true); + res.write(`data: ${JSON.stringify(errChunk)}\n\n`); + res.write(formatSseDone()); + streamTerminated = true; + res.end(); + return; } const doneChunk = { @@ -1166,6 +1364,11 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }: value: PROVIDER_BOUNDARY_MODE_RAW, }); } + if (!TOOL_LOOP_MAX_REPEAT_VALID) { + log.warn("Invalid CURSOR_ACP_TOOL_LOOP_MAX_REPEAT; defaulting to 3", { + value: TOOL_LOOP_MAX_REPEAT_RAW, + }); + } if (ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK && PROVIDER_BOUNDARY.mode !== "v1") { log.debug("Provider boundary auto-fallback is enabled but inactive unless mode=v1"); } @@ -1174,6 +1377,7 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }: providerBoundary: PROVIDER_BOUNDARY.mode, proxyExecToolCalls: PROXY_EXECUTE_TOOL_CALLS, providerBoundaryAutoFallback: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK, + toolLoopMaxRepeat: TOOL_LOOP_MAX_REPEAT, }); await ensurePluginDirectory(); diff --git a/src/tools/router.ts b/src/tools/router.ts index c33017a..ac74c18 100644 --- a/src/tools/router.ts +++ b/src/tools/router.ts @@ -58,9 +58,13 @@ export class ToolRouter { const args = this.extractArgs(event); log.debug("Executing tool", { name, toolId: tool.id }); + const t0 = Date.now(); const result = await this.ctx.execute(tool.id, args); + const elapsed = Date.now() - t0; if (result.status === "error") { - log.warn("Tool execution returned error", { name, error: result.error }); + log.warn("Tool execution returned error", { name, error: result.error, elapsed }); + } else { + log.debug("Tool execution completed", { name, toolId: tool.id, elapsed }); } return this.buildResult(meta, callId, name, result); } diff --git a/src/utils/perf.ts b/src/utils/perf.ts new file mode 100644 index 0000000..c85303f --- /dev/null +++ b/src/utils/perf.ts @@ -0,0 +1,44 @@ +import { createLogger } from "./logger.js"; + +const log = createLogger("perf"); + +export interface PerfMarker { + name: string; + ts: number; +} + +export class RequestPerf { + private markers: PerfMarker[] = []; + private readonly requestId: string; + + constructor(requestId: string) { + this.requestId = requestId; + this.mark("request:start"); + } + + mark(name: string): void { + this.markers.push({ name, ts: Date.now() }); + } + + /** Log timing summary at debug level. Call once at request end. */ + summarize(): void { + if (this.markers.length < 2) return; + const start = this.markers[0].ts; + const phases: Record = {}; + for (let i = 1; i < this.markers.length; i++) { + phases[this.markers[i].name] = this.markers[i].ts - this.markers[i - 1].ts; + } + const total = this.markers[this.markers.length - 1].ts - start; + log.debug("Request timing", { requestId: this.requestId, total, phases }); + } + + /** Get elapsed ms since construction. */ + elapsed(): number { + return this.markers.length > 0 ? Date.now() - this.markers[0].ts : 0; + } + + /** Get all markers (for testing). */ + getMarkers(): ReadonlyArray { + return this.markers; + } +} diff --git a/tests/unit/perf.test.ts b/tests/unit/perf.test.ts new file mode 100644 index 0000000..8e92f66 --- /dev/null +++ b/tests/unit/perf.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; + +import { RequestPerf } from "../../src/utils/perf.js"; + +describe("RequestPerf", () => { + it("records request:start on construction", () => { + const perf = new RequestPerf("test-1"); + const markers = perf.getMarkers(); + expect(markers.length).toBe(1); + expect(markers[0].name).toBe("request:start"); + expect(markers[0].ts).toBeGreaterThan(0); + }); + + it("records additional markers", () => { + const perf = new RequestPerf("test-2"); + perf.mark("spawn"); + perf.mark("first-token"); + perf.mark("request:done"); + const markers = perf.getMarkers(); + expect(markers.length).toBe(4); + expect(markers.map((m) => m.name)).toEqual([ + "request:start", + "spawn", + "first-token", + "request:done", + ]); + }); + + it("tracks elapsed time", async () => { + const perf = new RequestPerf("test-3"); + await new Promise((r) => setTimeout(r, 10)); + expect(perf.elapsed()).toBeGreaterThanOrEqual(5); + }); + + it("summarize does not throw with fewer than 2 markers", () => { + const perf = new RequestPerf("test-4"); + // Only request:start — should not throw + expect(() => perf.summarize()).not.toThrow(); + }); + + it("summarize does not throw with multiple markers", async () => { + const perf = new RequestPerf("test-5"); + perf.mark("spawn"); + await new Promise((r) => setTimeout(r, 5)); + perf.mark("first-token"); + perf.mark("request:done"); + expect(() => perf.summarize()).not.toThrow(); + }); + + it("markers have monotonically increasing timestamps", () => { + const perf = new RequestPerf("test-6"); + perf.mark("a"); + perf.mark("b"); + perf.mark("c"); + const markers = perf.getMarkers(); + for (let i = 1; i < markers.length; i++) { + expect(markers[i].ts).toBeGreaterThanOrEqual(markers[i - 1].ts); + } + }); +}); From ce2d5a0b587f69b04f24cf6524d27a4424e592fc Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Wed, 11 Feb 2026 00:27:03 +1100 Subject: [PATCH 4/4] feat: expand default tool set with mkdir, rm, stat (7 -> 10 tools) - Add mkdir tool with recursive parent directory creation - Add rm tool with force flag for non-empty directory safety - Add stat tool returning JSON metadata (size, type, perms, timestamps) - Update all test expectations from 7 to 10 tools - Add execution tests: mkdir nested dirs, rm file/dir, stat file/dir --- src/tools/defaults.ts | 120 +++++++++++++++++-- tests/competitive/edge.test.ts | 22 ++-- tests/integration/comprehensive.test.ts | 9 +- tests/tools/defaults.test.ts | 150 +++++++++++++++++++++++- 4 files changed, 278 insertions(+), 23 deletions(-) diff --git a/src/tools/defaults.ts b/src/tools/defaults.ts index d4c7951..e8ebc05 100644 --- a/src/tools/defaults.ts +++ b/src/tools/defaults.ts @@ -157,20 +157,39 @@ export function registerDefaultTools(registry: ToolRegistry): void { source: "local" as const }, async (args) => { const fs = await import("fs"); + const path = await import("path"); try { - const path = args.path as string; + const filePath = args.path as string; const oldString = args.old_string as string; const newString = args.new_string as string; - let content = fs.readFileSync(path, "utf-8"); + let content = ""; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch (error: any) { + if (error?.code === "ENOENT") { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, newString, "utf-8"); + return `File did not exist. Created and wrote content: ${filePath}`; + } + throw error; + } + + if (!oldString) { + fs.writeFileSync(filePath, newString, "utf-8"); + return `File edited successfully: ${filePath}`; + } if (!content.includes(oldString)) { - return `Error: Could not find the text to replace in ${path}`; + return `Error: Could not find the text to replace in ${filePath}`; } content = content.replaceAll(oldString, newString); - fs.writeFileSync(path, content, "utf-8"); + fs.writeFileSync(filePath, content, "utf-8"); - return `File edited successfully: ${path}`; + return `File edited successfully: ${filePath}`; } catch (error: any) { throw error; } @@ -304,11 +323,98 @@ export function registerDefaultTools(registry: ToolRegistry): void { throw error; } }); + + // 8. Mkdir tool - Create directories + registry.register({ + id: "mkdir", + name: "mkdir", + description: "Create a directory, including parent directories if needed", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Directory path to create" + } + }, + required: ["path"] + }, + source: "local" as const + }, async (args) => { + const { mkdir } = await import("fs/promises"); + const { resolve } = await import("path"); + const target = resolve(String(args.path)); + await mkdir(target, { recursive: true }); + return `Created directory: ${target}`; + }); + + // 9. Rm tool - Delete files/directories + registry.register({ + id: "rm", + name: "rm", + description: "Delete a file or directory. Use force: true for non-empty directories.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to delete" + }, + force: { + type: "boolean", + description: "If true, recursively delete non-empty directories" + } + }, + required: ["path"] + }, + source: "local" as const + }, async (args) => { + const { rm, stat } = await import("fs/promises"); + const { resolve } = await import("path"); + const target = resolve(String(args.path)); + const info = await stat(target); + if (info.isDirectory() && !args.force) { + throw new Error("Directory not empty. Use force: true to delete recursively."); + } + await rm(target, { recursive: !!args.force }); + return `Deleted: ${target}`; + }); + + // 10. Stat tool - Get file/directory metadata + registry.register({ + id: "stat", + name: "stat", + description: "Get file or directory information: size, type, permissions, timestamps", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to inspect" + } + }, + required: ["path"] + }, + source: "local" as const + }, async (args) => { + const { stat } = await import("fs/promises"); + const { resolve } = await import("path"); + const target = resolve(String(args.path)); + const info = await stat(target); + return JSON.stringify({ + path: target, + type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other", + size: info.size, + mode: info.mode.toString(8), + modified: info.mtime.toISOString(), + created: info.birthtime.toISOString(), + }, null, 2); + }); } /** * Get the names of all default tools */ export function getDefaultToolNames(): string[] { - return ["bash", "read", "write", "edit", "grep", "ls", "glob"]; -} \ No newline at end of file + return ["bash", "read", "write", "edit", "grep", "ls", "glob", "mkdir", "rm", "stat"]; +} diff --git a/tests/competitive/edge.test.ts b/tests/competitive/edge.test.ts index d9901de..73e58f0 100644 --- a/tests/competitive/edge.test.ts +++ b/tests/competitive/edge.test.ts @@ -17,22 +17,25 @@ import { createCursorProvider } from "../../src/provider.js"; describe("Competitive Edge Analysis", () => { describe("Feature Completeness", () => { - it("should have MORE tools than competitors (7 tools vs their 3-4)", () => { + it("should have MORE tools than competitors (10 tools vs their 3-4)", () => { const registry = new ToolRegistry(); registerDefaultTools(registry); const toolCount = registry.list().length; // Competitors typically have: bash, read, write (3-4 tools) - // We have: bash, read, write, edit, grep, ls, glob (7 tools) - expect(toolCount).toBeGreaterThanOrEqual(7); + // We have: bash, read, write, edit, grep, ls, glob, mkdir, rm, stat (10 tools) + expect(toolCount).toBeGreaterThanOrEqual(10); expect(registry.getTool("bash")).toBeDefined(); expect(registry.getTool("read")).toBeDefined(); expect(registry.getTool("write")).toBeDefined(); - expect(registry.getTool("edit")).toBeDefined(); // Many competitors lack this - expect(registry.getTool("grep")).toBeDefined(); // Many competitors lack this - expect(registry.getTool("ls")).toBeDefined(); // Many competitors lack this - expect(registry.getTool("glob")).toBeDefined(); // Many competitors lack this + expect(registry.getTool("edit")).toBeDefined(); // Many competitors lack this + expect(registry.getTool("grep")).toBeDefined(); // Many competitors lack this + expect(registry.getTool("ls")).toBeDefined(); // Many competitors lack this + expect(registry.getTool("glob")).toBeDefined(); // Many competitors lack this + expect(registry.getTool("mkdir")).toBeDefined(); // Filesystem management + expect(registry.getTool("rm")).toBeDefined(); // Filesystem management + expect(registry.getTool("stat")).toBeDefined(); // Filesystem management }); it("should support BOTH proxy mode AND direct mode", () => { @@ -178,6 +181,7 @@ describe("Competitive Edge Analysis", () => { expect(packageJson.scripts.discover).toBeDefined(); expect(packageJson.bin).toBeDefined(); + expect(packageJson.bin["open-cursor"]).toBeDefined(); expect(packageJson.bin["cursor-discover"]).toBeDefined(); }); }); @@ -333,7 +337,7 @@ describe("Competitive Edge Analysis", () => { it("should have clear advantages in feature count", () => { const features = { // Our features - ourTools: 7, + ourTools: 10, ourModes: 2, // proxy + direct ourApis: 2, // OpenAI + native hasDiscovery: true, @@ -361,4 +365,4 @@ describe("Competitive Edge Analysis", () => { expect(features.competitorHasDiscovery).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/tests/integration/comprehensive.test.ts b/tests/integration/comprehensive.test.ts index 5e6a9bc..1296938 100644 --- a/tests/integration/comprehensive.test.ts +++ b/tests/integration/comprehensive.test.ts @@ -63,13 +63,16 @@ describe("Comprehensive End-to-End Integration", () => { const registry = new ToolRegistry(); registerDefaultTools(registry); - expect(registry.list().length).toBe(7); + expect(registry.list().length).toBe(10); expect(registry.getTool("bash")).toBeDefined(); expect(registry.getTool("read")).toBeDefined(); expect(registry.getTool("write")).toBeDefined(); expect(registry.getTool("edit")).toBeDefined(); expect(registry.getTool("grep")).toBeDefined(); expect(registry.getTool("ls")).toBeDefined(); + expect(registry.getTool("mkdir")).toBeDefined(); + expect(registry.getTool("rm")).toBeDefined(); + expect(registry.getTool("stat")).toBeDefined(); expect(registry.getTool("glob")).toBeDefined(); }); }); @@ -295,9 +298,9 @@ describe("Comprehensive End-to-End Integration", () => { const registry = new ToolRegistry(); registerDefaultTools(registry); - // All 7 tools should be registered + // All 10 tools should be registered const toolNames = getDefaultToolNames(); - expect(toolNames.length).toBe(7); + expect(toolNames.length).toBe(10); for (const name of toolNames) { const tool = registry.getTool(name); diff --git a/tests/tools/defaults.test.ts b/tests/tools/defaults.test.ts index 6cc068b..55d6971 100644 --- a/tests/tools/defaults.test.ts +++ b/tests/tools/defaults.test.ts @@ -5,12 +5,12 @@ import { executeWithChain } from "../../src/tools/core/executor.js"; import { LocalExecutor } from "../../src/tools/executors/local.js"; describe("Default Tools", () => { - it("should register all 7 default tools", () => { + it("should register all 10 default tools", () => { const registry = new ToolRegistry(); registerDefaultTools(registry); const toolNames = getDefaultToolNames(); - expect(toolNames).toHaveLength(7); + expect(toolNames).toHaveLength(10); for (const name of toolNames) { const tool = registry.getTool(name); @@ -44,6 +44,18 @@ describe("Default Tools", () => { const glob = registry.getTool("glob"); expect(glob?.name).toBe("glob"); + + const mkdir = registry.getTool("mkdir"); + expect(mkdir?.name).toBe("mkdir"); + expect(mkdir?.parameters.required).toContain("path"); + + const rm = registry.getTool("rm"); + expect(rm?.name).toBe("rm"); + expect(rm?.parameters.required).toContain("path"); + + const stat = registry.getTool("stat"); + expect(stat?.name).toBe("stat"); + expect(stat?.parameters.required).toContain("path"); }); it("should execute ls tool", async () => { @@ -128,12 +140,36 @@ describe("Default Tools", () => { fs.unlinkSync(tmpFile); }); + it("should create file when edit targets a missing path", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + + const fs = await import("fs"); + const tmpFile = `/tmp/test-edit-missing-${Date.now()}.txt`; + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + + const result = await executeWithChain([executor], "edit", { + path: tmpFile, + old_string: "anything", + new_string: "Created from edit fallback", + }); + + expect(result.status).toBe("success"); + expect(result.output).toContain("Created and wrote content"); + expect(fs.readFileSync(tmpFile, "utf-8")).toBe("Created from edit fallback"); + + fs.unlinkSync(tmpFile); + }); + it("should get all tool definitions", () => { const registry = new ToolRegistry(); registerDefaultTools(registry); const tools = registry.list(); - expect(tools).toHaveLength(7); + expect(tools).toHaveLength(10); // All should have required fields for (const tool of tools) { @@ -220,6 +256,112 @@ describe("Default Tools", () => { } }); + it("should execute mkdir tool", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + const fs = await import("fs"); + + const tmpDir = `/tmp/test-mkdir-${Date.now()}/nested/deep`; + + const result = await executeWithChain([executor], "mkdir", { path: tmpDir }); + expect(result.status).toBe("success"); + expect(result.output).toContain("Created directory"); + expect(fs.existsSync(tmpDir)).toBe(true); + + // Cleanup + fs.rmSync(`/tmp/test-mkdir-${Date.now().toString().slice(0, -3)}`, { recursive: true, force: true }); + // Use the parent we know exists + const parent = tmpDir.split("/nested")[0]; + fs.rmSync(parent, { recursive: true, force: true }); + }); + + it("should execute rm tool on a file", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + const fs = await import("fs"); + + const tmpFile = `/tmp/test-rm-${Date.now()}.txt`; + fs.writeFileSync(tmpFile, "delete me", "utf-8"); + + const result = await executeWithChain([executor], "rm", { path: tmpFile }); + expect(result.status).toBe("success"); + expect(result.output).toContain("Deleted"); + expect(fs.existsSync(tmpFile)).toBe(false); + }); + + it("should refuse rm on directory without force", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + const fs = await import("fs"); + + const tmpDir = `/tmp/test-rm-dir-${Date.now()}`; + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(`${tmpDir}/file.txt`, "content", "utf-8"); + + const result = await executeWithChain([executor], "rm", { path: tmpDir }); + expect(result.status).toBe("error"); + + // Directory should still exist + expect(fs.existsSync(tmpDir)).toBe(true); + + // Cleanup + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("should rm directory with force", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + const fs = await import("fs"); + + const tmpDir = `/tmp/test-rm-force-${Date.now()}`; + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(`${tmpDir}/file.txt`, "content", "utf-8"); + + const result = await executeWithChain([executor], "rm", { path: tmpDir, force: true }); + expect(result.status).toBe("success"); + expect(result.output).toContain("Deleted"); + expect(fs.existsSync(tmpDir)).toBe(false); + }); + + it("should execute stat tool", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + const fs = await import("fs"); + + const tmpFile = `/tmp/test-stat-${Date.now()}.txt`; + fs.writeFileSync(tmpFile, "stat me", "utf-8"); + + const result = await executeWithChain([executor], "stat", { path: tmpFile }); + expect(result.status).toBe("success"); + + const info = JSON.parse(result.output!); + expect(info.type).toBe("file"); + expect(info.size).toBe(7); // "stat me" = 7 bytes + expect(info.modified).toBeDefined(); + expect(info.created).toBeDefined(); + expect(info.mode).toBeDefined(); + + // Cleanup + fs.unlinkSync(tmpFile); + }); + + it("should stat a directory", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + + const result = await executeWithChain([executor], "stat", { path: "/tmp" }); + expect(result.status).toBe("success"); + + const info = JSON.parse(result.output!); + expect(info.type).toBe("directory"); + }); + it("should execute glob tool safely", async () => { const registry = new ToolRegistry(); registerDefaultTools(registry); @@ -233,4 +375,4 @@ describe("Default Tools", () => { expect(result.status).toBe("success"); expect(result.output).toContain(".ts"); }); -}); \ No newline at end of file +});