Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased (master, since v0.1.38)
- **fix(compress): 出口同步内核瘦身的 compress 锚点 args(closes #340)** — 内核 #230 修复(acp-kernel #232)后,出口视图的 compress 锚点调用文本会被瘦身(滤死范围 + >200 字符摘要存根化),但 pi 适配层 `coreOutToAgentMessages` 用原始 entry 内容重建 assistant 消息、不消费内核改写后的 tool-call text → 出口请求仍携带全量 args 复写本(风暴会话 01a07b3c 实测 16 条活锚点 args 合计 21.6K tokens,单条 924→278 chars)。现在 `coreOutToAgentMessages` 建 `coreTextByCallId` 映射(只收 `contentType:"tool-call"` 的 core——tool-result core 共享同一 toolCallId,其文本会覆盖调用文本,真实内核 E2E 抓到的回归),`syncToolCallArgs` 把内核改写后的 JSON(自首个 `{` 起,前缀与 content 形状由内核保证保留)同步回 `toolCall.arguments`,no-`#` 与 `#` 两条路径都生效:仅对 `name==="compress"` 动作(内核只改写 compress,可证守卫)、字节一致即跳过(旧内核 0.0.56 输出字节稳定 → 天然 no-op,本 PR 保持 pin 0.0.56 零行为变化)、JSON 解析失败或形状不符(须为含 `content` 数组/字符串的对象)回退原 args、字符串形式 args 保持字符串形状。与 #339(剥 thinking,~53K)正交,两项合计削掉锚点三层地板中的两层。实现:`src/messages.ts` `syncToolCallArgs` + `patchRefTag`/`reconstructToolCallMessage` 接线;测试:`tests/messages.test.ts` 新增 7 例(单调用 no-# / # 路径 / 字节一致 no-op / 非 compress 花括号字符串 args / 字符串形状 / 不可解析回退 / tool-result 同 id 覆盖)+ 真实内核 E2E(acp-kernel pr-232 overlay:processTurn → applyCompression → processTurn → 出口重建,实测 478→278 chars 与内核改写一致)。依赖 acp-kernel #232(v0.0.60)发版后 bump pin
- **fix(nudge): 增长基线刻度混用复位(closes #267)** — token 计量在估算值 ↔ provider 真实 usage 锚点间翻转(hostFloor 激活/失活)时,增长增量跨翻转相减是伪度量:旧刻度基线对新刻度 tokenCount 相减,凭空多出 "+35K 假增长",绕过 cadence 节流乱发 nudge(或反向漏发)。现在翻转时统一重置全部增长基线:kernel 侧 `lastNudgeShownTokens` / `lastPerMessageNudgeTokens` / `lastShownByTier`(0.0.55 per-tier cadence 基线)+ 扩展侧 `clearNudgeTokenStamps()`(#316 引入的同轮 re-inject 刻度戳,旧刻度戳同样会伪造满地板增长触发伪重注入);usage bands 的 floor-stale 行为保留不动。附带:② `/acp` 面板与 `acp_status` 百分比分母统一为与实时仲裁相同的 `window − maxOutput`(新增 `src/overflow-selfheal.ts` `applyOutputHeadroom`,此前面板按全窗口报告而 nudge bands 按 headroom 后窗口仲裁);③ auto-update 限流与只读停止标记按安装位置 sha256 隔离(多副本互不干扰)。实现:`src/runtime.ts` `noteTokenScale`/`dropTokenScale`,`src/index.ts` 翻转检测重置,`src/update.ts` `locationKey`;测试 `tests/growth-scale-flip.test.ts` + `tests/update.test.ts` 扩展
- **fix(state): 导入会话后从会话日志重放重建压缩状态(closes #299)** — pi 的 `importFromJsonl` 只拷贝 `.jsonl`,`<sessionFile>.acp.json` sidecar 不随行,导入的会话压缩状态静默归零(全量重发 → 重压缩 → 全量重缓存)。新增最后一级兑底:sidecar 与父会话继承都未命中且日志含成功 compress 调用时,首个 context 事件中重放日志(assistant toolCall 参数 + 非 error toolResult 配对,逐调用先 `processTurn` 补齐 `messageRefs` 再 `applyCompression`,镜像线上流水顺序),重建 blocks/summaries/effectiveMessageIds/stats 并落盘 sidecar;errored/no-op/无法解析的调用跳过(批量原子,拒绝即状态不变),重放幂等(sidecar 重建后不再触发)。新增 `src/state-rebuild.ts`(`hasCompressHistory` / `rebuildStateFromLog`),挂载 `src/runtime.ts` `stateFor`。上游宿主侧修复另行提案(ranxianglei/pi#1:import 拷贝 `${basename}.*` sidecar + export 保留 `parentSession`)
- **feat(kernel): acp-kernel 0.0.55 → 0.0.56 — 面板文案修正 + decompress 后 block 状态保持 (#182)** — ① 面板 "Context (session accounting)" 行改为 "includes compressed originals; shrinks slower than the sent view"(原 "never shrinks" 不成立:摘要替换被 prune 的原文后会降);"tokens compressed" 标注 cumulative 消除与当前 active blocks 的表面矛盾。② sync.ts 行为修复:宿主标记 `expanded` 的 block(用户显式 decompress 还原过)在 syncBlocks 中保持 inactive,不再被重新激活——否则已还原的消息下一轮又被折叠(双倍成本 + 丢原文);重新压缩同区间会创建新 block。适配 tests/commands-kit-panel.test.ts 断言新文案
Expand Down
72 changes: 63 additions & 9 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,22 @@ export function coreOutToAgentMessages(
): AgentMessage[] {
const out: AgentMessage[] = [];
const emittedSplit = new Set<string>();
const coreTextByCallId = new Map<string, string>();
for (const c of coreOut) {
// tool-result cores share the call's toolCallId — only the tool-call core
// ever carries the kernel-rewritten args text.
if (c.toolCallId && c.contentType === "tool-call" && typeof c.text === "string") {
coreTextByCallId.set(c.toolCallId, c.text);
}
}

for (const core of coreOut) {
if (core.id.startsWith("acp_summary_")) continue;

const hashIdx = core.id.indexOf("#");
if (hashIdx < 0) {
const original = originalById.get(core.id);
if (original) out.push(patchRefTag(original, core));
if (original) out.push(patchRefTag(original, core, coreTextByCallId));
continue;
}

Expand All @@ -246,7 +254,7 @@ export function coreOutToAgentMessages(
.filter((id): id is string => !!id),
);

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

return out;
Expand All @@ -256,6 +264,7 @@ function reconstructToolCallMessage(
original: AgentMessage,
firstCore: CoreMessage,
survivingCallIds: Set<string>,
coreTextByCallId: Map<string, string>,
): AgentMessage {
const base = original as AnyMessage;
const match = firstCore.text ? firstCore.text.match(REF_TAG) : null;
Expand All @@ -272,7 +281,8 @@ function reconstructToolCallMessage(
if (b.type === "toolCall") return survivingCallIds.has(b.id ?? "");
return true;
});
const peeled2 = peelRefTagBlocks(filtered2);
const synced2 = syncToolCallArgs(filtered2, coreTextByCallId);
const peeled2 = peelRefTagBlocks(synced2);
return { ...(original as object), content: peeled2 } as AgentMessage;
}

Expand All @@ -288,7 +298,8 @@ function reconstructToolCallMessage(
return true;
});

const peeled = peelRefTagBlocks(filtered);
const synced = syncToolCallArgs(filtered, coreTextByCallId);
const peeled = peelRefTagBlocks(synced);
const stableTag = rewriteTagTokens(tag, coreBodyOf(firstCore.text ?? "", tag));
const lastTextIdx = [...peeled].reverse().findIndex((b) => (b as { type?: string }).type === "text");
if (lastTextIdx >= 0) {
Expand All @@ -308,15 +319,58 @@ function coreBodyOf(coreText: string, tag: string): string {
return coreText.slice(bodyStart);
}

function patchRefTag(original: AgentMessage, core: CoreMessage): AgentMessage {
const match = core.text ? core.text.match(REF_TAG) : null;
const tag = match ? match[0] : null;
if (!tag) return original;
// The kernel's hide-compress-calls stage (acp-kernel #230/#232) rewrites the
// outgoing text of live compress calls (consumed-range filter + >200-char
// summary stubs). It only rewrites compress tool-calls, keeps everything before
// the first `{`, and keeps the content shape (string stays string). Sync the
// rewritten JSON back into toolCall.arguments so the provider receives the
// slimmed args instead of the original full-args copy.
function syncToolCallArgs(blocks: unknown[], coreTextByCallId: Map<string, string>): unknown[] {
let changed = false;
const out = blocks.map((block) => {
const b = block as { type?: string; name?: string; id?: string; arguments?: unknown };
if (b.type !== "toolCall" || b.name !== "compress" || !b.id) return block;
const rewritten = coreTextByCallId.get(b.id);
if (rewritten === undefined) return block;
const start = rewritten.indexOf("{");
if (start < 0) return block;
const jsonPart = rewritten.slice(start);
const argStr = stringifyArgs(b.arguments);
if (jsonPart === argStr) return block;
let parsed: unknown;
try {
parsed = JSON.parse(jsonPart);
} catch {
return block;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return block;
const content = (parsed as { content?: unknown }).content;
if (!Array.isArray(content) && typeof content !== "string") return block;
if (typeof b.arguments === "string") {
const prefix = argStr.indexOf("{") >= 0 ? argStr.slice(0, argStr.indexOf("{")) : "";
changed = true;
return { ...b, arguments: prefix + jsonPart };
}
changed = true;
return { ...b, arguments: parsed };
});
return changed ? out : blocks;
}

function patchRefTag(original: AgentMessage, core: CoreMessage, coreTextByCallId: Map<string, string>): AgentMessage {
const base = original as AnyMessage;
// Skip tag injection for assistant messages — the model sees tags on its own
// previous responses and echoes them, causing visible tag fragments in the terminal.
// The model can still reference assistant messages by inferring refs from context.
if (base.role === "assistant") return original;
if (base.role === "assistant") {
const blocks = Array.isArray(base.content) ? base.content : [];
const synced = syncToolCallArgs(blocks, coreTextByCallId);
if (synced === blocks) return original;
return { ...(original as object), content: synced } as AgentMessage;
}
const match = core.text ? core.text.match(REF_TAG) : null;
const tag = match ? match[0] : null;
if (!tag) return original;
// Honor kernel body mutations (emergency truncation of large tool-results,
// future rewrites): if core.text's body differs from the original text,
// rebuild from the kernel body — otherwise truncation never reaches the model.
Expand Down
148 changes: 148 additions & 0 deletions tests/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,151 @@ test("message identity ignores tag-only text blocks but preserves original empty
assert.equal(messageIdentity(taggedImage), messageIdentity(imageOnly));
assert.notEqual(messageIdentity(emptyText), messageIdentity(imageOnly));
});

function compressAssistantMsg(args: unknown): object {
return {
role: "assistant",
content: [{ type: "toolCall", id: "tc1", name: "compress", arguments: args }],
api: "anthropic",
provider: "anthropic",
model: "m",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "toolUse",
timestamp: Date.now(),
};
}

function contentOf(msg: object): Array<{ type: string; id?: string; name?: string; arguments?: unknown }> {
return (msg as { content: Array<{ type: string; id?: string; name?: string; arguments?: unknown }> }).content;
}

test("coreOutToAgentMessages syncs kernel-slimmed compress args (single-call, no-# path)", () => {
const longArgs = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(400) }] };
const stubbedArgs = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(199) + "…" }] };
const assistantMsg = compressAssistantMsg(longArgs);
const originalById = new Map([["entry1", assistantMsg as SessionMessageEntry["message"]]]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: `${tag}\n${JSON.stringify(stubbedArgs)}` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
assert.equal(out.length, 1);
const tc = contentOf(out[0]!).find((b) => b.type === "toolCall");
assert.deepEqual(tc?.arguments, stubbedArgs, "outgoing args are the kernel-slimmed version");
assert.equal(contentOf(assistantMsg)[0]!.arguments, longArgs, "original entry not mutated");
});

test("coreOutToAgentMessages syncs compress args on # path, leaves non-compress calls untouched", () => {
const longArgs = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(400) }] };
const stubbedArgs = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(199) + "…" }] };
const readArgs = { path: "/tmp/x" };
const assistantMsg = assistantParallelToolCalls([
{ id: "tc1", name: "compress", args: longArgs },
{ id: "tc2", name: "read", args: readArgs },
]);
const originalById = new Map([["entry1", assistantMsg as SessionMessageEntry["message"]]]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1#tc1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: `${tag}\n${JSON.stringify(stubbedArgs)}` },
{ id: "entry1#tc2", role: "assistant", contentType: "tool-call", toolName: "read", toolCallId: "tc2", text: `${tag}\n${JSON.stringify(readArgs)}` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
assert.equal(out.length, 1);
const blocks = contentOf(out[0]!);
assert.deepEqual(blocks.find((b) => b.id === "tc1")?.arguments, stubbedArgs, "compress call synced");
assert.deepEqual(blocks.find((b) => b.id === "tc2")?.arguments, readArgs, "non-compress call untouched");
});

