diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c67944..f7f7397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Fixed: auto-compaction (and manual `/compact`) failed with `Tool call not + allowed while generating summary` whenever the Cursor agent used a tool while + summarizing.** opencode declares zero tools on a compaction/summary turn, but + the Cursor agent runs its own tools regardless; the provider forwarded that + activity as provider-executed `tool-call` parts, which opencode's summary + guard rejects. The provider now routes no-tools turns through the existing + `"reasoning"` tool-display path, so Cursor's tool activity surfaces as + reasoning text instead of crossing the tool-execution boundary. Manual + `/compact` was affected all along; **auto**-compaction became reachable only + in 0.7.1-next.0, because #89 published real per-model context windows — + pre-0.7.1 opencode saw `limit.context: 0` for every Cursor model, and a zero + context limit structurally disables the auto-compaction trigger. + ## [0.7.1-next.0] — 2026-08-03 (pre-release) Pre-release of the skills bridge (#90) and per-model context limits + pricing diff --git a/README.md b/README.md index c5c2626..f540d02 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,10 @@ open a PR. - **`"reasoning"`** — compact inline lines (`[tool] write {"path":…}`). Works on any host; use this on older opencode versions. +Turns where the host declares no tools at all — compaction/summary and title generation — always +use `"reasoning"` regardless of this setting. opencode rejects tool parts on a summary turn, so +Cursor's tool activity is folded into reasoning text there instead. + To force the fallback: ```json diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index dee2813..be1d7aa 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -34,6 +34,7 @@ import { import { cursorEventsToContent, cursorEventsToStream, + effectiveToolDisplay, type ToolDisplay, } from "./stream-map.js"; import { resolveControls } from "./controls.js"; @@ -549,9 +550,11 @@ export class CursorLanguageModel implements LanguageModelV3 { ? (po["sessionID"] as string) : undefined; return { - stream: cursorEventsToStream(this.agentRun(options), this.config.toolDisplay, { - sessionID, - }), + stream: cursorEventsToStream( + this.agentRun(options), + effectiveToolDisplay(this.config.toolDisplay, options.tools), + { sessionID }, + ), }; } @@ -563,7 +566,7 @@ export class CursorLanguageModel implements LanguageModelV3 { }> { const result = await cursorEventsToContent( this.agentRun(options), - this.config.toolDisplay, + effectiveToolDisplay(this.config.toolDisplay, options.tools), ); return { ...result, warnings: [] }; } diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 1cbf241..59a847c 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -1,4 +1,5 @@ import type { + LanguageModelV3CallOptions, LanguageModelV3Content, LanguageModelV3FinishReason, LanguageModelV3StreamPart, @@ -55,6 +56,24 @@ function injectSubagentSessionId( */ export type ToolDisplay = "reasoning" | "blocks"; +/** + * A host that declared no tools cannot accept tool parts — opencode's summary + * guard throws on them (`Tool call not allowed while generating summary`). + * Cursor's agent runs its own tools regardless, so fold that activity into + * reasoning text for those turns. Returns the configured mode otherwise. + */ +export function effectiveToolDisplay( + configured: ToolDisplay | undefined, + tools: LanguageModelV3CallOptions["tools"], +): ToolDisplay { + // `Array.isArray` rather than a null check: opencode passes tools as a + // Record at its own layer and the ai-sdk converts it to an array (an empty + // Record short-circuits to `undefined`), so a raw record never reaches us + // today — but this way the guard holds even if that conversion changes. + if (!Array.isArray(tools) || tools.length === 0) return "reasoning"; + return configured ?? "blocks"; +} + const FINISH_STOP: LanguageModelV3FinishReason = { unified: "stop", raw: undefined, diff --git a/test/language-model.test.ts b/test/language-model.test.ts index b4f651e..6101f95 100644 --- a/test/language-model.test.ts +++ b/test/language-model.test.ts @@ -456,4 +456,123 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => { // Original resume failure preserved as the cause for diagnosability. expect((error.cause as Error)?.message).toContain("error"); }); +}); + +// Regression for "Tool call not allowed while generating summary" on turns +// where opencode declares no tools (compaction/summary, title generation). +// The Cursor agent runs its own tools regardless; the provider must fold that +// activity into reasoning text rather than emitting provider-executed +// tool-call parts the host cannot accept. +describe("CursorLanguageModel — no-tools turns fold tool activity into reasoning", () => { + const TOOL_TYPES = [ + "tool-input-start", + "tool-input-delta", + "tool-input-end", + "tool-call", + "tool-result", + ] as const; + + // A Cursor tool-call for an MCP tool (the shape that produced the reported + // `cursor_context-mode_ctx_search` part). + const mcpToolCallUpdate = { + type: "tool-call-started", + callId: "c1", + toolCall: { type: "mcp", args: { toolName: "context-mode_ctx_search", providerIdentifier: "context-mode" } }, + }; + const mcpToolResultUpdate = { + type: "tool-call-completed", + callId: "c1", + toolCall: { type: "mcp", result: { content: [{ type: "text", text: "ok" }] } }, + }; + + it("doStream: a no-tools turn emits no tool parts", async () => { + const model = makeModel(); + create.mockResolvedValueOnce( + fakeAgent({ + agentId: "a1", + updates: [mcpToolCallUpdate, mcpToolResultUpdate, { type: "text-delta", text: "summary" }], + }), + ); + + const parts = await collectStream( + streamCall(model, { + prompt: [user("summarize")], + // tools intentionally omitted — mirrors opencode's compaction turn + providerOptions: { cursor: { sessionID: "s1" } }, + } as never), + ); + + for (const t of TOOL_TYPES) { + expect(eventTypes(parts)).not.toContain(t); + } + + // Absence alone would also hold if the stream emitted nothing, so assert + // the activity was FOLDED INTO reasoning rather than dropped. + const reasoning = parts + .filter( + (p): p is Extract => + p.type === "reasoning-delta", + ) + .map((p) => p.delta) + .join(""); + expect(reasoning).toContain("context-mode_ctx_search"); + // The summary text itself still reaches the host. + const text = parts + .filter( + (p): p is Extract => + p.type === "text-delta", + ) + .map((p) => p.delta) + .join(""); + expect(text).toBe("summary"); + }); + + it("doStream: a turn WITH tools still emits tool-call parts", async () => { + const model = makeModel(); + create.mockResolvedValueOnce( + fakeAgent({ + agentId: "a1", + updates: [mcpToolCallUpdate, mcpToolResultUpdate, { type: "text-delta", text: "done" }], + }), + ); + + const parts = await collectStream( + streamCall(model, { + prompt: [sys("S"), user("hi")], + tools: [{ type: "function", name: "read", inputSchema: {} }], + providerOptions: { cursor: { sessionID: "s1" } }, + } as never), + ); + + // Normal tool blocks are preserved — the suppression is no-tools-only. + expect(eventTypes(parts)).toContain("tool-call"); + }); + + it("doGenerate: a no-tools turn carries no tool-call in content", async () => { + const model = makeModel(); + create.mockResolvedValueOnce( + fakeAgent({ + agentId: "a1", + updates: [mcpToolCallUpdate, mcpToolResultUpdate, { type: "text-delta", text: "summary" }], + }), + ); + + const result = await model.doGenerate({ + prompt: [user("summarize")], + providerOptions: { cursor: { sessionID: "s1" } }, + } as never); + + const toolContent = result.content.filter((c) => c.type === "tool-call"); + expect(toolContent).toHaveLength(0); + + // Folded into reasoning, not dropped. + const reasoning = result.content + .filter( + (c): c is Extract => + c.type === "reasoning", + ) + .map((c) => c.text) + .join(""); + expect(reasoning).toContain("context-mode_ctx_search"); + }); }); \ No newline at end of file diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index aec64f6..dd9aa80 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -4,6 +4,7 @@ import type { CursorEvent } from "../src/provider/agent-events.js"; import { cursorEventsToContent, cursorEventsToStream, + effectiveToolDisplay, mapUsage, } from "../src/provider/stream-map.js"; import { @@ -1696,3 +1697,25 @@ describe("subagent child-session linking (blocks)", () => { expect(foldedMetadata(result)["sessionId"]).toBeUndefined(); }); }); + +describe("effectiveToolDisplay", () => { + it("returns \"reasoning\" when tools are undefined", () => { + expect(effectiveToolDisplay("blocks", undefined)).toBe("reasoning"); + }); + + it("returns \"reasoning\" when tools are an empty array", () => { + expect(effectiveToolDisplay("blocks", [])).toBe("reasoning"); + }); + + it("returns the configured mode when tools are present", () => { + expect(effectiveToolDisplay("blocks", [{ type: "function", name: "read" } as never])).toBe( + "blocks", + ); + }); + + it("defaults to \"blocks\" when configured is undefined and tools are present", () => { + expect( + effectiveToolDisplay(undefined, [{ type: "function", name: "read" } as never]), + ).toBe("blocks"); + }); +});