Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
11 changes: 11 additions & 0 deletions packages/ai/src/api/cursor-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4133,6 +4133,17 @@ function buildConversationTurns(
return turns;
}

/** Exported for tests: returns the encoded Cursor history blob payloads. */
export function buildCursorHistoryWireBytesForTest(
messages: Message[],
activeUserMessageIndex = findLastUserMessageIndex(messages),
): Uint8Array[] {
const blobStore = new Map<string, Uint8Array>();
buildRootPromptMessagesJson(messages, [], blobStore, activeUserMessageIndex);
buildConversationTurns(messages, blobStore, activeUserMessageIndex);
return [...blobStore.values()];
}

/** Exported for tests: decodes Cursor history blobs built from conversation messages. */
export function buildCursorHistoryForTest(
messages: Message[],
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

### Fixed

- Cursor tool-result request views now truncate text and image payloads without mutating shared agent state or persisted session history; aggregate eviction preserves newest results, accounts conservatively for escaped/enveloped serialization, never amplifies output with markers, and remains grapheme-safe (#1043).

- `cursor-cli-oauth` no longer mixes Cursor's internal tool-call protocol, arguments, and results into assistant text; Cursor still owns execution, while Senpi now stores and renders only the model's actual prose ([OmO #7169](https://github.com/code-yeongyu/oh-my-openagent/issues/7169)).

- On the `claude-sdk-oauth` lane, the "Compaction rejected: the Claude Agent SDK owns compaction for
Expand Down
112 changes: 110 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,99 @@ 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. The bound is UTF-8 bytes. */
export const CURSOR_TOOL_RESULT_MAX_CHARS = 2000;
export const CURSOR_TOOL_RESULT_MAX_BYTES = 50_000;
const CURSOR_TRUNCATION_MARKER = "\n...[truncated]";
// Cursor emits each history item in duplicated JSON and protobuf envelopes. Sixteen
// bounds worst-case JSON escaping (control characters) plus framing and duplication.
const CURSOR_SERIALIZED_BYTES_PER_RAW_BYTE = 16;
const CURSOR_CONTENT_ITEM_OVERHEAD_BYTES = 64;

export function truncateToolResultBodies(
messages: AgentMessage[] | undefined,
maxChars = CURSOR_TOOL_RESULT_MAX_CHARS,
maxBytes = CURSOR_TOOL_RESULT_MAX_BYTES,
): { messages: AgentMessage[] | undefined; changed: boolean } {
if (!Array.isArray(messages) || messages.length === 0) return { messages, changed: false };
const encoder = new TextEncoder();
const byteLength = (text: string): number => encoder.encode(text).byteLength;
const serializedCost = (text: string): number =>
byteLength(text) * CURSOR_SERIALIZED_BYTES_PER_RAW_BYTE + CURSOR_CONTENT_ITEM_OVERHEAD_BYTES;
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const markerChars = [...segmenter.segment(CURSOR_TRUNCATION_MARKER)].length;
let usedBytes = 0;
let changed = false;
const result = messages.slice();

// Walk newest-to-oldest: the next continuation needs the most recent results.
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
const message: AgentMessage = messages[messageIndex];
if (message.role !== "toolResult" || !Array.isArray(message.content)) continue;
let nextContent: typeof message.content | undefined;
const emptiedTextIndexes = new Set<number>();
for (let partIndex = message.content.length - 1; partIndex >= 0; partIndex--) {
const part = message.content[partIndex];
if (part.type === "image" && typeof part.data === "string") {
if (usedBytes + serializedCost(part.data) <= maxBytes) {
usedBytes += serializedCost(part.data);
} else {
nextContent ??= message.content.slice();
nextContent[partIndex] = { ...part, data: "" };
changed = true;
}
continue;
}
if (part.type !== "text" || typeof part.text !== "string") continue;
const text = part.text;
const graphemes = [...segmenter.segment(text)].map((segment) => segment.segment);
const fullCost = serializedCost(text);
if (graphemes.length <= maxChars && usedBytes + fullCost <= maxBytes) {
usedBytes += fullCost;
continue;
}

const markerCost = serializedCost(CURSOR_TRUNCATION_MARKER);
const markerFits = usedBytes + markerCost <= maxBytes;
const availableBytes = markerFits
? Math.floor(
(maxBytes - usedBytes - markerCost - CURSOR_CONTENT_ITEM_OVERHEAD_BYTES) /
CURSOR_SERIALIZED_BYTES_PER_RAW_BYTE,
)
: 0;
let kept = "";
if (markerFits) {
for (const grapheme of graphemes.slice(0, Math.max(0, maxChars - markerChars))) {
if (byteLength(kept + grapheme) > availableBytes) break;
kept += grapheme;
}
}
const nextText = markerFits ? kept + CURSOR_TRUNCATION_MARKER : "";
nextContent ??= message.content.slice();
nextContent[partIndex] = { ...part, text: nextText };
if (!markerFits) emptiedTextIndexes.add(partIndex);
usedBytes += markerFits ? serializedCost(nextText) : serializedCost("");
changed = true;
}
// Empty text items still carry a Cursor content-item envelope. Keep one
// placeholder for each consecutive run so the tool result remains paired
// without allowing rejected items to consume unbounded wire space.
if (nextContent && emptiedTextIndexes.size > 1) {
const compactedContent: Array<TextContent | ImageContent> = [];
let previousWasEmptied = false;
for (const [partIndex, part] of nextContent.entries()) {
const isEmptied = emptiedTextIndexes.has(partIndex);
if (isEmptied && previousWasEmptied) continue;
compactedContent.push(part);
previousWasEmptied = isEmptied;
}
nextContent = compactedContent;
}
if (nextContent) result[messageIndex] = { ...message, content: nextContent };
}
return { messages: changed ? result : messages, changed };
}

// ============================================================================
// AgentSession Class
// ============================================================================
Expand Down Expand Up @@ -1323,6 +1416,15 @@ export class AgentSession {
(this.agent.prepareNextTurn
? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal)
: undefined);
const previousTransformContext = this.agent.transformContext;
this.agent.transformContext = async (messages, signal) => {
const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
if (this.model?.provider === "cursor" || this.model?.provider === "cursor-cli-oauth") {
return truncateToolResultBodies(transformed).messages ?? transformed;
}
return transformed;
};

this.agent.prepareNextTurnWithContext = async (turn, signal) => {
// Enforce compaction only when this prepare precedes an actual provider
// admission: a tool continuation or queued steer/follow-up messages. A
Expand All @@ -1331,7 +1433,8 @@ 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;
}
Expand Down Expand Up @@ -5404,10 +5507,15 @@ export class AgentSession {
fromHook: fromExtension,
};

const simulatedMessages = buildSessionContext(
let simulatedMessages = buildSessionContext(
[...pathEntries, simulatedCompactionEntry],
simulatedCompactionEntry.id,
).messages;
// Size the same retained context that will be admitted to Cursor. Persisted JSONL
// remains verbatim, but the in-memory request representation is bounded first.
if (model.provider === "cursor" || model.provider === "cursor-cli-oauth") {
simulatedMessages = truncateToolResultBodies(simulatedMessages).messages ?? simulatedMessages;
}
const contextTokens = estimateMessagesTokens(filterContextExcludedMessages(simulatedMessages));
const settings = this.settingsManager.getCompactionSettings();
return contextTokens > model.contextWindow - settings.reserveTokens;
Expand Down
31 changes: 31 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
# changes

## 2026-08-29 - Bound Cursor serialized tool-result admissions

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: Cursor tool-result request views now use a conservative escaped/enveloped serialization bound and never amplify output with markers.

### Why

- Cursor duplicates history across JSON and protobuf envelopes, and JSON escaping makes raw-byte heuristics unsafe.

### Why an extension could not handle it

- AgentSession owns provider admission and the request-only transform boundary.

### Expected merge conflict zones

- `packages/coding-agent/src/core/agent-session.ts`

## 2026-08-29 - Make externally owned compaction delegation sticky

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: Cursor tool-result truncation is immutable and request-scoped, bounds worst-case escaped/enveloped serialized payload cost, preserves newest results under the aggregate bound, and omits the marker when its serialized cost cannot fit.

### Why

- Cursor serializes tool-result history into duplicated JSON and protobuf envelopes; the request view must be bounded before provider admission.

### Why an extension could not handle it

- AgentSession owns provider admission and the request-only transform boundary.

### Expected merge conflict zones

- `packages/coding-agent/src/core/agent-session.ts`
- `packages/coding-agent/src/core/agent-session.ts`: remember the provider and model id after an automatic compaction is rejected by an external owner, suppressing repeated automatic attempts until that key changes, compaction is accepted, or runtime ownership is reconfigured by reload/registry refresh. Manual compaction remains admitted.
- Added `test/suite/regressions/1174-sticky-delegated-compaction.test.ts` covering repeated turns, manual compaction, and model changes.

Expand Down
Loading