diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abd6d8..dcdf82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`,`.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 断言新文案 diff --git a/src/messages.ts b/src/messages.ts index 35e9d21..e3a2f6e 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -221,6 +221,14 @@ export function coreOutToAgentMessages( ): AgentMessage[] { const out: AgentMessage[] = []; const emittedSplit = new Set(); + const coreTextByCallId = new Map(); + 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; @@ -228,7 +236,7 @@ export function coreOutToAgentMessages( 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; } @@ -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; @@ -256,6 +264,7 @@ function reconstructToolCallMessage( original: AgentMessage, firstCore: CoreMessage, survivingCallIds: Set, + coreTextByCallId: Map, ): AgentMessage { const base = original as AnyMessage; const match = firstCore.text ? firstCore.text.match(REF_TAG) : null; @@ -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; } @@ -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) { @@ -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): 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): 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. diff --git a/tests/messages.test.ts b/tests/messages.test.ts index f33c91f..5434f6d 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -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"); +});