test("coreOutToAgentMessages is a no-op when kernel text is byte-identical to original args", () => {
const args = { content: [{ startId: "m00001", endId: "m00010", summary: "short" }] };
const assistantMsg = compressAssistantMsg(args);
const originalById = new Map([["entry1", assistantMsg as SessionMessageEntry["message"]]]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: `${tag}\n${JSON.stringify(args)}` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
assert.equal(out[0], assistantMsg, "unchanged text returns the original reference (byte-stability)");
});

test("coreOutToAgentMessages does not rewrite non-compress string args containing braces", () => {
const bashArgs = 'result: {"x":1}';
const assistantMsg = {
role: "assistant",
content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: bashArgs }],
api: "anthropic",
provider: "anthropic",
model: "m",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "toolUse",
timestamp: Date.now(),
};
const originalById = new Map([["entry1", assistantMsg as SessionMessageEntry["message"]]]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: `${tag}\n${bashArgs}` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
assert.equal(out[0], assistantMsg, "non-compress tool with brace-containing string args is untouched");
});

test("coreOutToAgentMessages keeps string-form compress args as a string (shape preserved)", () => {
const longInner = JSON.stringify([{ startId: "m00001", endId: "m00010", summary: "S".repeat(400) }]);
const strArgs = JSON.stringify({ content: longInner });
const stubbedInner = JSON.stringify([{ startId: "m00001", endId: "m00010", summary: "S".repeat(199) + "…" }]);
const stubbedStr = JSON.stringify({ content: stubbedInner });
const assistantMsg = compressAssistantMsg(strArgs);
const originalById = new Map([["entry1", assistantMsg as SessionMessageEntry["message"]]]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: `${tag}\n${stubbedStr}` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
const args = contentOf(out[0]!).find((b) => b.type === "toolCall")?.arguments;
assert.equal(typeof args, "string", "string-form args stay a string");
assert.equal(args, stubbedStr, "string-form args hold the slimmed JSON");
});

test("coreOutToAgentMessages keeps original args when rewritten text is unparseable", () => {
const args = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(400) }] };
const assistantMsg = compressAssistantMsg(args);
const originalById = new Map([["entry1", assistantMsg as SessionMessageEntry["message"]]]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: `${tag}\n{"content":"[truncated...` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
assert.deepEqual(contentOf(out[0]!).find((b) => b.type === "toolCall")?.arguments, args, "unparseable rewrite falls back to original args");
});

test("coreOutToAgentMessages ignores tool-result cores sharing the compress callId", () => {
const longArgs = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(400) }] };
const stubbedArgs = { content: [{ startId: "m00001", endId: "m00010", summary: "S".repeat(199) + "…" }] };
const assistantMsg = compressAssistantMsg(longArgs);
const originalById = new Map([
["entry1", assistantMsg as SessionMessageEntry["message"]],
["entry2", toolResult("tc1", "compress", "Compressed 3 messages into block b1") as SessionMessageEntry["message"]],
]);

const tag = acpRef("m00003");
const coreOut: CoreMessage[] = [
{ id: "entry1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: `${tag}\n${JSON.stringify(stubbedArgs)}` },
{ id: "entry2", role: "tool", contentType: "tool-result", toolName: "compress", toolCallId: "tc1", text: `${tag}\nCompressed 3 messages into block b1` },
];

const out = coreOutToAgentMessages(coreOut, originalById);
const tc = contentOf(out[0]!).find((b) => b.type === "toolCall");
assert.deepEqual(tc?.arguments, stubbedArgs, "sync uses the tool-call core text, not the tool-result core");
});