Skip to content
Closed
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
23 changes: 23 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@
* window and would suppress every nudge. Maps to kernel
* nudge.minPressureBenefitTokens. */
minPressureBenefitTokens?: number;
/** Reasoning-block replay policy (kernel reasoningReplay), available at all
* three levels (global → provider → model). Default: "open-round" —
* history thinking is stripped from the outgoing view once its round
* closes; providers only require replaying thinking for the current
* unresolved round. GPT-family providers/models default to "always"
* (legacy keep-everything — OpenAI reasoning items are encrypted and
* must round-trip unmodified, untestable from here). Set "always"/"never"
* at any level to override either default. */
reasoningReplay?: "always" | "open-round" | "never";
}

/** Per-provider compression overrides. Carries the same tuning fields as the
Expand Down Expand Up @@ -358,6 +367,7 @@
emergencyThresholdPercent: model?.emergencyThresholdPercent ?? provider?.emergencyThresholdPercent ?? global?.emergencyThresholdPercent,
nudgeGrowthTokens: model?.nudgeGrowthTokens ?? provider?.nudgeGrowthTokens ?? global?.nudgeGrowthTokens,
minPressureBenefitTokens: model?.minPressureBenefitTokens ?? provider?.minPressureBenefitTokens ?? global?.minPressureBenefitTokens,
reasoningReplay: model?.reasoningReplay ?? provider?.reasoningReplay ?? global?.reasoningReplay,
};
}

Expand Down Expand Up @@ -394,13 +404,15 @@
preserveRecentMessages: adapter.preserveRecentMessages ?? 5,
...adapter.coreOverrides,
});
config.reasoningReplay = adapter.coreOverrides?.reasoningReplay ?? defaultReasoningReplay(provider, modelId);
const c = resolveCompress(adapter.compress, provider, modelId);
if (c.reasoningReplay !== undefined) config.reasoningReplay = c.reasoningReplay;
if (c.maxContextLimit !== undefined) config.nudge.maxContextLimitPct = parsePercent(c.maxContextLimit);
if (c.emergencyThresholdPercent !== undefined) {
const pct = parsePercent(c.emergencyThresholdPercent);
config.nudge.emergencyThresholdPct = pct;

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / e2e (ubuntu-latest)

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / e2e (ubuntu-latest)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / e2e (windows-latest)

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / e2e (windows-latest)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 24)

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 24)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / build-artifact

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / build-artifact

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 22)

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 22)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 24)

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 24)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 22)

Property 'reasoningReplay' does not exist on type 'Partial<Config>'.

Check failure on line 413 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 22)

Property 'reasoningReplay' does not exist on type 'Config'.
config.truncate.threshold = pct;
}

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / e2e (ubuntu-latest)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / e2e (windows-latest)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 24)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / build-artifact

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 22)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 24)

Property 'reasoningReplay' does not exist on type 'Config'.

Check failure on line 415 in src/config.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 22)

Property 'reasoningReplay' does not exist on type 'Config'.
if (c.nudgeGrowthTokens !== undefined) {
config.nudge.growthFloor = c.nudgeGrowthTokens;
config.nudge.growthCap = c.nudgeGrowthTokens;
Expand All @@ -417,3 +429,14 @@
if (s.endsWith("%")) return Number(s.slice(0, -1)) / 100;
return Number(s);
}

// OpenAI reasoning models hand back opaque encrypted reasoning items that
// must be echoed back unmodified (Responses API), and we cannot test that
// path from here — GPT-family providers/models default to the legacy
// keep-everything behavior. Explicit reasoningReplay config always wins.
const GPT_FAMILY = /(^|[^a-z0-9])(gpt-|o[134](-|\d|\/|$)|codex|openai)/i;

export function defaultReasoningReplay(provider?: string, modelId?: string): "always" | "open-round" {
const hay = `${provider ?? ""} ${modelId ?? ""}`;
return GPT_FAMILY.test(hay) ? "always" : "open-round";
}
96 changes: 81 additions & 15 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,28 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] {
}];
}
if (role === "assistant") {
// Thinking parts project as `reasoning` cores with deterministic `#r<n>`
// sub-ids so the kernel can see (and, per reasoningReplay, strip) them;
// the rebuild filters the original thinking blocks by sub-id survival.
const thinkingTexts = thinkingBlockTexts(msg.content);
const splitIds = thinkingTexts.length > 0;
const out: CoreMessage[] = [];
thinkingTexts.forEach((text, i) => {
if (text.trim().length > 0) {
out.push({ id: `${id}#r${i}`, role: "assistant", contentType: "reasoning", text });
}
});
const calls = allToolCalls(msg.content);
if (calls.length > 0) {
const textParts = extractText(msg.content);
if (calls.length === 1) {
const call = calls[0]!;
const argStr = stringifyArgs(call.arguments);
const text = argStr && textParts ? `${textParts}\n${argStr}` : argStr || textParts;
return [{ id, role: "assistant", contentType: "tool-call", toolName: call.name, toolCallId: call.id, text }];
out.push({ id: splitIds ? `${id}#${call.id}` : id, role: "assistant", contentType: "tool-call", toolName: call.name, toolCallId: call.id, text });
return out;
}
return calls.map((call) => {
out.push(...calls.map((call) => {
const argStr = stringifyArgs(call.arguments);
return {
id: `${id}#${call.id}`,
Expand All @@ -78,20 +90,35 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] {
toolCallId: call.id,
text: argStr || textParts,
};
});
}));
return out;
}
const text = extractText(msg.content);
// Drop thinking-only turns: empty assistant text makes OpenAI-compatible
// providers (e.g. GLM) return 400 (no body), which Pi misreads as overflow.
if (!text.trim()) return [];
return [{ id, role: "assistant", contentType: "text", text }];
out.push({ id: splitIds ? `${id}#t0` : id, role: "assistant", contentType: "text", text });
return out;
}
const customText = extractText(msg.content) || fallbackText(msg);
return customText.length > 0
? [{ id, role: "user", contentType: "text", text: customText }]
: [];
}

function thinkingBlockTexts(content: unknown): string[] {
if (!Array.isArray(content)) return [];
return content
.filter((block): block is Record<string, unknown> => {
if (!block || typeof block !== "object") return false;
return (block as { type?: string }).type === "thinking";
})
.map((block) => {
const text = block.thinking ?? block.text;
return typeof text === "string" ? text : "";
});
}

function fallbackText(msg: AnyMessage): string {
const parts: string[] = [];
if (msg.command) parts.push(`$ ${msg.command}`);
Expand Down Expand Up @@ -245,8 +272,18 @@ export function coreOutToAgentMessages(
.map((c) => c.toolCallId)
.filter((id): id is string => !!id),
);
const survivingSubIds = new Set(
coreOut
.filter((c) => c.id.startsWith(`${baseId}#`) && !c.id.startsWith("acp_summary_"))
.map((c) => c.id),
);
const coreTextByCallId = new Map(
coreOut
.filter((c) => c.id.startsWith(`${baseId}#`) && c.toolCallId)
.map((c) => [c.toolCallId as string, c.text]),
);

out.push(reconstructToolCallMessage(original, core, survivingCallIds));
out.push(reconstructToolCallMessage(original, core, survivingCallIds, baseId, survivingSubIds, coreTextByCallId));
}

return out;
Expand All @@ -256,22 +293,55 @@ function reconstructToolCallMessage(
original: AgentMessage,
firstCore: CoreMessage,
survivingCallIds: Set<string>,
baseId?: string,
survivingSubIds?: Set<string>,
coreTextByCallId?: Map<string, string | undefined>,
): AgentMessage {
const base = original as AnyMessage;
const match = firstCore.text ? firstCore.text.match(REF_TAG) : null;
const tag = match ? match[0] : null;

const filterBlocks = (blocks: unknown[]): unknown[] => {
let reasoningIndex = 0;
return blocks.filter((block) => {
const b = block as { type?: string; id?: string };
if (b.type === "toolCall") return survivingCallIds.has(b.id ?? "");
if (b.type === "thinking") {
const subId = baseId !== undefined && survivingSubIds !== undefined
? `${baseId}#r${reasoningIndex}`
: null;
reasoningIndex++;
if (subId === null) return true;
return survivingSubIds!.has(subId);
}
return true;
}).map((block) => {
// The kernel may have rewritten a compress call's text (live-range
// filter, summary stubs). Sync it back so the rewritten form — not the
// original full-args JSON — is what the provider receives.
const b = block as { type?: string; id?: string; arguments?: unknown };
if (b.type !== "toolCall" || !coreTextByCallId) return block;
const rewritten = coreTextByCallId.get(b.id ?? "");
if (rewritten === undefined) return block;
const argStr = stringifyArgs(b.arguments);
if (rewritten === argStr || !argStr) return block;
const start = rewritten.indexOf("{");
if (start < 0) return block;
try {
return { ...b, arguments: JSON.parse(rewritten.slice(start)) };
} catch {
return block;
}
});
};

if (base.role === "assistant" || !tag) {
const rawBlocks2: unknown[] = Array.isArray(base.content)
? base.content
: typeof base.content === "string"
? [{ type: "text", text: base.content }]
: [];
const filtered2 = rawBlocks2.filter((block) => {
const b = block as { type?: string; id?: string };
if (b.type === "toolCall") return survivingCallIds.has(b.id ?? "");
return true;
});
const filtered2 = filterBlocks(rawBlocks2);
const peeled2 = peelRefTagBlocks(filtered2);
return { ...(original as object), content: peeled2 } as AgentMessage;
}
Expand All @@ -282,11 +352,7 @@ function reconstructToolCallMessage(
? [{ type: "text", text: base.content }]
: [];

const filtered = rawBlocks.filter((block) => {
const b = block as { type?: string; id?: string };
if (b.type === "toolCall") return survivingCallIds.has(b.id ?? "");
return true;
});
const filtered = filterBlocks(rawBlocks);

const peeled = peelRefTagBlocks(filtered);
const stableTag = rewriteTagTokens(tag, coreBodyOf(firstCore.text ?? "", tag));
Expand Down
137 changes: 137 additions & 0 deletions tests/messages-reasoning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createCore, createInitialState, defaultConfig } from "acp-kernel";
import { entriesToCoreMessages, coreOutToAgentMessages } from "../src/messages.ts";
import { resolveConfig } from "../src/config.ts";
import type { AgentMessage, SessionEntry } from "../src/types.ts";

const countTokens = (text: string) => Math.ceil(text.length / 4);

function msgEntry(id: string, message: object): SessionEntry {
return {
type: "message",
id,
parentId: null,
timestamp: new Date().toISOString(),
message: message as AgentMessage,
} as SessionEntry;
}

function userEntry(id: string, text: string): SessionEntry {
return msgEntry(id, { role: "user", content: [{ type: "text", text }], timestamp: Date.now() });
}

function thinkingAssistantEntry(id: string, thinking: string, text: string): SessionEntry {
return msgEntry(id, {
role: "assistant",
content: [
{ type: "thinking", thinking },
{ type: "text", text },
],
timestamp: Date.now(),
});
}

function rebuiltView(entries: SessionEntry[], reasoningReplay: "always" | "open-round" | "never") {
const core = createCore({ countTokens });
const config = { ...defaultConfig(262144, { limit: 212992 }), reasoningReplay };
const turn = core.processTurn({
messages: entriesToCoreMessages(entries),
state: createInitialState(),
config,
tokenCount: 1000,
});
const byId = new Map(entries.map((e) => [e.id, (e as { message: AgentMessage }).message]));
return coreOutToAgentMessages(turn.messages, byId);
}

function thinkingBlocksOf(message: AgentMessage | undefined): string[] {
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) return [];
return content
.filter((b): b is { type: string; thinking?: string } => (b as { type?: string }).type === "thinking")
.map((b) => b.thinking ?? "");
}

test("open-round strips closed-turn thinking but keeps the open round's", () => {
const entries = [
userEntry("u1", "first question"),
thinkingAssistantEntry("a1", "stale reasoning about question one", "answer one"),
userEntry("u2", "second question"),
thinkingAssistantEntry("a2", "fresh reasoning about question two", "answer two"),
];
const view = rebuiltView(entries, "open-round");
const a1 = view.find((m) => (m as { id?: string }).id === undefined && JSON.stringify((m as { content?: unknown }).content).includes("answer one"));
const a2 = view.find((m) => JSON.stringify((m as { content?: unknown }).content).includes("answer two"));
assert.deepEqual(thinkingBlocksOf(a1), [], "closed-turn thinking stripped from rebuilt view");
assert.deepEqual(thinkingBlocksOf(a2), ["fresh reasoning about question two"], "open-round thinking kept");
});

test("always keeps every thinking block in the rebuilt view", () => {
const entries = [
userEntry("u1", "q"),
thinkingAssistantEntry("a1", "keep me", "answer"),
];
const view = rebuiltView(entries, "always");
assert.deepEqual(thinkingBlocksOf(view[1]), ["keep me"]);
});

test("never strips thinking from the open round too", () => {
const entries = [
userEntry("u1", "q"),
thinkingAssistantEntry("a1", "drop me", "answer"),
];
const view = rebuiltView(entries, "never");
assert.deepEqual(thinkingBlocksOf(view[1]), []);
});

test("reasoning core text is never inlined into the rebuilt text block", () => {
const entries = [
userEntry("u1", "q"),
thinkingAssistantEntry("a1", "secret chain", "answer"),
];
const view = rebuiltView(entries, "always");
const texts = JSON.stringify(view.map((m) => (m as { content?: unknown }).content));
assert.ok(texts.includes("answer"));
assert.ok(texts.includes("secret chain"));
const a1 = view[1] as unknown as { content: { type: string; text?: string; thinking?: string }[] };
const textBlock = a1.content.find((b) => b.type === "text");
assert.equal(textBlock?.text, "answer");
});

test("resolveConfig defaults reasoningReplay to open-round and honors overrides", () => {
const def = resolveConfig({}, 262144);
assert.equal(def.reasoningReplay, "open-round");
const off = resolveConfig({ compress: { reasoningReplay: "always" } }, 262144);
assert.equal(off.reasoningReplay, "always");
const viaCore = resolveConfig({ coreOverrides: { reasoningReplay: "never" } }, 262144);
assert.equal(viaCore.reasoningReplay, "never");
const viaProvider = resolveConfig(
{ compress: { providers: { openai: { reasoningReplay: "open-round" } } } },
262144,
"openai",
"gpt-5",
);
assert.equal(viaProvider.reasoningReplay, "open-round");
const viaModel = resolveConfig(
{ compress: { reasoningReplay: "always", providers: { openai: { models: { "gpt-5": { reasoningReplay: "never" } } } } } },
262144,
"openai",
"gpt-5",
);
assert.equal(viaModel.reasoningReplay, "never");
});

test("reasoningReplay defaults conservatively for GPT-family provider/model", () => {
assert.equal(resolveConfig({}, 262144, "openai", "gpt-5").reasoningReplay, "always");
assert.equal(resolveConfig({}, 262144, undefined, "o3-mini").reasoningReplay, "always");
assert.equal(resolveConfig({}, 262144, "openai-compatible", "gpt-4o").reasoningReplay, "always");
assert.equal(resolveConfig({}, 262144, "zhipuai-lb", "glm-5.3").reasoningReplay, "open-round");
assert.equal(resolveConfig({}, 262144, undefined, "qwen3.8-27b").reasoningReplay, "open-round");
assert.equal(resolveConfig({}, 262144, undefined, "llama-3").reasoningReplay, "open-round");
});

test("explicit reasoningReplay beats the GPT-family default", () => {
assert.equal(resolveConfig({ compress: { reasoningReplay: "open-round" } }, 262144, "openai", "gpt-5").reasoningReplay, "open-round");
assert.equal(resolveConfig({ compress: { reasoningReplay: "always" } }, 262144, "zhipuai-lb", "glm-5.3").reasoningReplay, "always");
});
12 changes: 8 additions & 4 deletions tests/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,20 @@ test("entriesToCoreMessages drops thinking-only assistant turns (no empty assist
);
});

test("entriesToCoreMessages keeps assistant turn that has thinking AND text (text extracted, thinking ignored)", () => {
test("entriesToCoreMessages keeps assistant turn that has thinking AND text (reasoning projected with #r sub-id, text with #t0)", () => {
const entries: SessionEntry[] = [
msgEntry("a", assistantThinkingAndText("private reasoning", "visible answer") as object),
];
const core = entriesToCoreMessages(entries);

assert.equal(core.length, 1);
assert.equal(core.length, 2);
assert.equal(core[0]!.role, "assistant");
assert.equal(core[0]!.contentType, "text");
assert.equal(core[0]!.text, "visible answer", "text kept, thinking block not inlined");
assert.equal(core[0]!.contentType, "reasoning");
assert.equal(core[0]!.id, "a#r0");
assert.equal(core[0]!.text, "private reasoning");
assert.equal(core[1]!.contentType, "text");
assert.equal(core[1]!.id, "a#t0");
assert.equal(core[1]!.text, "visible answer", "text kept, thinking block not inlined");
});

test("entriesToCoreMessages drops assistant turn whose text is whitespace-only", () => {
Expand Down
Loading
Loading