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
1 change: 1 addition & 0 deletions .acp-019fc0fc-f454-74d3-8d19-f9af6f837aad.acp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"blocks":[],"messageRefs":{"byRaw":{"6e473b99":"m00001","b9495b28":"m00002","773e2a86":"m00003"},"byRef":{"m00001":"6e473b99","m00002":"b9495b28","m00003":"773e2a86"}},"nudge":{"lastPerMessageNudgeTokens":30,"lastNudgeShownTokens":0,"baselineTokens":0,"anchors":{},"lastShownByTier":{}},"stats":{"tokensCompressed":0,"compressionCount":0},"nextBlockId":1,"nextRunId":1}
44 changes: 37 additions & 7 deletions src/render-refs.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { CoreMessage, CompressionState, MessageRefMap } from "./types.js";
import type {
CoreMessage,
CompressionState,
MessageRefMap,
RenderConfig,
} from "./types.js";
import { refForRaw, BLOCKED_REF } from "./refs.js";
import type { PipelineNode, PipelineContext, NodeIO } from "./pipeline.js";

/** Format token count: <1K raw, <10K "X.YK", >=10K "XK". */
export interface RenderOptions {
skipToolMessages?: boolean;
}

function formatTokens(tokens: number): string {
if (tokens < 1000) return String(tokens);
if (tokens < 10000) return (tokens / 1000).toFixed(1) + "K";
Expand Down Expand Up @@ -32,16 +40,23 @@ function acpTag(ref: string, tokens: number, type: string): string {
return TAG_OPEN + 'tokens="' + formatTokens(tokens) + '" type="' + type + '"' + GT + ref + TAG_CLOSE;
}

function renderMessage(
export function renderMessage(
message: CoreMessage,
map: MessageRefMap,
countTokens: (text: string) => number,
options?: RenderOptions,
): CoreMessage {
const ref = refForRaw(map, message.id);
if (!ref || ref === BLOCKED_REF) return message;

// Strip own stale tag BEFORE computing tokens (idempotency).
// Match the message's own ref only — foreign tags survive (content-corruption fix).
if (
options?.skipToolMessages &&
(message.contentType === "tool-call" ||
message.contentType === "tool-result")
) {
return message;
}

const ownTagRe = new RegExp(
"^" + escapeRegex(TAG_OPEN) + "[^>]*" + GT + escapeRegex(ref) + escapeRegex(TAG_CLOSE) + "\\n?",
);
Expand All @@ -60,19 +75,34 @@ export function renderVisibleRefs(
state: CompressionState,
countTokens: (text: string) => number = (text) =>
Math.ceil(text.length / 4),
options?: RenderOptions,
): CoreMessage[] {
const map = state.messageRefs;
return messages.map((message) =>
renderMessage(message, map, countTokens),
renderMessage(message, map, countTokens, options),
);
}

export function optionsFromConfig(
config?: { render?: RenderConfig },
): RenderOptions | undefined {
if (config?.render?.skipToolMessageTags) {
return { skipToolMessages: true };
}
return undefined;
}

export const renderRefsNode: PipelineNode = {
name: "render-refs",
run(io: NodeIO, ctx: PipelineContext): NodeIO {
return {
...io,
messages: renderVisibleRefs(io.messages, io.state, ctx.countTokens),
messages: renderVisibleRefs(
io.messages,
io.state,
ctx.countTokens,
optionsFromConfig(ctx.config),
),
};
},
};
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export interface CompressValidationConfig {
minSummaryLength: number;
}

export interface RenderConfig {
skipToolMessageTags?: boolean;
}

export interface Config {
tiers: TierConfig;
nudge: NudgeConfig;
Expand All @@ -123,6 +127,7 @@ export interface Config {
preserveRecentTokens: number;
modelContextLimit: number;
messageFilters?: import("./filter/types.js").MessageFiltersConfig;
render?: RenderConfig;
}

export type CompressMode = "range" | "message";
Expand Down
68 changes: 68 additions & 0 deletions tests/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,71 @@ test("processTurn tags every mapped message with a derived ref (end-to-end)", ()
assert.match(result.messages[0]!.text!, /^<acp tokens="\d+" type="text">m00001<\/acp>\nalpha$/);
assert.match(result.messages[1]!.text!, /^<acp tokens="\d+" type="text">m00002<\/acp>\nbeta$/);
});

test("renderVisibleRefs tags tool messages by default (backward compat)", () => {
const state = createInitialState();
const messages: CoreMessage[] = [
{ id: "u1", role: "user", contentType: "text", text: "run echo" },
{ id: "a1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: '{"command":"echo hello"}' },
{ id: "t1", role: "tool", contentType: "tool-result", toolName: "bash", toolCallId: "tc1", text: "hello" },
{ id: "a2", role: "assistant", contentType: "text", text: "Done." },
];
state.messageRefs = assignRefs(messages, {
existing: state.messageRefs,
nextIndex: 1,
}).map;

const rendered = renderVisibleRefs(messages, state);

assert.match(rendered[0]!.text!, /m00001<\/acp>/, "user message gets tag");
assert.match(rendered[1]!.text!, /m00002<\/acp>/, "tool-call gets tag (default)");
assert.match(rendered[2]!.text!, /m00003<\/acp>/, "tool-result gets tag (default)");
assert.match(rendered[3]!.text!, /m00004<\/acp>/, "assistant text gets tag");
});

test("renderVisibleRefs skips tool messages when options.skipToolMessages is set", () => {
const state = createInitialState();
const messages: CoreMessage[] = [
{ id: "u1", role: "user", contentType: "text", text: "run echo" },
{ id: "a1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: '{"command":"echo hello"}' },
{ id: "t1", role: "tool", contentType: "tool-result", toolName: "bash", toolCallId: "tc1", text: "hello" },
{ id: "a2", role: "assistant", contentType: "text", text: "Done." },
];
state.messageRefs = assignRefs(messages, {
existing: state.messageRefs,
nextIndex: 1,
}).map;

const rendered = renderVisibleRefs(messages, state, undefined, {
skipToolMessages: true,
});

assert.match(rendered[0]!.text!, /m00001<\/acp>/, "user message gets tag");
assert.equal(rendered[1]!.text, '{"command":"echo hello"}', "tool-call args unmodified");
assert.equal(rendered[2]!.text, "hello", "tool-result content unmodified");
assert.match(rendered[3]!.text!, /m00004<\/acp>/, "assistant text gets tag");
});

test("renderRefsNode respects config.render.skipToolMessageTags", () => {
const messages: CoreMessage[] = [
{ id: "u1", role: "user", contentType: "text", text: "run echo" },
{ id: "a1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: '{"command":"echo hello"}' },
];
const state = createInitialState();
state.messageRefs = assignRefs(messages, {
existing: state.messageRefs,
nextIndex: 1,
}).map;

const configWithSkip = { ...defaultConfig(100000), render: { skipToolMessageTags: true } };
const io = makeIO(messages, state);
const ctx = {
config: configWithSkip,
tokenCount: 100,
countTokens: (t: string) => Math.ceil(t.length / 4),
};
const result = renderRefsNode.run(io, ctx);

assert.match(result.messages[0]!.text!, /m00001<\/acp>/, "user text gets tag");
assert.equal(result.messages[1]!.text, '{"command":"echo hello"}', "tool-call args unmodified with skip flag");
});
Loading