Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 0 additions & 1 deletion .omo/init-deep.json

This file was deleted.

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 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
107 changes: 106 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,96 @@ 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]";

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 markerBytes = byteLength(CURSOR_TRUNCATION_MARKER);
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const markerChars = [...segmenter.segment(CURSOR_TRUNCATION_MARKER)].length;
const textPartCount = messages.reduce(
(count, message) =>
count +
(message.role === "toolResult" && Array.isArray(message.content)
? message.content.filter((part) => part.type === "text" && typeof part.text === "string").length
: 0),
0,
);
const totalBodyBytes = messages.reduce(
(total, message) =>
total +
(message.role === "toolResult" && Array.isArray(message.content)
? message.content.reduce(
(sum, part) =>
sum +
(part.type === "text"
? byteLength(part.text)
: part.type === "image" && typeof part.data === "string"
? byteLength(part.data)
: 0),
0,
)
: 0),
0,
);
const effectiveMaxBytes = totalBodyBytes > maxBytes ? Math.max(0, maxBytes - textPartCount * markerBytes) : maxBytes;
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 = messages[messageIndex];
if (message.role !== "toolResult" || !Array.isArray(message.content)) continue;
let nextContent: typeof message.content | undefined;
for (let partIndex = message.content.length - 1; partIndex >= 0; partIndex--) {
const part = message.content[partIndex];
if (part.type === "image" && typeof part.data === "string") {
const fullBytes = byteLength(part.data);
if (usedBytes + fullBytes <= effectiveMaxBytes) {
usedBytes += fullBytes;
} 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 fullBytes = byteLength(text);
if (graphemes.length <= maxChars && usedBytes + fullBytes <= maxBytes) {
usedBytes += fullBytes;
continue;
}

const availableBytes = Math.max(0, effectiveMaxBytes - usedBytes - markerBytes);
let kept = "";
for (const grapheme of graphemes.slice(0, Math.max(0, maxChars - markerChars))) {
if (byteLength(kept + grapheme) > availableBytes) break;
kept += grapheme;
}
const nextText = kept + CURSOR_TRUNCATION_MARKER;
nextContent ??= message.content.slice();
nextContent[partIndex] = { ...part, text: nextText };
usedBytes += byteLength(nextText);
changed = true;
}
if (nextContent) result[messageIndex] = { ...message, content: nextContent };
}
return { messages: changed ? result : messages, changed };
}

// ============================================================================
// AgentSession Class
// ============================================================================
Expand Down Expand Up @@ -1323,6 +1413,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 +1430,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 @@ -5408,6 +5508,11 @@ export class AgentSession {
[...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") {
truncateToolResultBodies(simulatedMessages);
}
const contextTokens = estimateMessagesTokens(filterContextExcludedMessages(simulatedMessages));
const settings = this.settingsManager.getCompactionSettings();
return contextTokens > model.contextWindow - settings.reserveTokens;
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: Cursor tool-result truncation is immutable and request-scoped, includes image payload bytes, preserves newest results under the aggregate bound, and uses grapheme-safe marker-inclusive cuts.
- `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
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { describe, expect, it } from "vitest";
import { CURSOR_TOOL_RESULT_MAX_CHARS, truncateToolResultBodies } from "../../../src/core/agent-session.ts";

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

function messageText(message: AgentMessage): string {
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) throw new Error("expected content message");
const part = content[0] as { type?: string; text?: string } | undefined;
if (part?.type !== "text" || typeof part.text !== "string") throw new Error("expected text part");
return part.text;
}

describe("1043 cursor toolResult truncate", () => {
it("caps long toolResult text at a code-point-safe, marker-inclusive boundary", () => {
const messages = [
textMessage("user", "진행해"),
textMessage("assistant", "ok".repeat(5000)),
textMessage("toolResult", `${"a".repeat(1998)}😀tail`),
];
const original = messages[2];
const { messages: next, changed } = truncateToolResultBodies(messages, 2000);
expect(changed).toBe(true);
if (!next) throw new Error("expected messages");
expect(messageText(next[1]).length).toBe(10_000);
expect(next[2]).not.toBe(original);
expect(messageText(messages[2])).toBe(`${"a".repeat(1998)}😀tail`);
const toolText = messageText(next[2]);
expect(toolText).toMatch(/^a+\n\.\.\.\[truncated\]$/);
expect(toolText.length).toBeLessThanOrEqual(2000);
expect([...toolText].length).toBeLessThanOrEqual(2000);
expect(toolText).not.toContain("\ud800");
});

it("bounds the aggregate UTF-8 payload across all tool results", () => {
const messages = Array.from({ length: 100 }, (_, index) =>
textMessage("toolResult", `${index}:${"가".repeat(2000)}`),
);
const { messages: next, changed } = truncateToolResultBodies(messages);
expect(changed).toBe(true);
if (!next) throw new Error("expected messages");
expect(messageText(next[99])).toMatch(/^99:가+\n\.\.\.\[truncated\]$/);
expect(messageText(next[0])).toContain("...[truncated]");
const bytes = new TextEncoder().encode(next.map(messageText).join("")).byteLength;
expect(bytes).toBeLessThanOrEqual(50_000);
});

it("keeps grapheme clusters intact and retains a marker when only marker space remains", () => {
const messages = [textMessage("toolResult", "👩‍💻e\u0301".repeat(10))];
const { messages: next } = truncateToolResultBodies(messages, 4, 20);
const text = next ? messageText(next[0]) : "";
expect(text).toContain("...[truncated]");
expect(text).not.toMatch(/👩(?:$|[^‍])/u);
expect(text).not.toMatch(/e$/u);
});

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);
});
});