Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import {
cursorEventsToContent,
cursorEventsToStream,
effectiveToolDisplay,
type ToolDisplay,
} from "./stream-map.js";
import { resolveControls } from "./controls.js";
Expand Down Expand Up @@ -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 },
),
};
}

Expand All @@ -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: [] };
}
Expand Down
19 changes: 19 additions & 0 deletions src/provider/stream-map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
LanguageModelV3CallOptions,
LanguageModelV3Content,
LanguageModelV3FinishReason,
LanguageModelV3StreamPart,
Expand Down Expand Up @@ -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,
Expand Down
119 changes: 119 additions & 0 deletions test/language-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LanguageModelV3StreamPart, { type: "reasoning-delta" }> =>
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<LanguageModelV3StreamPart, { type: "text-delta" }> =>
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<typeof c, { type: "reasoning" }> =>
c.type === "reasoning",
)
.map((c) => c.text)
.join("");
expect(reasoning).toContain("context-mode_ctx_search");
});
});
23 changes: 23 additions & 0 deletions test/stream-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
});
});