Skip to content

Commit 731ae8d

Browse files
fix(provider): honor the host's tool set so compaction can summarize (#91)
opencode calls the model with `tools: {}` on a compaction/summary turn, and the bundled ai-sdk converts an empty tool record to `options.tools === undefined`. The Cursor agent runs its own tools regardless of what the host declared, and the provider forwarded that activity as provider-executed `tool-call` / `tool-input-start` parts. opencode's SessionProcessor rejects those on a summary turn: case "tool-input-start": case "tool-call": if (assistantMessage.summary) throw Error(`Tool call not allowed while generating summary: ${name}`) so the turn hard-errored and the session could not be compacted at all. A host that declared no tools cannot accept tool parts, so route those turns through the existing `"reasoning"` tool-display path: Cursor's tool activity is folded into reasoning text instead of crossing the tool-execution boundary. Turns that do declare tools are untouched and still render structured blocks. The empty-array case matters as well as `undefined`: ai-sdk's early return only covers a null tool set, so a non-empty `tools` filtered down by `activeTools` arrives as `[]`. Manual `/compact` has been affected all along. Auto-compaction became reachable only in 0.7.1-next.0, because #89 published real per-model context windows — before that opencode resolved `limit.context` to 0 for every Cursor model, and a zero context limit structurally disables the auto-compaction trigger.
1 parent 78bd55d commit 731ae8d

6 files changed

Lines changed: 185 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Fixed: auto-compaction (and manual `/compact`) failed with `Tool call not
8+
allowed while generating summary` whenever the Cursor agent used a tool while
9+
summarizing.** opencode declares zero tools on a compaction/summary turn, but
10+
the Cursor agent runs its own tools regardless; the provider forwarded that
11+
activity as provider-executed `tool-call` parts, which opencode's summary
12+
guard rejects. The provider now routes no-tools turns through the existing
13+
`"reasoning"` tool-display path, so Cursor's tool activity surfaces as
14+
reasoning text instead of crossing the tool-execution boundary. Manual
15+
`/compact` was affected all along; **auto**-compaction became reachable only
16+
in 0.7.1-next.0, because #89 published real per-model context windows —
17+
pre-0.7.1 opencode saw `limit.context: 0` for every Cursor model, and a zero
18+
context limit structurally disables the auto-compaction trigger.
19+
720
## [0.7.1-next.0] — 2026-08-03 (pre-release)
821

