Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

### Fixed

- Cursor still skips mid-turn compact (#984) but now truncates each `toolResult` text part to 2000 characters at admission and again after compact reloads `sessionContext.messages`, then remints, so a compact that keeps the last tool turn cannot restore megabyte bodies and 0-token `resource_exhausted` on retry (#1043).
- Assistant text that arrives after the last tool call now renders below the tool cards instead of updating the blob above the stack, so approval questions stay visible (#990).
- Late Cursor `tool_execution_end` events now create a TUI tool card when none is pending, so a result is not rendered without a card (#1011).
- Session title generation now uses the session model's summarization auth instead of remapped compaction auth, so an explicit compaction model no longer produces `unauthenticated` Cursor title calls (#980).
Expand Down
51 changes: 49 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,34 @@ const THINKING_LEVELS_WITH_MAX: ThinkingLevel[] = ["off", "minimal", "low", "med
/** Caps explicit skill expansion so one prompt cannot consume unbounded context. */
export const MAX_SKILL_EXPANSIONS_PER_PROMPT = 5;

/** Cursor ingest rejects large verbatim tool payloads; cap each toolResult text part. */
export const CURSOR_TOOL_RESULT_MAX_CHARS = 2000;

export function truncateToolResultBodies(
messages: AgentMessage[] | undefined,
maxChars = CURSOR_TOOL_RESULT_MAX_CHARS,
): { messages: AgentMessage[] | undefined; changed: boolean } {
if (!Array.isArray(messages) || messages.length === 0) {
return { messages, changed: false };
}
let changed = false;
const next = messages.map((msg) => {
if (msg.role !== "toolResult" || !Array.isArray(msg.content)) return msg;
let local = false;
const content = msg.content.map((part) => {
if (part.type === "text" && typeof part.text === "string" && part.text.length > maxChars) {
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
local = true;
return { ...part, text: `${part.text.slice(0, maxChars)}\n...[truncated]` };
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
}
return part;
});
if (!local) return msg;
changed = true;
return { ...msg, content };
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
});
return { messages: changed ? next : messages, changed };
}

// ============================================================================
// AgentSession Class
// ============================================================================
Expand Down Expand Up @@ -1291,9 +1319,12 @@ export class AgentSession {
const compactBeforeNextAdmission = async (): Promise<boolean> => {
const provider = this.model?.provider;
// Cursor rebuilds the full conversation each hop. Compacting here
// mutates rootPrompt mid-run and poisons conversationId.
// mutates rootPrompt mid-run and poisons conversationId (#984).
// Still truncate verbatim toolResult bodies so the skip cannot send MB-scale payloads (#1043).
if (provider === "cursor" || provider === "cursor-cli-oauth") {
return false;
const truncated = this._truncateCursorToolResultBodies();
if (truncated && this.agent) this.agent.allowConversationRotate = true;
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
return truncated;
}
if (turn.toolResults.length === 0 && !this.agent.hasQueuedMessages()) {
return false;
Expand Down Expand Up @@ -5112,6 +5143,7 @@ export class AgentSession {
}
}
this.agent.state.messages = [...sessionContext.messages, ...preservedPendingMessages];
this._reapplyCursorToolTruncateAfterReload();
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
compactionResult.estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
this._incrementMessageRevision();
if (
Expand Down Expand Up @@ -5222,6 +5254,7 @@ export class AgentSession {
pendingMessages.push(message);
}
this.agent.state.messages = [...sessionMessages, ...pendingMessages];
this._reapplyCursorToolTruncateAfterReload();
}

private async _rejectCompaction(
Expand Down Expand Up @@ -6720,6 +6753,20 @@ export class AgentSession {
return isCursorPayloadResourceExhausted(message, 0);
}

private _reapplyCursorToolTruncateAfterReload(): boolean {
const provider = this.model?.provider;
if (provider !== "cursor" && provider !== "cursor-cli-oauth") return false;
const truncated = this._truncateCursorToolResultBodies();
if (this.agent) this.agent.allowConversationRotate = true;
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
return truncated;
}

private _truncateCursorToolResultBodies(maxChars = CURSOR_TOOL_RESULT_MAX_CHARS): boolean {
const { messages: next, changed } = truncateToolResultBodies(this.agent?.state?.messages, maxChars);
if (changed && next) this.agent.state.messages = next;
return changed;
}

private _truncateAgentMessagesToLastUserTurn(): boolean {
const messages = this.agent?.state?.messages;
if (!Array.isArray(messages) || messages.length === 0) return false;
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# changes

## 2026-08-20 - Cursor truncates toolResult bodies across compact reload

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: export `truncateToolResultBodies` (2000 chars). Cursor `compactBeforeNextAdmission` truncates before the #984 skip return. Compact apply and `_restoreAgentMessagesFromSession` re-truncate and remint.

### Why

- Compact cannot cut at `toolResult`. Reloading `buildSessionContext()` restored full jsonl bodies, so the Cursor retry still `resource_exhausted`. In-memory truncation without a post-reload pass is wiped.

### Why an extension could not handle it

- Compact reload of `agent.state.messages` is private AgentSession state. There is no hook after `sessionContext.messages` replaces the in-memory transcript.

### Expected merge conflict zones

- `packages/coding-agent/src/core/agent-session.ts` `compactBeforeNextAdmission`, `_executeCompaction` message replace, `_restoreAgentMessagesFromSession`.

## 2026-08-20 - Session title uses session-model auth

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
CURSOR_TOOL_RESULT_MAX_CHARS,
truncateToolResultBodies,
} from "../../../src/core/agent-session.ts";
import { readFileSync } from "node:fs";
import { join } from "node:path";

function textMessage(role: AgentMessage["role"], text: string): AgentMessage {
return { role, content: [{ type: "text", text }] } as AgentMessage;
}

describe("1043 cursor toolResult truncate", () => {
it("caps long toolResult text and leaves other roles", () => {
const messages = [
textMessage("user", "진행해"),
textMessage("assistant", "ok".repeat(5000)),
textMessage("toolResult", "x".repeat(40_000)),
];
const { messages: next, changed } = truncateToolResultBodies(messages, 2000);
expect(changed).toBe(true);
expect(next?.[0].content).toEqual([{ type: "text", text: "진행해" }]);
expect((next?.[1].content[0] as { text: string }).text.length).toBe(10_000);
const toolText = (next?.[2].content[0] as { text: string }).text;
expect(toolText.startsWith("x".repeat(2000))).toBe(true);
expect(toolText.length).toBeLessThanOrEqual(2000 + 32);
expect(toolText.length).not.toBe(40_000);
});

it("is a no-op when every toolResult is already short", () => {
const messages = [textMessage("toolResult", "ok")];
const { changed } = truncateToolResultBodies(messages, CURSOR_TOOL_RESULT_MAX_CHARS);
expect(changed).toBe(false);
});

it("Cursor admission truncates before the compact skip return", () => {
Comment thread
code-yeongyu marked this conversation as resolved.
Outdated
const src = readFileSync(join(import.meta.dirname, "../../../src/core/agent-session.ts"), "utf8");
const start = src.indexOf("const compactBeforeNextAdmission = async");
expect(start).toBeGreaterThanOrEqual(0);
const slice = src.slice(start, start + 1800);
const truncateAt = slice.indexOf("_truncateCursorToolResultBodies");
const skipAt = slice.indexOf("return truncated");
expect(truncateAt).toBeGreaterThanOrEqual(0);
expect(skipAt).toBeGreaterThan(truncateAt);
});

it("re-truncates after compact reloads sessionContext.messages", () => {
const src = readFileSync(join(import.meta.dirname, "../../../src/core/agent-session.ts"), "utf8");
const assign = "this.agent.state.messages = [...sessionContext.messages, ...preservedPendingMessages];";
const i = src.indexOf(assign);
expect(i).toBeGreaterThanOrEqual(0);
expect(src.slice(i, i + 400)).toContain("_reapplyCursorToolTruncateAfterReload");
});
});
Loading