From c28ad86eb85c38e250f98e76f848b4f343e0ffa6 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 10 Jun 2026 17:38:05 -0500 Subject: [PATCH] feat: blocks tool display default + edit diffs Default toolDisplay to "blocks" so Cursor's tool activity renders as structured tool blocks out of the box. "reasoning" stays available as the documented fallback for pre-1.16 / non-V3 opencode hosts. Map Cursor `edit` activity onto opencode's native `edit` tool so its built-in diff viewer renders: the real unified diff is passed via metadata.diff and oldString/newString are reconstructed from it (the call is provider-executed, so they are never applied to disk). Edits without a usable diff fall back to a safe cursor_edit block; all other tools remain cursor_* prefixed blocks. Test infra: typecheck now covers test/ via a dedicated build config (tsconfig.build.json handles src-only emit), surfacing and fixing 10 stale-type test bugs that the excluded test dir had hidden. --- CHANGELOG.md | 21 +- README.md | 28 +- src/provider/index.ts | 170 +++--- src/provider/language-model.ts | 270 +++++---- src/provider/stream-map.ts | 778 ++++++++++++++++--------- test/agent-events.test.ts | 212 ++++--- test/message-map.test.ts | 131 +++-- test/stream-map.test.ts | 1000 +++++++++++++++++++++----------- tsconfig.build.json | 11 + tsconfig.json | 8 +- tsup.config.ts | 31 +- 11 files changed, 1676 insertions(+), 984 deletions(-) create mode 100644 tsconfig.build.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b48e7b9..aa15bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Native diff viewer for Cursor edits (blocks mode).** A Cursor `edit` tool + call is now surfaced under opencode's registered `edit` tool with its real + unified diff in `metadata.diff`, so opencode renders its built-in diff viewer + instead of a generic block. The required `oldString`/`newString` (which Cursor + does not expose) are reconstructed from the diff; the call is provider-executed + so they are never applied to disk. Any edit without a usable diff (errors, + unexpected shapes, or a host without a registered `edit` tool) falls back to a + safe `cursor_edit` block. Other Cursor tools (shell/read/mcp/…) remain + prefixed `cursor_*` blocks. +- **`toolDisplay` now defaults to `"blocks"`.** Cursor's internal tool activity + renders as structured, provider-executed tool blocks out of the box (was + `"reasoning"`). `"reasoning"` remains available as the fallback for + older/non-V3 opencode hosts via `provider.cursor.options.toolDisplay: + "reasoning"`; `"blocks"` requires a V3-native host (opencode 1.16+). - `0.1.0-rc.2` — pre-release on the npm `next` dist-tag. Fixes found while validating rc.1 against opencode 1.16.2: - **Plugin now loads when installed by package name.** Added the @@ -51,11 +65,12 @@ and a permission-gated delegation tool surface. session via `Agent.resume()` across turns, with automatic fallback to a fresh agent. A run wedged by a crashed/duplicate process is recovered by retrying the send once with the SDK's `local.force` escape hatch. -- **`toolDisplay` provider option** (`"reasoning"` default | `"blocks"`): +- **`toolDisplay` provider option** (`"blocks"` default | `"reasoning"`): - `"reasoning"` renders Cursor's internal tool activity (including the real MCP tool name) as concise `[tool] …` reasoning lines. Always safe — tool - calls never cross opencode's tool-execution boundary. - - `"blocks"` emits structured, provider-executed **dynamic** `tool-call` / + calls never cross opencode's tool-execution boundary; the fallback for + older/non-V3 hosts. + - `"blocks"` (default) emits structured, provider-executed **dynamic** `tool-call` / `tool-result` parts so opencode renders native tool blocks. Names are `cursor_`-prefixed and sanitized (`shell` → `cursor_shell`, `serena/find_symbol` → `cursor_serena_find_symbol`) so they can't collide diff --git a/README.md b/README.md index fb71253..fd2c26c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ It uses your Cursor API key to: - register a `cursor` provider in opencode, - **list the models available to your account** (live, via `Cursor.models.list()`), and - run chats through Cursor's local agent runtime (`Agent.create` / `agent.send`), streaming - text and reasoning back into opencode (Cursor's own tool activity is surfaced as reasoning). + text and reasoning back into opencode (Cursor's own tool activity is surfaced as structured tool + blocks by default; see [Tool display](#tool-display)). This plugin registers Cursor as a **native opencode provider**: its models appear in `opencode models` and the model picker, and you talk to a Cursor model *directly* — with live model @@ -135,7 +136,7 @@ This plugin also registers two **delegation tools** that complement the provider | `session` | `false` | Reuse one Cursor agent per opencode session (resume across turns; see below) | | `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent | | `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones | -| `toolDisplay` | `"reasoning"` | How Cursor's internal tool activity is shown: `"reasoning"` (compact lines, works everywhere) or `"blocks"` (structured provider-executed tool blocks; opt-in, see [Tool display](#tool-display)) | +| `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown: `"blocks"` (structured provider-executed tool blocks; default, requires opencode 1.16+) or `"reasoning"` (compact lines, the fallback for older/non-V3 hosts). See [Tool display](#tool-display) | ### Session reuse (`session`) @@ -311,31 +312,32 @@ no sidecar is spawned. Cursor runs its own agent loop and executes its own tools. The `toolDisplay` option controls how that activity appears in opencode: -- **`"reasoning"` (default)** — each tool call is shown as a compact reasoning line - (`[tool] write {"path":…}`; failures as `[tool] x failed`). Robust on every host: no tool-call - parts cross into opencode, so there's no dependency on how the host treats provider-executed - tools. -- **`"blocks"` (opt-in)** — tool activity is emitted as structured, **provider-executed** +- **`"blocks"` (default)** — tool activity is emitted as structured, **provider-executed** `tool-call`/`tool-result` parts so opencode renders proper, collapsible tool blocks with inputs and outputs. opencode skips execution for provider-executed calls (they're display-only), so - Cursor's tools (`shell`, `mcp`, …) don't trigger an "unavailable tool" error. This requires a + Cursor's tools (`shell`, `mcp`, …) don't trigger an "unavailable tool" error. Requires a V3-native opencode host (1.16+). +- **`"reasoning"` (fallback)** — each tool call is shown as a compact reasoning line + (`[tool] write {"path":…}`; failures as `[tool] x failed`). Robust on every host: no tool-call + parts cross into opencode, so there's no dependency on how the host treats provider-executed + tools. Use this on older/non-V3 opencode hosts. -Enable blocks mode in your opencode config: +The default needs no configuration. To force the reasoning fallback (e.g. on a pre-1.16 host): ```jsonc { "provider": { "cursor": { - "options": { "toolDisplay": "blocks" } + "options": { "toolDisplay": "reasoning" } } } } ``` -> Why opt-in: `"blocks"` depends on V3-native, provider-executed dynamic tool parts and has been -> verified against opencode 1.16+. `"reasoning"` requires nothing from the host and remains the -> always-safe default. If `"blocks"` renders cleanly for you, it's the nicer experience. +> Why blocks by default: structured tool blocks are the nicer experience and have been verified +> against opencode 1.16+. `"blocks"` depends on V3-native, provider-executed dynamic tool parts; if +> your host predates that (or renders them poorly), set `"toolDisplay": "reasoning"` — it requires +> nothing from the host and works everywhere. ## Troubleshooting diff --git a/src/provider/index.ts b/src/provider/index.ts index 7192177..d677c11 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,54 +1,68 @@ -import type { EmbeddingModelV3, ImageModelV3, ProviderV3 } from "@ai-sdk/provider"; +import type { + EmbeddingModelV3, + ImageModelV3, + ProviderV3, +} from "@ai-sdk/provider"; import { NoSuchModelError } from "@ai-sdk/provider"; -import type { AgentDefinition, AgentModeOption, McpServerConfig, SettingSource } from "@cursor/sdk"; +import type { + AgentDefinition, + AgentModeOption, + McpServerConfig, + SettingSource, +} from "@cursor/sdk"; import { resolveCursorApiKey } from "../api-key.js"; -import { CursorLanguageModel, type CursorModelConfig } from "./language-model.js"; +import { + CursorLanguageModel, + type CursorModelConfig, +} from "./language-model.js"; import type { ToolDisplay } from "./stream-map.js"; export interface CursorProviderOptions { - /** - * Cursor API key. opencode passes this from the provider's resolved auth / - * options. When omitted, falls back to the CURSOR_API_KEY environment - * variable at call time. - */ - apiKey?: string; - /** Provider id, supplied by opencode as `name`. Defaults to "cursor". */ - name?: string; - /** Working directory for the local Cursor agent. Defaults to process.cwd(). */ - cwd?: string; - /** Default conversation mode: "agent" (default) or "plan". Overridable per-request. */ - mode?: AgentModeOption; - /** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */ - params?: Record; - /** - * MCP servers to make available to the Cursor agent, keyed by name. The - * plugin's `config` hook populates this by translating opencode's configured - * `config.mcp` servers, so the agent can use the same MCP servers (e.g. - * Serena) that opencode does. - */ - mcpServers?: Record; - /** - * Cursor settings layers to load from the local filesystem ("project", - * "user", "all", ...). Enables the agent to pick up your Cursor skills, - * rules, and `.cursor/mcp.json` servers. - */ - settingSources?: SettingSource[]; - /** Run the agent's tools inside Cursor's sandbox. */ - sandbox?: boolean; - /** Cursor subagent definitions (`{ description, prompt, model?, mcpServers? }`). */ - agents?: Record; - /** - * Reuse one Cursor agent per opencode session (resume across turns instead of - * creating a fresh agent each turn). Off by default. - */ - session?: boolean; - /** - * How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced: - * - `"reasoning"` (default): compact reasoning lines (works on every host). - * - `"blocks"`: structured provider-executed `tool-call`/`tool-result` parts - * so opencode renders proper tool blocks. Opt-in; requires a V3-native host. - */ - toolDisplay?: ToolDisplay; + /** + * Cursor API key. opencode passes this from the provider's resolved auth / + * options. When omitted, falls back to the CURSOR_API_KEY environment + * variable at call time. + */ + apiKey?: string; + /** Provider id, supplied by opencode as `name`. Defaults to "cursor". */ + name?: string; + /** Working directory for the local Cursor agent. Defaults to process.cwd(). */ + cwd?: string; + /** Default conversation mode: "agent" (default) or "plan". Overridable per-request. */ + mode?: AgentModeOption; + /** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */ + params?: Record; + /** + * MCP servers to make available to the Cursor agent, keyed by name. The + * plugin's `config` hook populates this by translating opencode's configured + * `config.mcp` servers, so the agent can use the same MCP servers (e.g. + * Serena) that opencode does. + */ + mcpServers?: Record; + /** + * Cursor settings layers to load from the local filesystem ("project", + * "user", "all", ...). Enables the agent to pick up your Cursor skills, + * rules, and `.cursor/mcp.json` servers. + */ + settingSources?: SettingSource[]; + /** Run the agent's tools inside Cursor's sandbox. */ + sandbox?: boolean; + /** Cursor subagent definitions (`{ description, prompt, model?, mcpServers? }`). */ + agents?: Record; + /** + * Reuse one Cursor agent per opencode session (resume across turns instead of + * creating a fresh agent each turn). Off by default. + */ + session?: boolean; + /** + * How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced: + * - `"blocks"` (default): structured provider-executed `tool-call`/ + * `tool-result` parts so opencode renders proper tool blocks. Requires a + * V3-native opencode host (1.16+). + * - `"reasoning"`: compact reasoning lines; the fallback for older/non-V3 + * hosts (works everywhere). + */ + toolDisplay?: ToolDisplay; } /** @@ -60,35 +74,41 @@ export interface CursorProviderOptions { * and then calls `.languageModel(modelId)`. */ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 { - const mcpServers = - options.mcpServers && Object.keys(options.mcpServers).length > 0 ? options.mcpServers : undefined; - const config: CursorModelConfig = { - providerName: options.name ?? "cursor", - apiKey: resolveCursorApiKey(options.apiKey), - cwd: options.cwd ?? process.cwd(), - mode: options.mode ?? "agent", - ...(options.params ? { params: options.params } : {}), - ...(mcpServers ? { mcpServers } : {}), - ...(options.settingSources ? { settingSources: options.settingSources } : {}), - ...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}), - ...(options.agents ? { agents: options.agents } : {}), - ...(options.session !== undefined ? { session: options.session } : {}), - ...(options.toolDisplay ? { toolDisplay: options.toolDisplay } : {}), - }; + const mcpServers = + options.mcpServers && Object.keys(options.mcpServers).length > 0 + ? options.mcpServers + : undefined; + const config: CursorModelConfig = { + providerName: options.name ?? "cursor", + apiKey: resolveCursorApiKey(options.apiKey), + cwd: options.cwd ?? process.cwd(), + mode: options.mode ?? "agent", + ...(options.params ? { params: options.params } : {}), + ...(mcpServers ? { mcpServers } : {}), + ...(options.settingSources + ? { settingSources: options.settingSources } + : {}), + ...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}), + ...(options.agents ? { agents: options.agents } : {}), + ...(options.session !== undefined ? { session: options.session } : {}), + toolDisplay: options.toolDisplay ?? "blocks", + }; - const notImplemented = (kind: string, modelId: string): never => { - throw new NoSuchModelError({ - modelId, - modelType: kind as "languageModel", - message: `The Cursor provider does not support ${kind} models.`, - }); - }; + const notImplemented = (kind: string, modelId: string): never => { + throw new NoSuchModelError({ + modelId, + modelType: kind as "languageModel", + message: `The Cursor provider does not support ${kind} models.`, + }); + }; - return { - specificationVersion: "v3", - languageModel: (modelId: string) => new CursorLanguageModel(modelId, config), - embeddingModel: (modelId: string): EmbeddingModelV3 => - notImplemented("embeddingModel", modelId), - imageModel: (modelId: string): ImageModelV3 => notImplemented("imageModel", modelId), - }; + return { + specificationVersion: "v3", + languageModel: (modelId: string) => + new CursorLanguageModel(modelId, config), + embeddingModel: (modelId: string): EmbeddingModelV3 => + notImplemented("embeddingModel", modelId), + imageModel: (modelId: string): ImageModelV3 => + notImplemented("imageModel", modelId), + }; } diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index d503b32..a0e60c1 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -1,55 +1,59 @@ import type { - LanguageModelV3, - LanguageModelV3CallOptions, - LanguageModelV3Content, - LanguageModelV3FinishReason, - LanguageModelV3StreamPart, - LanguageModelV3Usage, + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3StreamPart, + LanguageModelV3Usage, } from "@ai-sdk/provider"; import { LoadAPIKeyError } from "@ai-sdk/provider"; import type { - AgentDefinition, - McpServerConfig, - SettingSource, - AgentModeOption, + AgentDefinition, + McpServerConfig, + SettingSource, + AgentModeOption, } from "@cursor/sdk"; import { resolveCursorApiKey } from "../api-key.js"; import { latestUserMessage, promptToCursorMessage } from "./message-map.js"; import { streamAgentTurn, type CursorEvent } from "./agent-events.js"; -import { cursorEventsToContent, cursorEventsToStream, type ToolDisplay } from "./stream-map.js"; +import { + cursorEventsToContent, + cursorEventsToStream, + type ToolDisplay, +} from "./stream-map.js"; import { resolveControls } from "./controls.js"; import { acquireAgent } from "./session-pool.js"; export interface CursorModelConfig { - /** Provider id used for logging and the providerOptions key (e.g. "cursor"). */ - providerName: string; - /** Explicit API key; re-resolved against the env at call time when absent. */ - apiKey?: string; - /** Working directory the local Cursor agent operates in. */ - cwd: string; - /** Default conversation mode; overridable per-request via providerOptions. */ - mode: AgentModeOption; - /** Default Cursor model params (id -> value); overridable per-request. */ - params?: Record; - /** MCP servers forwarded to the Cursor agent (e.g. opencode's Serena). */ - mcpServers?: Record; - /** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */ - settingSources?: SettingSource[]; - /** Run the agent's tools inside Cursor's sandbox. */ - sandbox?: boolean; - /** Cursor subagent definitions made available to the agent. */ - agents?: Record; - /** - * Reuse one Cursor agent per opencode session (resume across turns, sending - * only the new message). Off by default; the default per-turn-fresh path - * re-sends the full transcript and is robust to opencode's non-chat calls. - */ - session?: boolean; - /** - * How Cursor's internal tool activity is surfaced (see {@link ToolDisplay}). - * Defaults to `"reasoning"`. - */ - toolDisplay?: ToolDisplay; + /** Provider id used for logging and the providerOptions key (e.g. "cursor"). */ + providerName: string; + /** Explicit API key; re-resolved against the env at call time when absent. */ + apiKey?: string; + /** Working directory the local Cursor agent operates in. */ + cwd: string; + /** Default conversation mode; overridable per-request via providerOptions. */ + mode: AgentModeOption; + /** Default Cursor model params (id -> value); overridable per-request. */ + params?: Record; + /** MCP servers forwarded to the Cursor agent (e.g. opencode's Serena). */ + mcpServers?: Record; + /** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */ + settingSources?: SettingSource[]; + /** Run the agent's tools inside Cursor's sandbox. */ + sandbox?: boolean; + /** Cursor subagent definitions made available to the agent. */ + agents?: Record; + /** + * Reuse one Cursor agent per opencode session (resume across turns, sending + * only the new message). Off by default; the default per-turn-fresh path + * re-sends the full transcript and is robust to opencode's non-chat calls. + */ + session?: boolean; + /** + * How Cursor's internal tool activity is surfaced (see {@link ToolDisplay}). + * Defaults to `"blocks"`. + */ + toolDisplay?: ToolDisplay; } /** @@ -59,96 +63,114 @@ export interface CursorModelConfig { * without a live agent. */ export class CursorLanguageModel implements LanguageModelV3 { - readonly specificationVersion = "v3" as const; - readonly modelId: string; - readonly provider: string; - // Images are passed inline as base64 data, so no URLs are fetched natively. - readonly supportedUrls: Record = {}; + readonly specificationVersion = "v3" as const; + readonly modelId: string; + readonly provider: string; + // Images are passed inline as base64 data, so no URLs are fetched natively. + readonly supportedUrls: Record = {}; - constructor( - modelId: string, - private readonly config: CursorModelConfig, - ) { - this.modelId = modelId; - this.provider = config.providerName; - } + constructor( + modelId: string, + private readonly config: CursorModelConfig, + ) { + this.modelId = modelId; + this.provider = config.providerName; + } - private requireApiKey(): string { - const apiKey = resolveCursorApiKey(this.config.apiKey); - if (!apiKey) { - throw new LoadAPIKeyError({ - message: - "Cursor API key missing. Run `opencode auth login` and choose Cursor, or set CURSOR_API_KEY.", - }); - } - return apiKey; - } + private requireApiKey(): string { + const apiKey = resolveCursorApiKey(this.config.apiKey); + if (!apiKey) { + throw new LoadAPIKeyError({ + message: + "Cursor API key missing. Run `opencode auth login` and choose Cursor, or set CURSOR_API_KEY.", + }); + } + return apiKey; + } - private async *agentRun(options: LanguageModelV3CallOptions): AsyncGenerator { - // opencode delivers per-request controls (merged model options + selected - // variant) under providerOptions keyed by our provider id. The session id is - // injected there by the plugin's chat.params hook. - const providerOptions = options.providerOptions?.[this.provider] as - | Record - | undefined; - const { mode, modelSelection } = resolveControls( - this.modelId, - { mode: this.config.mode, params: this.config.params }, - providerOptions, - ); - const sessionID = - typeof providerOptions?.["sessionID"] === "string" - ? (providerOptions["sessionID"] as string) - : undefined; - const useSession = this.config.session === true && Boolean(sessionID); - // Power users can resume a specific Cursor agent via - // `providerOptions.cursor.agentId`; it takes precedence over session pooling. - const explicitAgentId = - typeof providerOptions?.["agentId"] === "string" - ? (providerOptions["agentId"] as string) - : undefined; + private async *agentRun( + options: LanguageModelV3CallOptions, + ): AsyncGenerator { + // opencode delivers per-request controls (merged model options + selected + // variant) under providerOptions keyed by our provider id. The session id is + // injected there by the plugin's chat.params hook. + const providerOptions = options.providerOptions?.[this.provider] as + | Record + | undefined; + const { mode, modelSelection } = resolveControls( + this.modelId, + { mode: this.config.mode, params: this.config.params }, + providerOptions, + ); + const sessionID = + typeof providerOptions?.["sessionID"] === "string" + ? (providerOptions["sessionID"] as string) + : undefined; + const useSession = this.config.session === true && Boolean(sessionID); + // Power users can resume a specific Cursor agent via + // `providerOptions.cursor.agentId`; it takes precedence over session pooling. + const explicitAgentId = + typeof providerOptions?.["agentId"] === "string" + ? (providerOptions["agentId"] as string) + : undefined; - const acquired = await acquireAgent({ - apiKey: this.requireApiKey(), - modelSelection, - mode, - cwd: this.config.cwd, - ...(this.config.settingSources ? { settingSources: this.config.settingSources } : {}), - ...(this.config.sandbox !== undefined ? { sandbox: this.config.sandbox } : {}), - ...(this.config.mcpServers ? { mcpServers: this.config.mcpServers } : {}), - ...(this.config.agents ? { agents: this.config.agents } : {}), - ...(useSession ? { name: `opencode/${sessionID!.slice(-8)}` } : {}), - ...(explicitAgentId ? { agentId: explicitAgentId } : {}), - sessionID, - session: useSession, - }); + const acquired = await acquireAgent({ + apiKey: this.requireApiKey(), + modelSelection, + mode, + cwd: this.config.cwd, + ...(this.config.settingSources + ? { settingSources: this.config.settingSources } + : {}), + ...(this.config.sandbox !== undefined + ? { sandbox: this.config.sandbox } + : {}), + ...(this.config.mcpServers ? { mcpServers: this.config.mcpServers } : {}), + ...(this.config.agents ? { agents: this.config.agents } : {}), + ...(useSession ? { name: `opencode/${sessionID!.slice(-8)}` } : {}), + ...(explicitAgentId ? { agentId: explicitAgentId } : {}), + sessionID, + session: useSession, + }); - // A resumed agent already remembers the prior conversation, so send only the - // new turn; otherwise send the full transcript. - const message = acquired.resumed - ? (latestUserMessage(options.prompt) ?? promptToCursorMessage(options.prompt)) - : promptToCursorMessage(options.prompt); + // A resumed agent already remembers the prior conversation, so send only the + // new turn; otherwise send the full transcript. + const message = acquired.resumed + ? (latestUserMessage(options.prompt) ?? + promptToCursorMessage(options.prompt)) + : promptToCursorMessage(options.prompt); - try { - yield* streamAgentTurn(acquired.agent, message, { mode, abortSignal: options.abortSignal }); - } finally { - acquired.release(); - } - } + try { + yield* streamAgentTurn(acquired.agent, message, { + mode, + abortSignal: options.abortSignal, + }); + } finally { + acquired.release(); + } + } - async doStream(options: LanguageModelV3CallOptions): Promise<{ - stream: ReadableStream; - }> { - return { stream: cursorEventsToStream(this.agentRun(options), this.config.toolDisplay) }; - } + async doStream(options: LanguageModelV3CallOptions): Promise<{ + stream: ReadableStream; + }> { + return { + stream: cursorEventsToStream( + this.agentRun(options), + this.config.toolDisplay, + ), + }; + } - async doGenerate(options: LanguageModelV3CallOptions): Promise<{ - content: Array; - finishReason: LanguageModelV3FinishReason; - usage: LanguageModelV3Usage; - warnings: Array; - }> { - const result = await cursorEventsToContent(this.agentRun(options), this.config.toolDisplay); - return { ...result, warnings: [] }; - } + async doGenerate(options: LanguageModelV3CallOptions): Promise<{ + content: Array; + finishReason: LanguageModelV3FinishReason; + usage: LanguageModelV3Usage; + warnings: Array; + }> { + const result = await cursorEventsToContent( + this.agentRun(options), + this.config.toolDisplay, + ); + return { ...result, warnings: [] }; + } } diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index a749886..ff72020 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -1,18 +1,17 @@ import type { - LanguageModelV3Content, - LanguageModelV3FinishReason, - LanguageModelV3StreamPart, - LanguageModelV3Usage, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3StreamPart, + LanguageModelV3Usage, } from "@ai-sdk/provider"; import type { CursorEvent, CursorUsage } from "./agent-events.js"; /** * How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced to * opencode: - * - `"reasoning"` (default): rendered as compact reasoning lines. Robust on - * every host — no tool-call parts cross the execution boundary. - * - `"blocks"`: emitted as provider-executed AI-SDK `tool-call`/`tool-result` - * parts so opencode renders structured tool blocks. The parts must carry + * - `"blocks"` (default): emitted as provider-executed AI-SDK + * `tool-call`/`tool-result` parts so opencode renders structured tool + * blocks. Requires a V3-native opencode host (1.16+). The parts must carry * BOTH `providerExecuted: true` AND `dynamic: true` — ai's `parseToolCall` * (v6, `doParseToolCall`) only exempts that combination from registered-tool * validation; without `dynamic` an unknown name raises `NoSuchToolError`, @@ -24,15 +23,21 @@ import type { CursorEvent, CursorUsage } from "./agent-events.js"; */ export type ToolDisplay = "reasoning" | "blocks"; -const FINISH_STOP: LanguageModelV3FinishReason = { unified: "stop", raw: undefined }; -const FINISH_ERROR: LanguageModelV3FinishReason = { unified: "error", raw: undefined }; +const FINISH_STOP: LanguageModelV3FinishReason = { + unified: "stop", + raw: undefined, +}; +const FINISH_ERROR: LanguageModelV3FinishReason = { + unified: "error", + raw: undefined, +}; function safeJsonString(input: unknown): string { - try { - return typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - return "{}"; - } + try { + return typeof input === "string" ? input : JSON.stringify(input ?? {}); + } catch { + return "{}"; + } } /** @@ -41,91 +46,281 @@ function safeJsonString(input: unknown): string { * names contain `/` (e.g. `serena/find_symbol` → `cursor_serena_find_symbol`). */ function blockToolName(name: string): string { - return `cursor_${name.replace(/[^A-Za-z0-9_-]/g, "_")}`; + return `cursor_${name.replace(/[^A-Za-z0-9_-]/g, "_")}`; } /** - * Build a provider-executed dynamic `tool-call` stream part (V3). `input` is a - * stringified JSON object per the spec. + * A blocks-mode tool part. `tool-call` / `tool-result` are structurally + * identical in `LanguageModelV3StreamPart` (streaming) and + * `LanguageModelV3Content` (`doGenerate`), so the builders below produce one + * shape that both consumers cast to their respective union. + */ +type BlockToolPart = LanguageModelV3StreamPart; + +/** + * Build a provider-executed dynamic `tool-call`. The name is `cursor_`-prefixed + * so it can't collide with a tool opencode has registered; `input` is a + * stringified JSON object per the V3 spec. */ -function toolCallPart(id: string, name: string, input: unknown): LanguageModelV3StreamPart { - return { - type: "tool-call", - toolCallId: id, - toolName: blockToolName(name), - input: safeJsonString(input), - providerExecuted: true, - dynamic: true, - } as LanguageModelV3StreamPart; +function toolCallObj(id: string, name: string, input: unknown): BlockToolPart { + return { + type: "tool-call", + toolCallId: id, + toolName: blockToolName(name), + input: safeJsonString(input), + providerExecuted: true, + dynamic: true, + } as BlockToolPart; } /** - * Build a provider-executed dynamic `tool-result` stream part. Per the V3 spec - * (and ai v6's `runToolsTransformation`, which reads `chunk.result` / - * `chunk.isError`) the payload goes in `result`; `result` is typed - * `NonNullable` so a missing Cursor result is coalesced to `null` - * and cast. + * Build a provider-executed dynamic `tool-result`. Per the V3 spec (and ai v6's + * `runToolsTransformation`, which reads `chunk.result` / `chunk.isError`) the + * payload goes in `result`; `result` is typed `NonNullable` so a + * missing Cursor result is coalesced to `null` and cast. */ -function toolResultPart( - id: string, - name: string, - result: unknown, - isError: boolean, -): LanguageModelV3StreamPart { - return { - type: "tool-result", - toolCallId: id, - toolName: blockToolName(name), - result: (result ?? null) as never, - isError, - providerExecuted: true, - dynamic: true, - } as LanguageModelV3StreamPart; +function toolResultObj( + id: string, + name: string, + result: unknown, + isError: boolean, +): BlockToolPart { + return { + type: "tool-result", + toolCallId: id, + toolName: blockToolName(name), + result: (result ?? null) as never, + isError, + providerExecuted: true, + dynamic: true, + } as BlockToolPart; +} + +/** Cursor's file-edit tool surfaces with this name (its `toolCall.type`). */ +const EDIT_TOOL_NAME = "edit"; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +/** Extract the edit target path from Cursor's edit tool-call args (`{ path }`). */ +function editFilePath(input: unknown): string { + return isRecord(input) && typeof input["path"] === "string" + ? input["path"] + : ""; } -/** Content-item equivalents of the tool parts above, for `doGenerate`. */ -function toolCallContent(id: string, name: string, input: unknown): LanguageModelV3Content { - return { - type: "tool-call", - toolCallId: id, - toolName: blockToolName(name), - input: safeJsonString(input), - providerExecuted: true, - dynamic: true, - } as LanguageModelV3Content; +/** + * If `result` is a successful Cursor edit result carrying a unified diff, return + * its `diffString`; otherwise null (caller falls back to a safe generic block). + * Cursor shape: `{ status:"success", value:{ diffString?, linesAdded?, linesRemoved? } }`. + */ +function editDiffString(result: unknown): string | null { + if (!isRecord(result) || result["status"] !== "success") return null; + const value = result["value"]; + if (!isRecord(value)) return null; + const diff = value["diffString"]; + return typeof diff === "string" && diff.length > 0 ? diff : null; } -function toolResultContent( - id: string, - name: string, - result: unknown, - isError: boolean, -): LanguageModelV3Content { - return { - type: "tool-result", - toolCallId: id, - toolName: blockToolName(name), - result: (result ?? null) as never, - isError, - providerExecuted: true, - dynamic: true, - } as LanguageModelV3Content; + +/** + * Reconstruct opencode `edit` `{oldString,newString}` from a unified diff: + * removed (`-`) lines → oldString, added (`+`) lines → newString (file/hunk + * headers skipped). Faithful for a single hunk; approximate (concatenated) + * across multiple hunks. Used only to satisfy opencode's edit input schema — the + * call is provider-executed, so these strings are never applied to disk; the + * rendered diff comes from `metadata.diff`. + */ +function reconstructEditStrings(diff: string): { + oldString: string; + newString: string; +} { + const oldLines: string[] = []; + const newLines: string[] = []; + for (const line of diff.split("\n")) { + if ( + line.startsWith("---") || + line.startsWith("+++") || + line.startsWith("@@") || + line.startsWith("Index:") || + line.startsWith("===") + ) { + continue; + } + if (line.startsWith("-")) oldLines.push(line.slice(1)); + else if (line.startsWith("+")) newLines.push(line.slice(1)); + } + return { oldString: oldLines.join("\n"), newString: newLines.join("\n") }; +} + +/** + * Build the opencode-native `edit` `tool-call` payload for a completed Cursor + * edit. Emitted under the registered name `edit` (so opencode's diff viewer + * renders) with a schema-valid `{filePath, oldString, newString}` input. Still + * carries `providerExecuted` + `dynamic` so a host without a registered `edit` + * tool degrades to a dynamic generic block instead of erroring. + */ +function editCallFields( + id: string, + filePath: string, + diff: string, +): BlockToolPart { + const { oldString, newString } = reconstructEditStrings(diff); + return { + type: "tool-call", + toolCallId: id, + toolName: EDIT_TOOL_NAME, + input: safeJsonString({ filePath, oldString, newString }), + providerExecuted: true, + dynamic: true, + } as BlockToolPart; +} + +/** + * Build the opencode-native `edit` `tool-result` payload. opencode's processor + * folds a tool-result's payload into `state.{title,metadata,output}`; the Edit + * renderer's diff viewer keys on `metadata.diff`. + */ +function editResultFields( + id: string, + filePath: string, + diff: string, + result: unknown, +): BlockToolPart { + const value = isRecord(result) ? result["value"] : undefined; + const added = + isRecord(value) && typeof value["linesAdded"] === "number" + ? value["linesAdded"] + : undefined; + const removed = + isRecord(value) && typeof value["linesRemoved"] === "number" + ? value["linesRemoved"] + : undefined; + const counts = + added !== undefined || removed !== undefined + ? ` (+${added ?? 0}/-${removed ?? 0})` + : ""; + return { + type: "tool-result" as const, + toolCallId: id, + toolName: EDIT_TOOL_NAME, + result: { + title: filePath, + metadata: { diff, diagnostics: {} }, + output: `Edit applied${counts}.`, + } as never, + isError: false, + providerExecuted: true, + dynamic: true, + } as BlockToolPart; +} + +/** + * Per-turn blocks-mode tool bookkeeping, shared by the streaming and + * `doGenerate` paths: + * - `openToolCalls`: non-edit calls awaiting their result (id → original name). + * - `pendingEdits`: edit calls held until their result, which carries the diff + * needed to emit a schema-valid native `edit` call (id → filePath). + */ +interface BlockToolState { + openToolCalls: Map; + pendingEdits: Map; +} + +function newBlockToolState(): BlockToolState { + return { openToolCalls: new Map(), pendingEdits: new Map() }; +} + +/** Parts to emit for a blocks-mode `tool-call` event (edits are buffered). */ +function blockToolCallParts( + id: string, + name: string, + input: unknown, + state: BlockToolState, +): BlockToolPart[] { + if (name === EDIT_TOOL_NAME) { + // Hold the edit call until its result (which carries the diff). + state.pendingEdits.set(id, editFilePath(input)); + return []; + } + state.openToolCalls.set(id, name); + return [toolCallObj(id, name, input)]; +} + +/** Parts to emit for a blocks-mode `tool-result` event. */ +function blockToolResultParts( + id: string, + name: string, + result: unknown, + isError: boolean, + state: BlockToolState, +): BlockToolPart[] { + if (state.pendingEdits.has(id)) { + const filePath = state.pendingEdits.get(id)!; + state.pendingEdits.delete(id); + const diff = isError ? null : editDiffString(result); + if (diff && filePath) { + // Native edit: opencode renders its built-in diff viewer. + return [ + editCallFields(id, filePath, diff), + editResultFields(id, filePath, diff, result), + ]; + } + // No usable diff (error / unexpected shape): safe generic fallback. + return [ + toolCallObj(id, EDIT_TOOL_NAME, { path: filePath }), + toolResultObj(id, EDIT_TOOL_NAME, result, isError), + ]; + } + state.openToolCalls.delete(id); + return [toolResultObj(id, name, result, isError)]; +} + +/** + * Parts that close out any tool call whose completion never arrived (run + * errored/cancelled mid-tool) so blocks never dangle as "Tool execution + * aborted". Clears the state. + */ +function blockDanglingParts(state: BlockToolState): BlockToolPart[] { + const parts: BlockToolPart[] = []; + for (const [id, name] of state.openToolCalls) { + parts.push(toolResultObj(id, name, DANGLING_TOOL_RESULT, true)); + } + state.openToolCalls.clear(); + // Edits whose result never arrived: safe generic block + synthetic error + // (no diff available to build a native edit). + for (const [id, filePath] of state.pendingEdits) { + parts.push(toolCallObj(id, EDIT_TOOL_NAME, { path: filePath })); + parts.push(toolResultObj(id, EDIT_TOOL_NAME, DANGLING_TOOL_RESULT, true)); + } + state.pendingEdits.clear(); + return parts; } export const EMPTY_USAGE: LanguageModelV3Usage = { - inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, - outputTokens: { total: undefined, text: undefined, reasoning: undefined }, + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, }; export function mapUsage(usage: CursorUsage): LanguageModelV3Usage { - return { - inputTokens: { - total: usage.inputTokens, - noCache: undefined, - cacheRead: usage.cacheReadTokens, - cacheWrite: usage.cacheWriteTokens, - }, - outputTokens: { total: usage.outputTokens, text: undefined, reasoning: undefined }, - }; + return { + inputTokens: { + total: usage.inputTokens, + noCache: undefined, + cacheRead: usage.cacheReadTokens, + cacheWrite: usage.cacheWriteTokens, + }, + outputTokens: { + total: usage.outputTokens, + text: undefined, + reasoning: undefined, + }, + }; } /** @@ -142,14 +337,15 @@ export function mapUsage(usage: CursorUsage): LanguageModelV3Usage { * shown — never the raw result. */ function formatToolCall(name: string, input: unknown): string { - let arg = ""; - try { - const s = typeof input === "string" ? input : JSON.stringify(input); - if (s && s !== "{}" && s !== '""') arg = ` ${s.length > 120 ? `${s.slice(0, 120)}…` : s}`; - } catch { - // Non-serializable input; show the name only. - } - return `[tool] ${name}${arg}`; + let arg = ""; + try { + const s = typeof input === "string" ? input : JSON.stringify(input); + if (s && s !== "{}" && s !== '""') + arg = ` ${s.length > 120 ? `${s.slice(0, 120)}…` : s}`; + } catch { + // Non-serializable input; show the name only. + } + return `[tool] ${name}${arg}`; } /** @@ -160,8 +356,8 @@ function formatToolCall(name: string, input: unknown): string { * "Tool execution aborted" and the block dangles forever. */ const DANGLING_TOOL_RESULT = { - status: "error", - error: "Cursor run ended before this tool call completed.", + status: "error", + error: "Cursor run ended before this tool call completed.", }; /** @@ -170,190 +366,236 @@ const DANGLING_TOOL_RESULT = { * Pure with respect to the event source, so it can be tested by feeding a * fixed event sequence (no live agent required). Reasoning blocks are closed * before text begins so reasoning/text parts nest cleanly. Tool activity is - * rendered into the reasoning channel (see {@link formatToolCall}). + * surfaced per {@link ToolDisplay} (default `"blocks"`): structured tool parts, + * or reasoning lines when `"reasoning"` (see {@link formatToolCall}). */ export function cursorEventsToStream( - events: AsyncIterable, - toolDisplay: ToolDisplay = "reasoning", + events: AsyncIterable, + toolDisplay: ToolDisplay = "blocks", ): ReadableStream { - return new ReadableStream({ - async start(controller) { - controller.enqueue({ type: "stream-start", warnings: [] }); - - let textId: string | undefined; - let textCount = 0; - let reasoningId: string | undefined; - let reasoningCount = 0; - let usage: LanguageModelV3Usage | undefined; - let streamedText = false; - // Open (unanswered) tool calls in blocks mode: id -> original tool name. - const openToolCalls = new Map(); - const closeDanglingToolCalls = () => { - for (const [id, name] of openToolCalls) { - controller.enqueue(toolResultPart(id, name, DANGLING_TOOL_RESULT, true)); - } - openToolCalls.clear(); - }; - - const closeReasoning = () => { - if (reasoningId) { - controller.enqueue({ type: "reasoning-end", id: reasoningId }); - reasoningId = undefined; - } - }; - // Close the open text part when reasoning resumes: hosts position a part - // where it STARTED, so appending later text to an earlier part would - // render the final answer above the reasoning that preceded it. - const closeText = () => { - if (textId) { - controller.enqueue({ type: "text-end", id: textId }); - textId = undefined; - } - }; - const ensureText = () => { - closeReasoning(); - if (!textId) { - textId = `text-${textCount++}`; - controller.enqueue({ type: "text-start", id: textId }); - } - return textId; - }; - const ensureReasoning = () => { - closeText(); - if (!reasoningId) { - reasoningId = `reasoning-${reasoningCount++}`; - controller.enqueue({ type: "reasoning-start", id: reasoningId }); - } - return reasoningId; - }; - const reasoningLine = (text: string) => { - controller.enqueue({ type: "reasoning-delta", id: ensureReasoning(), delta: text }); - }; - - try { - for await (const event of events) { - switch (event.type) { - case "text-delta": - streamedText = true; - controller.enqueue({ type: "text-delta", id: ensureText(), delta: event.text }); - break; - case "reasoning-delta": - reasoningLine(event.text); - break; - case "tool-call": - if (toolDisplay === "blocks") { - openToolCalls.set(event.id, event.name); - controller.enqueue(toolCallPart(event.id, event.name, event.input)); - } else { - reasoningLine(`\n${formatToolCall(event.name, event.input)}\n`); - } - break; - case "tool-result": - if (toolDisplay === "blocks") { - openToolCalls.delete(event.id); - controller.enqueue( - toolResultPart(event.id, event.name, event.result, event.isError), - ); - } else if (event.isError) { - reasoningLine(`[tool] ${event.name} failed\n`); - } - break; - case "usage": - usage = mapUsage(event.usage); - break; - case "finish": - if (!streamedText && event.text) { - controller.enqueue({ type: "text-delta", id: ensureText(), delta: event.text }); - } - break; - } - } - - closeDanglingToolCalls(); - closeReasoning(); - closeText(); - controller.enqueue({ type: "finish", usage: usage ?? EMPTY_USAGE, finishReason: FINISH_STOP }); - controller.close(); - } catch (err) { - controller.enqueue({ type: "error", error: err }); - closeDanglingToolCalls(); - closeReasoning(); - closeText(); - controller.enqueue({ type: "finish", usage: usage ?? EMPTY_USAGE, finishReason: FINISH_ERROR }); - controller.close(); - } - }, - }); + return new ReadableStream({ + async start(controller) { + controller.enqueue({ type: "stream-start", warnings: [] }); + + let textId: string | undefined; + let textCount = 0; + let reasoningId: string | undefined; + let reasoningCount = 0; + let usage: LanguageModelV3Usage | undefined; + let streamedText = false; + // Blocks-mode tool bookkeeping (open non-edit calls + buffered edits). + const toolState = newBlockToolState(); + const closeDanglingToolCalls = () => { + for (const part of blockDanglingParts(toolState)) { + controller.enqueue(part); + } + }; + + const closeReasoning = () => { + if (reasoningId) { + controller.enqueue({ type: "reasoning-end", id: reasoningId }); + reasoningId = undefined; + } + }; + // Close the open text part when reasoning resumes: hosts position a part + // where it STARTED, so appending later text to an earlier part would + // render the final answer above the reasoning that preceded it. + const closeText = () => { + if (textId) { + controller.enqueue({ type: "text-end", id: textId }); + textId = undefined; + } + }; + const ensureText = () => { + closeReasoning(); + if (!textId) { + textId = `text-${textCount++}`; + controller.enqueue({ type: "text-start", id: textId }); + } + return textId; + }; + const ensureReasoning = () => { + closeText(); + if (!reasoningId) { + reasoningId = `reasoning-${reasoningCount++}`; + controller.enqueue({ type: "reasoning-start", id: reasoningId }); + } + return reasoningId; + }; + const reasoningLine = (text: string) => { + controller.enqueue({ + type: "reasoning-delta", + id: ensureReasoning(), + delta: text, + }); + }; + + try { + for await (const event of events) { + switch (event.type) { + case "text-delta": + streamedText = true; + controller.enqueue({ + type: "text-delta", + id: ensureText(), + delta: event.text, + }); + break; + case "reasoning-delta": + reasoningLine(event.text); + break; + case "tool-call": + if (toolDisplay === "blocks") { + for (const part of blockToolCallParts( + event.id, + event.name, + event.input, + toolState, + )) { + controller.enqueue(part); + } + } else { + reasoningLine(`\n${formatToolCall(event.name, event.input)}\n`); + } + break; + case "tool-result": + if (toolDisplay === "blocks") { + for (const part of blockToolResultParts( + event.id, + event.name, + event.result, + event.isError, + toolState, + )) { + controller.enqueue(part); + } + } else if (event.isError) { + reasoningLine(`[tool] ${event.name} failed\n`); + } + break; + case "usage": + usage = mapUsage(event.usage); + break; + case "finish": + if (!streamedText && event.text) { + controller.enqueue({ + type: "text-delta", + id: ensureText(), + delta: event.text, + }); + } + break; + } + } + + closeDanglingToolCalls(); + closeReasoning(); + closeText(); + controller.enqueue({ + type: "finish", + usage: usage ?? EMPTY_USAGE, + finishReason: FINISH_STOP, + }); + controller.close(); + } catch (err) { + controller.enqueue({ type: "error", error: err }); + closeDanglingToolCalls(); + closeReasoning(); + closeText(); + controller.enqueue({ + type: "finish", + usage: usage ?? EMPTY_USAGE, + finishReason: FINISH_ERROR, + }); + controller.close(); + } + }, + }); } /** * Aggregate the normalized Cursor agent events into a non-streaming result for - * `doGenerate`. Same event source contract as {@link cursorEventsToStream}. - * Tool activity is folded into the reasoning text (display only). + * `doGenerate`. Same event source contract as {@link cursorEventsToStream} + * (consumed via `for await`). Tool activity is surfaced per {@link ToolDisplay} + * (default `"blocks"`): structured tool parts (see {@link blockToolCallParts} / + * {@link blockToolResultParts}), or folded into the reasoning text when + * `"reasoning"`. */ export async function cursorEventsToContent( - events: AsyncIterable, - toolDisplay: ToolDisplay = "reasoning", + events: AsyncIterable, + toolDisplay: ToolDisplay = "blocks", ): Promise<{ - content: Array; - finishReason: LanguageModelV3FinishReason; - usage: LanguageModelV3Usage; + content: Array; + finishReason: LanguageModelV3FinishReason; + usage: LanguageModelV3Usage; }> { - const content: Array = []; - const toolParts: Array = []; - // Open (unanswered) tool calls in blocks mode: id -> original tool name. - const openToolCalls = new Map(); - let text = ""; - let reasoning = ""; - let usage: LanguageModelV3Usage = EMPTY_USAGE; - let finishReason: LanguageModelV3FinishReason = FINISH_STOP; - - try { - for await (const event of events) { - switch (event.type) { - case "text-delta": - text += event.text; - break; - case "reasoning-delta": - reasoning += event.text; - break; - case "tool-call": - if (toolDisplay === "blocks") { - openToolCalls.set(event.id, event.name); - toolParts.push(toolCallContent(event.id, event.name, event.input)); - } else { - reasoning += `\n${formatToolCall(event.name, event.input)}\n`; - } - break; - case "tool-result": - if (toolDisplay === "blocks") { - openToolCalls.delete(event.id); - toolParts.push(toolResultContent(event.id, event.name, event.result, event.isError)); - } else if (event.isError) { - reasoning += `[tool] ${event.name} failed\n`; - } - break; - case "usage": - usage = mapUsage(event.usage); - break; - case "finish": - if (!text && event.text) text = event.text; - break; - } - } - } catch { - finishReason = FINISH_ERROR; - } - - // Close out any tool call whose completion never arrived (see DANGLING_TOOL_RESULT). - for (const [id, name] of openToolCalls) { - toolParts.push(toolResultContent(id, name, DANGLING_TOOL_RESULT, true)); - } - openToolCalls.clear(); - - if (reasoning) content.push({ type: "reasoning", text: reasoning }); - content.push(...toolParts); - if (text) content.push({ type: "text", text }); - - return { content, finishReason, usage }; + const content: Array = []; + const toolParts: Array = []; + // Blocks-mode tool bookkeeping (open non-edit calls + buffered edits). + const toolState = newBlockToolState(); + let text = ""; + let reasoning = ""; + let usage: LanguageModelV3Usage = EMPTY_USAGE; + let finishReason: LanguageModelV3FinishReason = FINISH_STOP; + + try { + for await (const event of events) { + switch (event.type) { + case "text-delta": + text += event.text; + break; + case "reasoning-delta": + reasoning += event.text; + break; + case "tool-call": + if (toolDisplay === "blocks") { + for (const part of blockToolCallParts( + event.id, + event.name, + event.input, + toolState, + )) { + toolParts.push(part as LanguageModelV3Content); + } + } else { + reasoning += `\n${formatToolCall(event.name, event.input)}\n`; + } + break; + case "tool-result": + if (toolDisplay === "blocks") { + for (const part of blockToolResultParts( + event.id, + event.name, + event.result, + event.isError, + toolState, + )) { + toolParts.push(part as LanguageModelV3Content); + } + } else if (event.isError) { + reasoning += `[tool] ${event.name} failed\n`; + } + break; + case "usage": + usage = mapUsage(event.usage); + break; + case "finish": + if (!text && event.text) text = event.text; + break; + } + } + } catch { + finishReason = FINISH_ERROR; + } + + // Close out any tool call whose completion never arrived (see DANGLING_TOOL_RESULT). + for (const part of blockDanglingParts(toolState)) { + toolParts.push(part as LanguageModelV3Content); + } + + if (reasoning) content.push({ type: "reasoning", text: reasoning }); + content.push(...toolParts); + if (text) content.push({ type: "text", text }); + + return { content, finishReason, usage }; } diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index b4de94a..0a818f2 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -1,118 +1,146 @@ import { describe, expect, it } from "vitest"; -import type { Run, SDKAgent, SDKUserMessage } from "@cursor/sdk"; -import { streamAgentTurn, type CursorEvent } from "../src/provider/agent-events.js"; +import type { Run, SDKUserMessage } from "@cursor/sdk"; +import { + streamAgentTurn, + type CursorEvent, +} from "../src/provider/agent-events.js"; +import type { AgentLike } from "../src/provider/agent-backend.js"; -const MESSAGE: SDKUserMessage = { type: "user", text: "hi" } as unknown as SDKUserMessage; +const MESSAGE: SDKUserMessage = { + type: "user", + text: "hi", +} as unknown as SDKUserMessage; -type OnDelta = (input: { update: Record & { type: string } }) => void; +type OnDelta = (input: { + update: Record & { type: string }; +}) => void; interface FakeRunResult { - status: string; - result?: string; + status: string; + result?: string; } -/** Build a fake SDKAgent whose send() drives onDelta and resolves wait(). */ +/** Build a fake agent (the {@link AgentLike} contract `streamAgentTurn` + * consumes) whose send() drives onDelta and resolves wait(). */ function fakeAgent(opts: { - updates?: Array & { type: string }>; - result?: FakeRunResult; - /** When set, reject the first N send() calls with this error. */ - rejectFirst?: { error: Error; times: number }; - sendCalls?: Array | undefined>; -}): SDKAgent { - let rejected = 0; - return { - agentId: "agent-test", - send: async (_message: SDKUserMessage, sendOptions?: Record) => { - opts.sendCalls?.push(sendOptions); - if (opts.rejectFirst && rejected < opts.rejectFirst.times) { - rejected++; - throw opts.rejectFirst.error; - } - const onDelta = sendOptions?.["onDelta"] as OnDelta | undefined; - for (const update of opts.updates ?? []) onDelta?.({ update }); - const run: Partial = { - wait: async () => (opts.result ?? { status: "finished", result: "" }) as never, - cancel: async () => {}, - }; - return run as Run; - }, - } as unknown as SDKAgent; + updates?: Array & { type: string }>; + result?: FakeRunResult; + /** When set, reject the first N send() calls with this error. */ + rejectFirst?: { error: Error; times: number }; + sendCalls?: Array | undefined>; +}): AgentLike { + let rejected = 0; + return { + agentId: "agent-test", + send: async ( + _message: SDKUserMessage, + sendOptions?: Record, + ) => { + opts.sendCalls?.push(sendOptions); + if (opts.rejectFirst && rejected < opts.rejectFirst.times) { + rejected++; + throw opts.rejectFirst.error; + } + const onDelta = sendOptions?.["onDelta"] as OnDelta | undefined; + for (const update of opts.updates ?? []) onDelta?.({ update }); + const run: Partial = { + wait: async () => + (opts.result ?? { status: "finished", result: "" }) as never, + cancel: async () => {}, + }; + return run as Run; + }, + } as unknown as AgentLike; } -async function collect(events: AsyncGenerator): Promise { - const out: CursorEvent[] = []; - for await (const e of events) out.push(e); - return out; +async function collect( + events: AsyncGenerator, +): Promise { + const out: CursorEvent[] = []; + for await (const e of events) out.push(e); + return out; } describe("streamAgentTurn run terminal status", () => { - it("throws when the run ends with status 'error' instead of finishing silently", async () => { - const agent = fakeAgent({ result: { status: "error", result: "boom" } }); - await expect(collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" }))).rejects.toThrow( - /error/i, - ); - }); + it("throws when the run ends with status 'error' instead of finishing silently", async () => { + const agent = fakeAgent({ result: { status: "error", result: "boom" } }); + await expect( + collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })), + ).rejects.toThrow(/error/i); + }); - it("completes without throwing when the run is cancelled", async () => { - const agent = fakeAgent({ result: { status: "cancelled" } }); - const events = await collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); - // No finish text is fabricated for a cancelled run. - const finish = events.find((e) => e.type === "finish"); - expect(finish).toBeDefined(); - expect((finish as { text?: string }).text).toBeUndefined(); - }); + it("completes without throwing when the run is cancelled", async () => { + const agent = fakeAgent({ result: { status: "cancelled" } }); + const events = await collect( + streamAgentTurn(agent, MESSAGE, { mode: "agent" }), + ); + // No finish text is fabricated for a cancelled run. + const finish = events.find((e) => e.type === "finish"); + expect(finish).toBeDefined(); + expect((finish as { text?: string }).text).toBeUndefined(); + }); }); describe("streamAgentTurn busy-agent recovery", () => { - it("retries send with local.force when the agent reports AgentBusyError", async () => { - const busy = new Error("agent busy"); - busy.name = "AgentBusyError"; - const sendCalls: Array | undefined> = []; - const agent = fakeAgent({ - rejectFirst: { error: busy, times: 1 }, - updates: [{ type: "text-delta", text: "ok" }], - result: { status: "finished", result: "ok" }, - sendCalls, - }); + it("retries send with local.force when the agent reports AgentBusyError", async () => { + const busy = new Error("agent busy"); + busy.name = "AgentBusyError"; + const sendCalls: Array | undefined> = []; + const agent = fakeAgent({ + rejectFirst: { error: busy, times: 1 }, + updates: [{ type: "text-delta", text: "ok" }], + result: { status: "finished", result: "ok" }, + sendCalls, + }); - const events = await collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); + const events = await collect( + streamAgentTurn(agent, MESSAGE, { mode: "agent" }), + ); - expect(sendCalls).toHaveLength(2); - expect(sendCalls[1]?.["local"]).toMatchObject({ force: true }); - expect(events).toContainEqual({ type: "text-delta", text: "ok" }); - }); + expect(sendCalls).toHaveLength(2); + expect(sendCalls[1]?.["local"]).toMatchObject({ force: true }); + expect(events).toContainEqual({ type: "text-delta", text: "ok" }); + }); - it("does not retry non-busy send failures", async () => { - const nope = new Error("auth failed"); - const sendCalls: Array | undefined> = []; - const agent = fakeAgent({ rejectFirst: { error: nope, times: 99 }, sendCalls }); + it("does not retry non-busy send failures", async () => { + const nope = new Error("auth failed"); + const sendCalls: Array | undefined> = []; + const agent = fakeAgent({ + rejectFirst: { error: nope, times: 99 }, + sendCalls, + }); - await expect(collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" }))).rejects.toThrow( - "auth failed", - ); - expect(sendCalls).toHaveLength(1); - }); + await expect( + collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })), + ).rejects.toThrow("auth failed"); + expect(sendCalls).toHaveLength(1); + }); }); describe("streamAgentTurn MCP error surfacing", () => { - it("marks an MCP tool result as error when its success value carries isError", async () => { - const agent = fakeAgent({ - updates: [ - { - type: "tool-call-completed", - callId: "c1", - toolCall: { - type: "mcp", - args: { toolName: "find_symbol", providerIdentifier: "serena" }, - result: { status: "success", value: { content: [], isError: true } }, - }, - }, - ], - result: { status: "finished", result: "" }, - }); + it("marks an MCP tool result as error when its success value carries isError", async () => { + const agent = fakeAgent({ + updates: [ + { + type: "tool-call-completed", + callId: "c1", + toolCall: { + type: "mcp", + args: { toolName: "find_symbol", providerIdentifier: "serena" }, + result: { + status: "success", + value: { content: [], isError: true }, + }, + }, + }, + ], + result: { status: "finished", result: "" }, + }); - const events = await collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); - const result = events.find((e) => e.type === "tool-result"); - expect(result).toMatchObject({ name: "serena/find_symbol", isError: true }); - }); + const events = await collect( + streamAgentTurn(agent, MESSAGE, { mode: "agent" }), + ); + const result = events.find((e) => e.type === "tool-result"); + expect(result).toMatchObject({ name: "serena/find_symbol", isError: true }); + }); }); diff --git a/test/message-map.test.ts b/test/message-map.test.ts index 979e706..236f9c3 100644 --- a/test/message-map.test.ts +++ b/test/message-map.test.ts @@ -1,71 +1,80 @@ import { describe, expect, it } from "vitest"; -import type { LanguageModelV2Prompt } from "@ai-sdk/provider"; -import { latestUserMessage, promptToCursorMessage } from "../src/provider/message-map.js"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import { + latestUserMessage, + promptToCursorMessage, +} from "../src/provider/message-map.js"; describe("promptToCursorMessage", () => { - it("flattens a multi-role conversation into a transcript", () => { - const prompt: LanguageModelV2Prompt = [ - { role: "system", content: "Be concise." }, - { role: "user", content: [{ type: "text", text: "Hello" }] }, - { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, - { role: "user", content: [{ type: "text", text: "What is 2+2?" }] }, - ]; - const msg = promptToCursorMessage(prompt); - expect(msg.text).toContain("# System\nBe concise."); - expect(msg.text).toContain("# User\nHello"); - expect(msg.text).toContain("# Assistant\nHi there"); - expect(msg.text).toContain("# User\nWhat is 2+2?"); - expect(msg.images).toBeUndefined(); - }); + it("flattens a multi-role conversation into a transcript", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "Be concise." }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + { role: "user", content: [{ type: "text", text: "What is 2+2?" }] }, + ]; + const msg = promptToCursorMessage(prompt); + expect(msg.text).toContain("# System\nBe concise."); + expect(msg.text).toContain("# User\nHello"); + expect(msg.text).toContain("# Assistant\nHi there"); + expect(msg.text).toContain("# User\nWhat is 2+2?"); + expect(msg.images).toBeUndefined(); + }); - it("attaches images from the final user turn as base64 data", () => { - const bytes = new Uint8Array([1, 2, 3, 4]); - const prompt: LanguageModelV2Prompt = [ - { - role: "user", - content: [ - { type: "text", text: "Describe this" }, - { type: "file", data: bytes, mediaType: "image/png" }, - ], - }, - ]; - const msg = promptToCursorMessage(prompt); - expect(msg.images).toHaveLength(1); - expect(msg.images![0]).toEqual({ - data: Buffer.from(bytes).toString("base64"), - mimeType: "image/png", - }); - expect(msg.text).toContain("[image attached]"); - }); + it("attaches images from the final user turn as base64 data", () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const prompt: LanguageModelV3Prompt = [ + { + role: "user", + content: [ + { type: "text", text: "Describe this" }, + { type: "file", data: bytes, mediaType: "image/png" }, + ], + }, + ]; + const msg = promptToCursorMessage(prompt); + expect(msg.images).toHaveLength(1); + expect(msg.images![0]).toEqual({ + data: Buffer.from(bytes).toString("base64"), + mimeType: "image/png", + }); + expect(msg.text).toContain("[image attached]"); + }); - it("passes through image URLs", () => { - const prompt: LanguageModelV2Prompt = [ - { - role: "user", - content: [{ type: "file", data: "https://example.com/a.png", mediaType: "image/png" }], - }, - ]; - const msg = promptToCursorMessage(prompt); - expect(msg.images![0]).toEqual({ url: "https://example.com/a.png" }); - }); + it("passes through image URLs", () => { + const prompt: LanguageModelV3Prompt = [ + { + role: "user", + content: [ + { + type: "file", + data: "https://example.com/a.png", + mediaType: "image/png", + }, + ], + }, + ]; + const msg = promptToCursorMessage(prompt); + expect(msg.images![0]).toEqual({ url: "https://example.com/a.png" }); + }); }); describe("latestUserMessage", () => { - it("returns only the final user turn (for resuming a pooled agent)", () => { - const prompt: LanguageModelV2Prompt = [ - { role: "system", content: "be nice" }, - { role: "user", content: [{ type: "text", text: "first" }] }, - { role: "assistant", content: [{ type: "text", text: "ok" }] }, - { role: "user", content: [{ type: "text", text: "second" }] }, - ]; - expect(latestUserMessage(prompt)).toEqual({ text: "second" }); - }); + it("returns only the final user turn (for resuming a pooled agent)", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "be nice" }, + { role: "user", content: [{ type: "text", text: "first" }] }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { role: "user", content: [{ type: "text", text: "second" }] }, + ]; + expect(latestUserMessage(prompt)).toEqual({ text: "second" }); + }); - it("returns undefined when the last turn is not a user message", () => { - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "hi" }] }, - { role: "assistant", content: [{ type: "text", text: "bye" }] }, - ]; - expect(latestUserMessage(prompt)).toBeUndefined(); - }); + it("returns undefined when the last turn is not a user message", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "bye" }] }, + ]; + expect(latestUserMessage(prompt)).toBeUndefined(); + }); }); diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 063244c..598d76f 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -2,31 +2,34 @@ import { describe, expect, it } from "vitest"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; import type { CursorEvent } from "../src/provider/agent-events.js"; import { - cursorEventsToContent, - cursorEventsToStream, - mapUsage, + cursorEventsToContent, + cursorEventsToStream, + mapUsage, } from "../src/provider/stream-map.js"; async function* gen(events: CursorEvent[]): AsyncGenerator { - for (const e of events) yield e; + for (const e of events) yield e; } -async function* genThenThrow(events: CursorEvent[], err: unknown): AsyncGenerator { - for (const e of events) yield e; - throw err; +async function* genThenThrow( + events: CursorEvent[], + err: unknown, +): AsyncGenerator { + for (const e of events) yield e; + throw err; } async function collect( - stream: ReadableStream, + stream: ReadableStream, ): Promise { - const reader = stream.getReader(); - const out: LanguageModelV3StreamPart[] = []; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - out.push(value); - } - return out; + const reader = stream.getReader(); + const out: LanguageModelV3StreamPart[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + out.push(value); + } + return out; } const types = (parts: LanguageModelV3StreamPart[]) => parts.map((p) => p.type); @@ -34,326 +37,665 @@ const types = (parts: LanguageModelV3StreamPart[]) => parts.map((p) => p.type); // Mirrors the exact sequence observed from a live Cursor agent in CI: // 4 reasoning deltas, then 2 text deltas, then usage, then finish. const LIVE_SEQUENCE: CursorEvent[] = [ - { type: "reasoning-delta", text: "th" }, - { type: "reasoning-delta", text: "in" }, - { type: "reasoning-delta", text: "ki" }, - { type: "reasoning-delta", text: "ng" }, - { type: "text-delta", text: "PO" }, - { type: "text-delta", text: "NG" }, - { type: "usage", usage: { inputTokens: 10251, outputTokens: 46, cacheReadTokens: 7412, cacheWriteTokens: 0 } }, - { type: "finish", text: "PONG" }, + { type: "reasoning-delta", text: "th" }, + { type: "reasoning-delta", text: "in" }, + { type: "reasoning-delta", text: "ki" }, + { type: "reasoning-delta", text: "ng" }, + { type: "text-delta", text: "PO" }, + { type: "text-delta", text: "NG" }, + { + type: "usage", + usage: { + inputTokens: 10251, + outputTokens: 46, + cacheReadTokens: 7412, + cacheWriteTokens: 0, + }, + }, + { type: "finish", text: "PONG" }, ]; describe("cursorEventsToStream", () => { - it("maps the live reasoning+text sequence with clean block nesting", async () => { - const parts = await collect(cursorEventsToStream(gen(LIVE_SEQUENCE))); - expect(types(parts)).toEqual([ - "stream-start", - "reasoning-start", - "reasoning-delta", - "reasoning-delta", - "reasoning-delta", - "reasoning-delta", - "reasoning-end", // closed before text starts - "text-start", - "text-delta", - "text-delta", - "text-end", - "finish", - ]); - - const text = parts - .filter((p): p is Extract => p.type === "text-delta") - .map((p) => p.delta) - .join(""); - expect(text).toBe("PONG"); - - const finish = parts.find((p) => p.type === "finish"); - expect(finish).toMatchObject({ - finishReason: { unified: "stop" }, - usage: { - inputTokens: { total: 10251, cacheRead: 7412 }, - outputTokens: { total: 46 }, - }, - }); - }); - - it("closes the open text part when reasoning resumes so parts render in true order", async () => { - // Interleaved turn: intro text → tool/reasoning activity → final text. The - // final text must land in a NEW part (text-1) that starts after the - // reasoning block — appending it to text-0 makes the final answer render - // ABOVE the thinking blocks in opencode's UI. - const events: CursorEvent[] = [ - { type: "text-delta", text: "intro" }, - { type: "reasoning-delta", text: "thinking" }, - { type: "text-delta", text: "final" }, - { type: "finish" }, - ]; - const parts = await collect(cursorEventsToStream(gen(events))); - expect(types(parts)).toEqual([ - "stream-start", - "text-start", - "text-delta", - "text-end", // closed before reasoning starts - "reasoning-start", - "reasoning-delta", - "reasoning-end", - "text-start", // fresh part for the final answer - "text-delta", - "text-end", - "finish", - ]); - - const textStartIds = parts - .filter((p): p is Extract => p.type === "text-start") - .map((p) => p.id); - expect(new Set(textStartIds).size).toBe(2); - - // Each delta belongs to the part that was open at the time. - const deltas = parts.filter( - (p): p is Extract => p.type === "text-delta", - ); - expect(deltas[0]!.id).toBe(textStartIds[0]); - expect(deltas[1]!.id).toBe(textStartIds[1]); - }); - - it("renders Cursor's own tool activity as reasoning, not tool-call parts", async () => { - const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "write", input: { path: "a.txt" } }, - { type: "tool-result", id: "c1", name: "write", result: { ok: true }, isError: false }, - { type: "tool-call", id: "c2", name: "serena/find_symbol", input: {} }, - { type: "tool-result", id: "c2", name: "serena/find_symbol", result: { e: 1 }, isError: true }, - { type: "text-delta", text: "done" }, - { type: "finish" }, - ]; - const parts = await collect(cursorEventsToStream(gen(events))); - - // No tool-call/tool-result parts cross into opencode (avoids "unavailable tool"). - expect(types(parts)).not.toContain("tool-call"); - expect(types(parts)).not.toContain("tool-result"); - - const reasoning = parts - .filter((p): p is Extract => p.type === "reasoning-delta") - .map((p) => p.delta) - .join(""); - expect(reasoning).toContain("[tool] write"); - expect(reasoning).toContain('{"path":"a.txt"}'); - // A failed tool surfaces its failure; a successful one does not add a status line. - expect(reasoning).toContain("[tool] serena/find_symbol failed"); - expect(reasoning).not.toContain("write failed"); - - // The final answer text is unpolluted by tool noise. - const text = parts - .filter((p): p is Extract => p.type === "text-delta") - .map((p) => p.delta) - .join(""); - expect(text).toBe("done"); - }); - - it("emits structured provider-executed tool-call/tool-result parts in 'blocks' mode", async () => { - const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "shell", input: { command: "ls" } }, - { type: "tool-result", id: "c1", name: "shell", result: { stdout: "a\nb" }, isError: false }, - { type: "tool-call", id: "c2", name: "serena/find_symbol", input: { q: "x" } }, - { type: "tool-result", id: "c2", name: "serena/find_symbol", result: { err: "no" }, isError: true }, - { type: "text-delta", text: "done" }, - { type: "finish" }, - ]; - const parts = await collect(cursorEventsToStream(gen(events), "blocks")); - - // Structured parts ARE emitted (not reasoning) in blocks mode. - expect(types(parts)).toContain("tool-call"); - expect(types(parts)).toContain("tool-result"); - expect(types(parts)).not.toContain("reasoning-delta"); - - // Names are prefixed/sanitized so they can't collide with opencode-registered - // tools, and parts carry providerExecuted+dynamic so ai's parseToolCall - // accepts them without registered-tool validation. - const call = parts.find( - (p): p is Extract => p.type === "tool-call", - )!; - expect(call).toMatchObject({ - toolCallId: "c1", - toolName: "cursor_shell", - input: JSON.stringify({ command: "ls" }), - providerExecuted: true, - dynamic: true, - }); - - // tool-result carries the V3-spec `result` + `isError` fields (ai v6 reads - // `chunk.result`; a structured `output` field would stream through as undefined). - const results = parts.filter((p) => p.type === "tool-result") as Array< - Record - >; - expect(results[0]).toMatchObject({ - toolCallId: "c1", - toolName: "cursor_shell", - providerExecuted: true, - dynamic: true, - result: { stdout: "a\nb" }, - isError: false, - }); - expect(results[1]).toMatchObject({ - toolCallId: "c2", - toolName: "cursor_serena_find_symbol", - result: { err: "no" }, - isError: true, - }); - - const text = parts - .filter((p): p is Extract => p.type === "text-delta") - .map((p) => p.delta) - .join(""); - expect(text).toBe("done"); - }); - - it("synthesizes an error tool-result for a dangling tool-call in 'blocks' mode", async () => { - // A run that dies mid-tool emits tool-call-started but never -completed. - // Without a matching tool-result, opencode renders "Tool execution aborted". - const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "shell", input: { command: "ls" } }, - { type: "finish" }, - ]; - const parts = await collect(cursorEventsToStream(gen(events), "blocks")); - - const result = parts.find((p) => p.type === "tool-result") as Record; - expect(result).toMatchObject({ - toolCallId: "c1", - toolName: "cursor_shell", - isError: true, - providerExecuted: true, - dynamic: true, - }); - // Synthetic result arrives before finish so the part is never dangling. - expect(types(parts).indexOf("tool-result")).toBeLessThan(types(parts).indexOf("finish")); - }); - - it("synthesizes error tool-results for dangling calls when the source throws", async () => { - const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, - ]; - const parts = await collect( - cursorEventsToStream(genThenThrow(events, new Error("run died")), "blocks"), - ); - const result = parts.find((p) => p.type === "tool-result") as Record; - expect(result).toMatchObject({ toolCallId: "c1", toolName: "cursor_read", isError: true }); - const finish = parts.find((p) => p.type === "finish"); - expect(finish).toMatchObject({ finishReason: { unified: "error" } }); - }); - - it("does not synthesize results for tool calls that completed", async () => { - const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "shell", input: {} }, - { type: "tool-result", id: "c1", name: "shell", result: { ok: 1 }, isError: false }, - { type: "finish" }, - ]; - const parts = await collect(cursorEventsToStream(gen(events), "blocks")); - const results = parts.filter((p) => p.type === "tool-result"); - expect(results).toHaveLength(1); - expect(results[0]).toMatchObject({ isError: false }); - }); - - it("falls back to finish.text when the agent streamed no text deltas", async () => { - const events: CursorEvent[] = [{ type: "finish", text: "final answer" }]; - const parts = await collect(cursorEventsToStream(gen(events))); - expect(types(parts)).toEqual(["stream-start", "text-start", "text-delta", "text-end", "finish"]); - const delta = parts.find((p) => p.type === "text-delta"); - expect(delta).toMatchObject({ delta: "final answer" }); - }); - - it("emits an error part and an error finish when the source throws", async () => { - const boom = new Error("agent exploded"); - const parts = await collect( - cursorEventsToStream(genThenThrow([{ type: "text-delta", text: "partial" }], boom)), - ); - const error = parts.find((p) => p.type === "error"); - expect(error).toMatchObject({ error: boom }); - // text block that was opened still gets closed - expect(types(parts)).toContain("text-end"); - const finish = parts.find((p) => p.type === "finish"); - expect(finish).toMatchObject({ finishReason: { unified: "error" } }); - }); - - it("uses empty usage when no usage event arrives", async () => { - const parts = await collect(cursorEventsToStream(gen([{ type: "text-delta", text: "hi" }, { type: "finish" }]))); - const finish = parts.find((p) => p.type === "finish"); - expect(finish).toMatchObject({ - usage: { inputTokens: { total: undefined }, outputTokens: { total: undefined } }, - }); - }); + it("maps the live reasoning+text sequence with clean block nesting", async () => { + const parts = await collect(cursorEventsToStream(gen(LIVE_SEQUENCE))); + expect(types(parts)).toEqual([ + "stream-start", + "reasoning-start", + "reasoning-delta", + "reasoning-delta", + "reasoning-delta", + "reasoning-delta", + "reasoning-end", // closed before text starts + "text-start", + "text-delta", + "text-delta", + "text-end", + "finish", + ]); + + const text = parts + .filter( + (p): p is Extract => + p.type === "text-delta", + ) + .map((p) => p.delta) + .join(""); + expect(text).toBe("PONG"); + + const finish = parts.find((p) => p.type === "finish"); + expect(finish).toMatchObject({ + finishReason: { unified: "stop" }, + usage: { + inputTokens: { total: 10251, cacheRead: 7412 }, + outputTokens: { total: 46 }, + }, + }); + }); + + it("closes the open text part when reasoning resumes so parts render in true order", async () => { + // Interleaved turn: intro text → tool/reasoning activity → final text. The + // final text must land in a NEW part (text-1) that starts after the + // reasoning block — appending it to text-0 makes the final answer render + // ABOVE the thinking blocks in opencode's UI. + const events: CursorEvent[] = [ + { type: "text-delta", text: "intro" }, + { type: "reasoning-delta", text: "thinking" }, + { type: "text-delta", text: "final" }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + expect(types(parts)).toEqual([ + "stream-start", + "text-start", + "text-delta", + "text-end", // closed before reasoning starts + "reasoning-start", + "reasoning-delta", + "reasoning-end", + "text-start", // fresh part for the final answer + "text-delta", + "text-end", + "finish", + ]); + + const textStartIds = parts + .filter( + (p): p is Extract => + p.type === "text-start", + ) + .map((p) => p.id); + expect(new Set(textStartIds).size).toBe(2); + + // Each delta belongs to the part that was open at the time. + const deltas = parts.filter( + (p): p is Extract => + p.type === "text-delta", + ); + expect(deltas[0]!.id).toBe(textStartIds[0]); + expect(deltas[1]!.id).toBe(textStartIds[1]); + }); + + it("renders Cursor's own tool activity as reasoning, not tool-call parts", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "c1", name: "write", input: { path: "a.txt" } }, + { + type: "tool-result", + id: "c1", + name: "write", + result: { ok: true }, + isError: false, + }, + { type: "tool-call", id: "c2", name: "serena/find_symbol", input: {} }, + { + type: "tool-result", + id: "c2", + name: "serena/find_symbol", + result: { e: 1 }, + isError: true, + }, + { type: "text-delta", text: "done" }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "reasoning")); + + // No tool-call/tool-result parts cross into opencode (avoids "unavailable tool"). + expect(types(parts)).not.toContain("tool-call"); + expect(types(parts)).not.toContain("tool-result"); + + const reasoning = parts + .filter( + ( + p, + ): p is Extract< + LanguageModelV3StreamPart, + { type: "reasoning-delta" } + > => p.type === "reasoning-delta", + ) + .map((p) => p.delta) + .join(""); + expect(reasoning).toContain("[tool] write"); + expect(reasoning).toContain('{"path":"a.txt"}'); + // A failed tool surfaces its failure; a successful one does not add a status line. + expect(reasoning).toContain("[tool] serena/find_symbol failed"); + expect(reasoning).not.toContain("write failed"); + + // The final answer text is unpolluted by tool noise. + const text = parts + .filter( + (p): p is Extract => + p.type === "text-delta", + ) + .map((p) => p.delta) + .join(""); + expect(text).toBe("done"); + }); + + it("emits structured provider-executed tool-call/tool-result parts in 'blocks' mode", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "c1", name: "shell", input: { command: "ls" } }, + { + type: "tool-result", + id: "c1", + name: "shell", + result: { stdout: "a\nb" }, + isError: false, + }, + { + type: "tool-call", + id: "c2", + name: "serena/find_symbol", + input: { q: "x" }, + }, + { + type: "tool-result", + id: "c2", + name: "serena/find_symbol", + result: { err: "no" }, + isError: true, + }, + { type: "text-delta", text: "done" }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + + // Structured parts ARE emitted (not reasoning) in blocks mode. + expect(types(parts)).toContain("tool-call"); + expect(types(parts)).toContain("tool-result"); + expect(types(parts)).not.toContain("reasoning-delta"); + + // Names are prefixed/sanitized so they can't collide with opencode-registered + // tools, and parts carry providerExecuted+dynamic so ai's parseToolCall + // accepts them without registered-tool validation. + const call = parts.find( + (p): p is Extract => + p.type === "tool-call", + )!; + expect(call).toMatchObject({ + toolCallId: "c1", + toolName: "cursor_shell", + input: JSON.stringify({ command: "ls" }), + providerExecuted: true, + dynamic: true, + }); + + // tool-result carries the V3-spec `result` + `isError` fields (ai v6 reads + // `chunk.result`; a structured `output` field would stream through as undefined). + const results = parts.filter((p) => p.type === "tool-result") as Array< + Record + >; + expect(results[0]).toMatchObject({ + toolCallId: "c1", + toolName: "cursor_shell", + providerExecuted: true, + dynamic: true, + result: { stdout: "a\nb" }, + isError: false, + }); + expect(results[1]).toMatchObject({ + toolCallId: "c2", + toolName: "cursor_serena_find_symbol", + result: { err: "no" }, + isError: true, + }); + + const text = parts + .filter( + (p): p is Extract => + p.type === "text-delta", + ) + .map((p) => p.delta) + .join(""); + expect(text).toBe("done"); + }); + + it("synthesizes an error tool-result for a dangling tool-call in 'blocks' mode", async () => { + // A run that dies mid-tool emits tool-call-started but never -completed. + // Without a matching tool-result, opencode renders "Tool execution aborted". + const events: CursorEvent[] = [ + { type: "tool-call", id: "c1", name: "shell", input: { command: "ls" } }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + + const result = parts.find((p) => p.type === "tool-result") as Record< + string, + unknown + >; + expect(result).toMatchObject({ + toolCallId: "c1", + toolName: "cursor_shell", + isError: true, + providerExecuted: true, + dynamic: true, + }); + // Synthetic result arrives before finish so the part is never dangling. + expect(types(parts).indexOf("tool-result")).toBeLessThan( + types(parts).indexOf("finish"), + ); + }); + + it("synthesizes error tool-results for dangling calls when the source throws", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, + ]; + const parts = await collect( + cursorEventsToStream( + genThenThrow(events, new Error("run died")), + "blocks", + ), + ); + const result = parts.find((p) => p.type === "tool-result") as Record< + string, + unknown + >; + expect(result).toMatchObject({ + toolCallId: "c1", + toolName: "cursor_read", + isError: true, + }); + const finish = parts.find((p) => p.type === "finish"); + expect(finish).toMatchObject({ finishReason: { unified: "error" } }); + }); + + it("does not synthesize results for tool calls that completed", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "c1", name: "shell", input: {} }, + { + type: "tool-result", + id: "c1", + name: "shell", + result: { ok: 1 }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + const results = parts.filter((p) => p.type === "tool-result"); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ isError: false }); + }); + + it("falls back to finish.text when the agent streamed no text deltas", async () => { + const events: CursorEvent[] = [{ type: "finish", text: "final answer" }]; + const parts = await collect(cursorEventsToStream(gen(events))); + expect(types(parts)).toEqual([ + "stream-start", + "text-start", + "text-delta", + "text-end", + "finish", + ]); + const delta = parts.find((p) => p.type === "text-delta"); + expect(delta).toMatchObject({ delta: "final answer" }); + }); + + it("emits an error part and an error finish when the source throws", async () => { + const boom = new Error("agent exploded"); + const parts = await collect( + cursorEventsToStream( + genThenThrow([{ type: "text-delta", text: "partial" }], boom), + ), + ); + const error = parts.find((p) => p.type === "error"); + expect(error).toMatchObject({ error: boom }); + // text block that was opened still gets closed + expect(types(parts)).toContain("text-end"); + const finish = parts.find((p) => p.type === "finish"); + expect(finish).toMatchObject({ finishReason: { unified: "error" } }); + }); + + it("uses empty usage when no usage event arrives", async () => { + const parts = await collect( + cursorEventsToStream( + gen([{ type: "text-delta", text: "hi" }, { type: "finish" }]), + ), + ); + const finish = parts.find((p) => p.type === "finish"); + expect(finish).toMatchObject({ + usage: { + inputTokens: { total: undefined }, + outputTokens: { total: undefined }, + }, + }); + }); }); describe("cursorEventsToContent (doGenerate)", () => { - it("aggregates reasoning, text, and tool activity with usage", async () => { - const { content, finishReason, usage } = await cursorEventsToContent(gen(LIVE_SEQUENCE)); - expect(finishReason).toMatchObject({ unified: "stop" }); - expect(usage).toMatchObject({ inputTokens: { total: 10251 }, outputTokens: { total: 46 } }); - // reasoning first, then text - expect(content[0]).toMatchObject({ type: "reasoning", text: "thinking" }); - expect(content.at(-1)).toMatchObject({ type: "text", text: "PONG" }); - }); - - it("folds tool activity into reasoning content", async () => { - const { content } = await cursorEventsToContent( - gen([ - { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, - { type: "text-delta", text: "ok" }, - { type: "finish" }, - ]), - ); - const reasoning = content.find((c) => c.type === "reasoning"); - expect(reasoning).toMatchObject({ type: "reasoning" }); - expect((reasoning as { text: string }).text).toContain("[tool] read"); - }); - - it("emits tool-call/tool-result content items in 'blocks' mode", async () => { - const { content } = await cursorEventsToContent( - gen([ - { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, - { type: "tool-result", id: "c1", name: "read", result: { data: "hi" }, isError: false }, - { type: "text-delta", text: "ok" }, - { type: "finish" }, - ]), - "blocks", - ); - const callItem = content.find((c) => c.type === "tool-call"); - expect(callItem).toMatchObject({ - toolCallId: "c1", - toolName: "cursor_read", - providerExecuted: true, - dynamic: true, - }); - const resultItem = content.find((c) => c.type === "tool-result") as Record; - expect(resultItem).toMatchObject({ result: { data: "hi" }, isError: false }); - expect(content.find((c) => c.type === "reasoning")).toBeUndefined(); - expect(content.at(-1)).toMatchObject({ type: "text", text: "ok" }); - }); - - it("synthesizes an error tool-result content item for a dangling call in 'blocks' mode", async () => { - const { content } = await cursorEventsToContent( - gen([ - { type: "tool-call", id: "c1", name: "shell", input: {} }, - { type: "finish" }, - ]), - "blocks", - ); - const resultItem = content.find((c) => c.type === "tool-result") as Record; - expect(resultItem).toMatchObject({ toolCallId: "c1", toolName: "cursor_shell", isError: true }); - }); - - it("reports finishReason 'error' when the source throws", async () => { - const { finishReason } = await cursorEventsToContent( - genThenThrow([{ type: "text-delta", text: "x" }], new Error("nope")), - ); - expect(finishReason).toMatchObject({ unified: "error" }); - }); + it("aggregates reasoning, text, and tool activity with usage", async () => { + const { content, finishReason, usage } = await cursorEventsToContent( + gen(LIVE_SEQUENCE), + ); + expect(finishReason).toMatchObject({ unified: "stop" }); + expect(usage).toMatchObject({ + inputTokens: { total: 10251 }, + outputTokens: { total: 46 }, + }); + // reasoning first, then text + expect(content[0]).toMatchObject({ type: "reasoning", text: "thinking" }); + expect(content[content.length - 1]).toMatchObject({ + type: "text", + text: "PONG", + }); + }); + + it("folds tool activity into reasoning content", async () => { + const { content } = await cursorEventsToContent( + gen([ + { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, + { type: "text-delta", text: "ok" }, + { type: "finish" }, + ]), + "reasoning", + ); + const reasoning = content.find((c) => c.type === "reasoning"); + expect(reasoning).toMatchObject({ type: "reasoning" }); + expect((reasoning as { text: string }).text).toContain("[tool] read"); + }); + + it("emits tool-call/tool-result content items in 'blocks' mode", async () => { + const { content } = await cursorEventsToContent( + gen([ + { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, + { + type: "tool-result", + id: "c1", + name: "read", + result: { data: "hi" }, + isError: false, + }, + { type: "text-delta", text: "ok" }, + { type: "finish" }, + ]), + "blocks", + ); + const callItem = content.find((c) => c.type === "tool-call"); + expect(callItem).toMatchObject({ + toolCallId: "c1", + toolName: "cursor_read", + providerExecuted: true, + dynamic: true, + }); + const resultItem = content.find((c) => c.type === "tool-result") as Record< + string, + unknown + >; + expect(resultItem).toMatchObject({ + result: { data: "hi" }, + isError: false, + }); + expect(content.find((c) => c.type === "reasoning")).toBeUndefined(); + expect(content[content.length - 1]).toMatchObject({ + type: "text", + text: "ok", + }); + }); + + it("synthesizes an error tool-result content item for a dangling call in 'blocks' mode", async () => { + const { content } = await cursorEventsToContent( + gen([ + { type: "tool-call", id: "c1", name: "shell", input: {} }, + { type: "finish" }, + ]), + "blocks", + ); + const resultItem = content.find((c) => c.type === "tool-result") as Record< + string, + unknown + >; + expect(resultItem).toMatchObject({ + toolCallId: "c1", + toolName: "cursor_shell", + isError: true, + }); + }); + + it("reports finishReason 'error' when the source throws", async () => { + const { finishReason } = await cursorEventsToContent( + genThenThrow([{ type: "text-delta", text: "x" }], new Error("nope")), + ); + expect(finishReason).toMatchObject({ unified: "error" }); + }); +}); + +// A unified diff as Cursor returns it in an edit result's `value.diffString`. +const EDIT_DIFF = [ + "Index: /a.ts", + "===================================================================", + "--- /a.ts", + "+++ /a.ts", + "@@ -1,3 +1,3 @@", + " const x = 1;", + "-const y = 2;", + "+const y = 3;", + " const z = 4;", +].join("\n"); + +type ToolCallPart = Extract; +type ToolResultPart = Extract< + LanguageModelV3StreamPart, + { type: "tool-result" } +>; +const toolCalls = (parts: LanguageModelV3StreamPart[]) => + parts.filter((p): p is ToolCallPart => p.type === "tool-call"); +const toolResults = (parts: LanguageModelV3StreamPart[]) => + parts.filter((p): p is ToolResultPart => p.type === "tool-result"); + +describe("native edit mapping (blocks)", () => { + it("maps a Cursor edit onto opencode's registered `edit` tool with a reconstructed input + metadata.diff", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { + type: "tool-result", + id: "e1", + name: "edit", + result: { + status: "success", + value: { diffString: EDIT_DIFF, linesAdded: 1, linesRemoved: 1 }, + }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + + // Emitted under the REGISTERED name `edit` (not `cursor_edit`) so opencode's + // diff viewer renders; input matches opencode's edit schema, reconstructed + // from the diff's -/+ lines. + const call = toolCalls(parts)[0]!; + expect(call).toMatchObject({ + toolCallId: "e1", + toolName: "edit", + input: JSON.stringify({ + filePath: "/a.ts", + oldString: "const y = 2;", + newString: "const y = 3;", + }), + providerExecuted: true, + dynamic: true, + }); + + // Result carries the {title, metadata:{diff}, output} shape opencode folds + // into state.* — the diff viewer keys on metadata.diff. + const result = toolResults(parts)[0]! as unknown as { + result: Record; + }; + expect(result).toMatchObject({ + toolCallId: "e1", + toolName: "edit", + isError: false, + providerExecuted: true, + dynamic: true, + result: { + title: "/a.ts", + metadata: { diff: EDIT_DIFF, diagnostics: {} }, + output: "Edit applied (+1/-1).", + }, + }); + }); + + it("holds the edit tool-call until its result (the diff only arrives with the result)", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { type: "reasoning-delta", text: "thinking" }, + { + type: "tool-result", + id: "e1", + name: "edit", + result: { status: "success", value: { diffString: EDIT_DIFF } }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + const callIndex = parts.findIndex((p) => p.type === "tool-call"); + const reasoningIndex = parts.findIndex((p) => p.type === "reasoning-delta"); + // The edit call is emitted AFTER the interleaved reasoning, i.e. at result time. + expect(reasoningIndex).toBeGreaterThanOrEqual(0); + expect(callIndex).toBeGreaterThan(reasoningIndex); + }); + + it("falls back to a safe `cursor_edit` block when the edit result is an error", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { + type: "tool-result", + id: "e1", + name: "edit", + result: { status: "error", error: "boom" }, + isError: true, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + expect(toolCalls(parts)[0]).toMatchObject({ + toolName: "cursor_edit", + dynamic: true, + }); + expect(toolResults(parts)[0]).toMatchObject({ + toolName: "cursor_edit", + isError: true, + }); + }); + + it("falls back to `cursor_edit` when a successful result carries no usable diff", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { + type: "tool-result", + id: "e1", + name: "edit", + result: { status: "success", value: {} }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + expect(toolCalls(parts)[0]).toMatchObject({ toolName: "cursor_edit" }); + expect(toolResults(parts)[0]).toMatchObject({ toolName: "cursor_edit" }); + }); + + it("closes a dangling edit (call, no result) as a safe `cursor_edit` error block", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + expect(toolCalls(parts)[0]).toMatchObject({ toolName: "cursor_edit" }); + expect(toolResults(parts)[0]).toMatchObject({ + toolName: "cursor_edit", + isError: true, + }); + }); + + it("leaves non-edit tools as prefixed `cursor_*` blocks", async () => { + const events: CursorEvent[] = [ + { type: "tool-call", id: "c1", name: "read", input: { path: "/a.ts" } }, + { + type: "tool-result", + id: "c1", + name: "read", + result: { ok: true }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + expect(toolCalls(parts)[0]).toMatchObject({ toolName: "cursor_read" }); + }); + + it("maps edits onto native `edit` content items in doGenerate", async () => { + const { content } = await cursorEventsToContent( + gen([ + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { + type: "tool-result", + id: "e1", + name: "edit", + result: { + status: "success", + value: { diffString: EDIT_DIFF, linesAdded: 1, linesRemoved: 1 }, + }, + isError: false, + }, + { type: "finish" }, + ]), + "blocks", + ); + const call = content.find( + (c) => c.type === "tool-call", + ) as unknown as Record; + const result = content.find((c) => c.type === "tool-result") as unknown as { + result: Record; + }; + expect(call).toMatchObject({ + toolName: "edit", + input: JSON.stringify({ + filePath: "/a.ts", + oldString: "const y = 2;", + newString: "const y = 3;", + }), + }); + expect(result.result).toMatchObject({ metadata: { diff: EDIT_DIFF } }); + }); }); describe("mapUsage", () => { - it("maps Cursor usage into the V3 nested shape", () => { - expect(mapUsage({ inputTokens: 100, outputTokens: 20, cacheReadTokens: 80, cacheWriteTokens: 5 })).toEqual({ - inputTokens: { total: 100, noCache: undefined, cacheRead: 80, cacheWrite: 5 }, - outputTokens: { total: 20, text: undefined, reasoning: undefined }, - }); - }); + it("maps Cursor usage into the V3 nested shape", () => { + expect( + mapUsage({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheWriteTokens: 5, + }), + ).toEqual({ + inputTokens: { + total: 100, + noCache: undefined, + cacheRead: 80, + cacheWrite: 5, + }, + outputTokens: { total: 20, text: undefined, reasoning: undefined }, + }); + }); }); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..ac45f58 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/tsconfig.json b/tsconfig.json index 6f6e0a5..1e77169 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,11 +9,9 @@ "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true, - "declaration": true, - "outDir": "dist", - "rootDir": "src", + "noEmit": true, "types": ["node"] }, - "include": ["src"], - "exclude": ["node_modules", "dist", "test"] + "include": ["src", "test"], + "exclude": ["node_modules", "dist"] } diff --git a/tsup.config.ts b/tsup.config.ts index 3db0fd2..2376ca1 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,18 +1,21 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: { - "provider/index": "src/provider/index.ts", - "plugin/index": "src/plugin/index.ts", - // Node sidecar hosting @cursor/sdk traffic when the plugin runs under Bun - // (Bun's node:http2 breaks Cursor's streaming RPC). Spawned, not imported. - "sidecar/agent-host": "src/sidecar/agent-host.mjs", - }, - format: ["esm"], - target: "node22", - dts: true, - clean: true, - sourcemap: true, - // @cursor/sdk is heavy and resolved at runtime; keep these external. - external: ["@cursor/sdk", "@ai-sdk/provider", "@opencode-ai/plugin"], + // Emit config (src-only rootDir + declaration); the root tsconfig.json is the + // broad editor/typecheck project that also covers test/. + tsconfig: "tsconfig.build.json", + entry: { + "provider/index": "src/provider/index.ts", + "plugin/index": "src/plugin/index.ts", + // Node sidecar hosting @cursor/sdk traffic when the plugin runs under Bun + // (Bun's node:http2 breaks Cursor's streaming RPC). Spawned, not imported. + "sidecar/agent-host": "src/sidecar/agent-host.mjs", + }, + format: ["esm"], + target: "node22", + dts: true, + clean: true, + sourcemap: true, + // @cursor/sdk is heavy and resolved at runtime; keep these external. + external: ["@cursor/sdk", "@ai-sdk/provider", "@opencode-ai/plugin"], });