922
Pre-release of the skills bridge (#90) and per-model context limits + pricing

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,10 @@ open a PR.
413413
- **`"reasoning"`** — compact inline lines (`[tool] write {"path":…}`). Works on any host; use
414414
this on older opencode versions.
415415

416+
Turns where the host declares no tools at all — compaction/summary and title generation — always
417+
use `"reasoning"` regardless of this setting. opencode rejects tool parts on a summary turn, so
418+
Cursor's tool activity is folded into reasoning text there instead.
419+
416420
To force the fallback:
417421

418422
```json

src/provider/language-model.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import {
3535
cursorEventsToContent,
3636
cursorEventsToStream,
37+
effectiveToolDisplay,
3738
type ToolDisplay,
3839
} from "./stream-map.js";
3940
import { resolveControls } from "./controls.js";
@@ -549,9 +550,11 @@ export class CursorLanguageModel implements LanguageModelV3 {
549550
? (po["sessionID"] as string)
550551
: undefined;
551552
return {
552-
stream: cursorEventsToStream(this.agentRun(options), this.config.toolDisplay, {
553-
sessionID,
554-
}),
553+
stream: cursorEventsToStream(
554+
this.agentRun(options),
555+
effectiveToolDisplay(this.config.toolDisplay, options.tools),
556+
{ sessionID },
557+
),
555558
};
556559
}
557560

@@ -563,7 +566,7 @@ export class CursorLanguageModel implements LanguageModelV3 {
563566
}> {
564567
const result = await cursorEventsToContent(
565568
this.agentRun(options),
566-
this.config.toolDisplay,
569+
effectiveToolDisplay(this.config.toolDisplay, options.tools),
567570
);
568571
return { ...result, warnings: [] };
569572
}

src/provider/stream-map.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
LanguageModelV3CallOptions,
23
LanguageModelV3Content,
34
LanguageModelV3FinishReason,
45
LanguageModelV3StreamPart,
@@ -55,6 +56,24 @@ function injectSubagentSessionId(
5556
*/
5657
export type ToolDisplay = "reasoning" | "blocks";
5758

59+
/**
60+
* A host that declared no tools cannot accept tool parts — opencode's summary
61+
* guard throws on them (`Tool call not allowed while generating summary`).
62+
* Cursor's agent runs its own tools regardless, so fold that activity into
63+
* reasoning text for those turns. Returns the configured mode otherwise.
64+
*/
65+
export function effectiveToolDisplay(
66+
configured: ToolDisplay | undefined,
67+
tools: LanguageModelV3CallOptions["tools"],
68+
): ToolDisplay {
69+
// `Array.isArray` rather than a null check: opencode passes tools as a
70+
// Record at its own layer and the ai-sdk converts it to an array (an empty
71+
// Record short-circuits to `undefined`), so a raw record never reaches us
72+
// today — but this way the guard holds even if that conversion changes.
73+
if (!Array.isArray(tools) || tools.length === 0) return "reasoning";
74+
return configured ?? "blocks";
75+
}
76+
5877
const FINISH_STOP: LanguageModelV3FinishReason = {
5978
unified: "stop",
6079
raw: undefined,

test/language-model.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,4 +456,123 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => {
456456
// Original resume failure preserved as the cause for diagnosability.
457457
expect((error.cause as Error)?.message).toContain("error");
458458
});
459+
});
460+
461+
// Regression for "Tool call not allowed while generating summary" on turns
462+
// where opencode declares no tools (compaction/summary, title generation).
463+
// The Cursor agent runs its own tools regardless; the provider must fold that
464+
// activity into reasoning text rather than emitting provider-executed
465+
// tool-call parts the host cannot accept.
466+
describe("CursorLanguageModel — no-tools turns fold tool activity into reasoning", () => {
467+
const TOOL_TYPES = [
468+
"tool-input-start",
469+
"tool-input-delta",
470+
"tool-input-end",
471+
"tool-call",
472+
"tool-result",
473+
] as const;
474+
475+
// A Cursor tool-call for an MCP tool (the shape that produced the reported
476+
// `cursor_context-mode_ctx_search` part).
477+
const mcpToolCallUpdate = {
478+
type: "tool-call-started",
479+
callId: "c1",
480+
toolCall: { type: "mcp", args: { toolName: "context-mode_ctx_search", providerIdentifier: "context-mode" } },
481+
};
482+
const mcpToolResultUpdate = {
483+
type: "tool-call-completed",
484+
callId: "c1",
485+
toolCall: { type: "mcp", result: { content: [{ type: "text", text: "ok" }] } },
486+
};
487+
488+
it("doStream: a no-tools turn emits no tool parts", async () => {
489+
const model = makeModel();
490+
create.mockResolvedValueOnce(
491+
fakeAgent({
492+
agentId: "a1",
493+
updates: [mcpToolCallUpdate, mcpToolResultUpdate, { type: "text-delta", text: "summary" }],
494+
}),
495+
);
496+
497+
const parts = await collectStream(
498+
streamCall(model, {
499+
prompt: [user("summarize")],
500+
// tools intentionally omitted — mirrors opencode's compaction turn
501+
providerOptions: { cursor: { sessionID: "s1" } },
502+
} as never),
503+
);
504+
505+
for (const t of TOOL_TYPES) {
506+
expect(eventTypes(parts)).not.toContain(t);
507+
}
508+
509+
// Absence alone would also hold if the stream emitted nothing, so assert
510+
// the activity was FOLDED INTO reasoning rather than dropped.
511+
const reasoning = parts
512+
.filter(
513+
(p): p is Extract<LanguageModelV3StreamPart, { type: "reasoning-delta" }> =>
514+
p.type === "reasoning-delta",
515+
)
516+
.map((p) => p.delta)
517+
.join("");
518+
expect(reasoning).toContain("context-mode_ctx_search");
519+
// The summary text itself still reaches the host.
520+
const text = parts
521+
.filter(
522+
(p): p is Extract<LanguageModelV3StreamPart, { type: "text-delta" }> =>
523+
p.type === "text-delta",
524+
)
525+
.map((p) => p.delta)
526+
.join("");
527+
expect(text).toBe("summary");
528+
});
529+
530+
it("doStream: a turn WITH tools still emits tool-call parts", async () => {
531+
const model = makeModel();
532+
create.mockResolvedValueOnce(
533+
fakeAgent({
534+
agentId: "a1",
535+
updates: [mcpToolCallUpdate, mcpToolResultUpdate, { type: "text-delta", text: "done" }],
536+
}),
537+
);
538+
539+
const parts = await collectStream(
540+
streamCall(model, {
541+
prompt: [sys("S"), user("hi")],
542+
tools: [{ type: "function", name: "read", inputSchema: {} }],
543+
providerOptions: { cursor: { sessionID: "s1" } },
544+
} as never),
545+
);
546+
547+
// Normal tool blocks are preserved — the suppression is no-tools-only.
548+
expect(eventTypes(parts)).toContain("tool-call");
549+
});
550+
551+
it("doGenerate: a no-tools turn carries no tool-call in content", async () => {
552+
const model = makeModel();
553+
create.mockResolvedValueOnce(
554+
fakeAgent({
555+
agentId: "a1",
556+
updates: [mcpToolCallUpdate, mcpToolResultUpdate, { type: "text-delta", text: "summary" }],
557+
}),
558+
);
559+
560+
const result = await model.doGenerate({
561+
prompt: [user("summarize")],
562+
providerOptions: { cursor: { sessionID: "s1" } },
563+
} as never);
564+
565+
const toolContent = result.content.filter((c) => c.type === "tool-call");
566+
expect(toolContent).toHaveLength(0);
567+
568+
// Folded into reasoning, not dropped.
569+
const reasoning = result.content
570+
.filter(
571+
(c): c is Extract<typeof c, { type: "reasoning" }> =>
572+
c.type === "reasoning",
573+
)
574+
.map((c) => c.text)
575+
.join("");
576+
expect(reasoning).toContain("context-mode_ctx_search");
577+
});
459578
});

test/stream-map.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { CursorEvent } from "../src/provider/agent-events.js";
44
import {
55
cursorEventsToContent,
66
cursorEventsToStream,
7+
effectiveToolDisplay,
78
mapUsage,
89
} from "../src/provider/stream-map.js";
910
import {
@@ -1696,3 +1697,25 @@ describe("subagent child-session linking (blocks)", () => {
16961697
expect(foldedMetadata(result)["sessionId"]).toBeUndefined();
16971698
});
16981699
});
1700+
1701+
describe("effectiveToolDisplay", () => {
1702+
it("returns \"reasoning\" when tools are undefined", () => {
1703+
expect(effectiveToolDisplay("blocks", undefined)).toBe("reasoning");
1704+
});
1705+
1706+
it("returns \"reasoning\" when tools are an empty array", () => {
1707+
expect(effectiveToolDisplay("blocks", [])).toBe("reasoning");
1708+
});
1709+
1710+
it("returns the configured mode when tools are present", () => {
1711+
expect(effectiveToolDisplay("blocks", [{ type: "function", name: "read" } as never])).toBe(
1712+
"blocks",
1713+
);
1714+
});
1715+
1716+
it("defaults to \"blocks\" when configured is undefined and tools are present", () => {
1717+
expect(
1718+
effectiveToolDisplay(undefined, [{ type: "function", name: "read" } as never]),
1719+
).toBe("blocks");
1720+
});
1721+
});

0 commit comments

Comments
 (0)