From 9213fd4bcfe92c81aa9f38f438db799b2ab53580 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 8 Sep 2026 00:55:42 +0800 Subject: [PATCH] feat(nudge): persist nudge injections as display-only session entries + single-line acp.log (closes #326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pi.appendEntry("acp-nudge", {text}) on injection: compact one-line record ([ACP nudge] EMERGENCY 95% · T1 · top range m00120–m00168), deduped per user turn; survives restarts (session JSONL), visible in TUI scrollback via a registered entry renderer (pi-tui Box/Text, bundled inline), never sent to the model (type:"custom" entries are not projected into the sent view) - log.ts: escape \r/\n in string fields (incl. stacks) so every acp.log entry occupies one physical line -> nudge-injected entries self-contained for grep - devDep @earendil-works/pi-tui pinned exactly 0.83.0 (matches host); dist stays zero-runtime-deps - tests: formatNudgeRecord unit, persistence/dedup integration (emergency re-injection must not duplicate records), renderer unit, log single-line --- CHANGELOG.md | 1 + README.md | 4 +- package-lock.json | 41 ++++++++++++ package.json | 1 + src/index.ts | 42 +++++++++++- src/log.ts | 13 +++- src/messages.ts | 8 +++ src/runtime.ts | 8 ++- tests/log.test.ts | 17 +++++ tests/nudge-record.test.ts | 130 +++++++++++++++++++++++++++++++++++++ 10 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 tests/nudge-record.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a1a91..ee79747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **feat(nudge): nudge 注入持久化为只读 session entry + acp.log 单物理行(closes #326)** — 此前 nudge 提示仅经 `ctx.ui.notify()` 显示为瞬态 `Text` 子节点,进程重启后 `chatContainer.clear()` + 重建把注入历史从视图里全部抹掉;且 acp.log 中多行 nudge body 溢出到未打标签的物理行,`grep nudge-injected` 只能拿到标题行。现在注入时经 `pi.appendEntry("acp-nudge", ...)` 追加一条紧凑记录(如 `[ACP nudge] EMERGENCY 95% · T1 · top range m00120–m00168`):pi 的 custom entry 为 `type:"custom"`,不进入发送视图(只有 `custom_message` 会投影给模型),故零模型上下文污染;entry 落盘 session JSONL,重启可恢复,并注册 entry renderer(基于 pi-tui 的 dim box,内联 bundle 进 dist、零新增运行时依赖)使其在 TUI scrollback 与重建后均可见;按用户轮去重(每轮至多一条,emergency 逐次重注入不重复落盘)。同时 `log.ts` 对字符串字段(含 stack)转义 `\r`/`\n`,保证每条日志恰好占一个物理行、grep 自包含 - **fix(delegate): 并发完成的 delegate 通知合并为单条批量消息(closes #157)** — 多个 subagent 同时(或主窗口正忙时接连)完成时,每条完成各自 `sendUserMessage(deliverAs: followUp)` 注入一条通知,N 个完成的 delegate 吃掉 N 个完整模型轮次,且模型已收尾后通知仍在持续涌入。现在 finalize 不再直接注入,而是进入 2s 尾沿防抖窗口(自首个排队完成起硬上限 10s,防连续错峰完成饿死投递);窗口关闭时 `flushDelegateNotifications` 发**一条**批量消息:头部计数(`[acp_delegate] 3 delegates finished (2 completed, 1 FAILED)`)+ 每个 run 一节(状态/exit code/超时注记/任务/结果文件/失败错误摘录)+ 单一尾部(仍在跑的 delegate 数、session delegate usage、收尾指令)。窗口期内获得 waiter 或被 `acp_delegate_wait`/`acp_delegate_cancel` 消费的 run 自动出批(不重复投递);发送失败不置 delivered,run 留在未送达集由后续 carrier 补投(`findUndeliveredRuns` 将排队中视为已排程而非丢失);单 run flush 保留原单条格式。净效果:N 个同时完成 → 1 个模型轮次 - **feat(delegate): 失败/取消保留日志 + 失败诊断 + `resumeFrom` 续跑中断的 run (closes #235)** — 此前 cancel 与 spawn error 路径直接删除 `.out`/`.activity` 文件,失败通知只有 exit code(信号被丢弃、stderr 可能为空、activity 轨迹不可见)。现在:① 所有终止路径保留文件(spawn error 把错误写入 `.out`;cancel 回填部分回复,cancel/wait 结果明确给出文件路径);② 失败通知带 exit 信号(`exit SIGTERM`)、stderr、activity 日志尾部(400 字符)与 activity 文件路径;③ pi 宿主 delegate 用 `--session /.session.jsonl` + `--session-dir` 持久化自身会话(omp 不变,保持 `--no-session`),新增 `resumeFrom: ""` 参数让新 run 恢复原会话(原任务 + 已执行的 tool calls + 部分结果)从中断处继续——`task` 变为可选(提供时作为本次追加指引);校验:原 run 不得仍在运行、session 文件必须存在、非 pi 宿主拒绝 - **fix(floor): provider-usage floor 跳过压缩后一轮的 stale anchor(#258 评审)** — pi 的 `getContextUsage()` 锚定最后一条有效 assistant usage;成功 compress 落在锚点之后时,下一个 LLM 调用仍以压缩前的大数字 floor,会在刚缩小的上下文上重跑 emergency(nudge 注入 + 工具结果机械截断),面板也继续显示压缩前数字,直到下一条 usage 到达。新增 `src/floor-stale.ts`(`usageAnchorPredatesCompression`):锚点(跳过 aborted/error/全零)早于最后一条成功 compress toolResult(失败/0-block no-op 不算)时跳过 floor,context transform / `acp_status` / `/acp` 三处一致(面板 sessionTokens 同步);对齐 pi 自身 compaction 的 "usage source must be post-compaction" 检查。已知接受:provider 永不报 usage 时(omp #18 tree-sum 回退)floor 仍会永久高位 diff --git a/README.md b/README.md index 2983285..18bef60 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,9 @@ billion-context-pi writes a structured, always-on log to `~/.pi/acp.log` (overri - **Always written** (even with `debug: false`): `error`, `warn`, `info` levels — session start, every context turn (token usage / nudge decision), compress/decompress, delegate spawn/done, and **all errors and warnings** (config/state/tool failures, delegate errors, guardrail caps, update failures). Error lines include the message and stack trace. - **Written only when `debug: true`**: verbose `debug`-level diagnostics (full field dumps, per-turn internals). -Each line: ` [] [] key=value key=value`. The file rotates to `~/.pi/acp.log.old` at 10 MB. +- **Nudge audit trail**: whenever a context-limit nudge is injected, a compact one-line record (e.g. `[ACP nudge] EMERGENCY 95% · T1 · top range m00120–m00168`) is appended as a *display-only* session entry — it survives process restarts, shows up in TUI scrollback and the session file, and is never sent to the model. + +Each line: ` [] [] key=value key=value`. Multi-line values (e.g. the full nudge text in `nudge-injected`) are escaped (`\n` → literal `\\n`), so every entry occupies exactly **one physical line** and stays self-contained for `grep`. The file rotates to `~/.pi/acp.log.old` at 10 MB. ```sh tail -f ~/.pi/acp.log # watch the session live diff --git a/package-lock.json b/package-lock.json index c31c74a..10c1b7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", + "@earendil-works/pi-tui": "0.83.0", "@types/node": "^26.1.2", "acp-kernel": "0.0.48", "tsup": "^8.5.1", @@ -1983,6 +1984,20 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz", + "integrity": "sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -3375,6 +3390,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -3425,6 +3453,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", diff --git a/package.json b/package.json index 4c59bfe..3f223fd 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", + "@earendil-works/pi-tui": "0.83.0", "@types/node": "^26.1.2", "acp-kernel": "0.0.48", "tsup": "^8.5.1", diff --git a/src/index.ts b/src/index.ts index 58e83b7..edc9bd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import type { SessionMessageEntry, } from "@earendil-works/pi-coding-agent"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { Box, Text } from "@earendil-works/pi-tui"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -18,7 +19,7 @@ import { makeSearchTool } from "./search-tool.js"; import { makeStatusTool } from "./status-tool.js"; import { makeDelegateTool, makeDelegateWaitTool, makeDelegateCancelTool, runningRunsSnapshot, resetDelegateUsage, setDelegateDisplayUsage } from "./delegate-tool.js"; import { makeCommands } from "./commands.js"; -import { coreOutToAgentMessages, extractText } from "./messages.js"; +import { coreOutToAgentMessages, extractText, ACP_NUDGE_CUSTOM_TYPE, type AcpNudgeRecord } from "./messages.js"; import { buildAcpSystemPrompt, ACP_DELEGATE_PROMPT } from "./system-prompt.js"; import { delegateStatusWidget } from "./fleet-widget.js"; import { wireToolGuardrails } from "./tool-guardrails.js"; @@ -69,6 +70,7 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor for (const { name, options } of makeCommands(runtime, pi)) { pi.registerCommand(name, options); } + wireNudgeRecords(pi); }; } @@ -369,6 +371,19 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { } if (!emergency) runtime.markNudgeShown(turnKey); debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn ? "terminal" : null].filter(Boolean), emergency, turnKey, text: rendered.text + example }); + // issue #326: persist a compact display-only record so the injection + // survives restarts (TUI scrollback + session file). One per user turn: + // emergency nudges re-inject on every LLM call and must not flood the + // session file; type:"custom" entries are never projected into the + // sent view, so model context stays clean. + if (typeof pi.appendEntry === "function" && !runtime.nudgeRecordedFor(turnKey)) { + try { + pi.appendEntry(ACP_NUDGE_CUSTOM_TYPE, { text: formatNudgeRecord(turn.nudge, emergency) }); + runtime.markNudgeRecorded(turnKey); + } catch (e) { + logWarn("nudge", { sid: ctx.sessionManager.getSessionId(), event: "persist-failed", error: e instanceof Error ? e.message : String(e) }); + } + } } else { debug.event("nudge-suppressed", { sid: ctx.sessionManager.getSessionId(), turnKey, reason: turn.nudge.reason }); } @@ -528,6 +543,20 @@ function wireThrottleRetry(pi: ExtensionAPI, runtime: AcpRuntime): void { }); } +// issue #326: render persisted nudge records in the TUI. Without a registered +// renderer pi silently drops custom entries on both live append and session +// rebuild, so this is required for the records to be visible at all. +function wireNudgeRecords(pi: ExtensionAPI): void { + if (typeof pi.registerEntryRenderer !== "function") return; + pi.registerEntryRenderer(ACP_NUDGE_CUSTOM_TYPE, (entry, _options, theme) => { + const text = entry.data?.text; + if (!text) return undefined; + const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t)); + box.addChild(new Text(theme.fg("dim", text), 0, 0)); + return box; + }); +} + function collectOriginals(entries: Array<{ type: string; id: string; message?: AgentMessage; content?: unknown }>): Map { const map = new Map(); for (const entry of entries) { @@ -573,6 +602,17 @@ function collectCompressOutcomes(entries: Array<{ type: string; id: string; mess return out; } +// Compact one-line twin of nudgeMessage() for the persisted session entry +// (issue #326): auditability only — the full multi-line nudge deliberately +// stays out of the session file. +export function formatNudgeRecord(nudge: NudgeDecision, emergency: boolean): string { + const pct = Math.round(nudge.contextUsage * 100); + const tier = `T${nudge.tier ?? 1}`; + const top = [...nudge.compressibleRanges].sort((a, b) => b.tokens - a.tokens)[0]; + const range = top ? ` · top range ${top.startRef}–${top.endRef}` : ""; + return `[ACP nudge]${emergency ? " EMERGENCY" : ""} ${pct}% · ${tier}${range}`; +} + function nudgeMessage(nudge: NudgeDecision, blocks: CompressionBlock[], prompts: Prompts): AgentMessage { const rendered = renderNudgeText(nudge, prompts); const lines = [rendered.text]; diff --git a/src/log.ts b/src/log.ts index 935aa8c..836b1ef 100644 --- a/src/log.ts +++ b/src/log.ts @@ -22,13 +22,20 @@ function debugOn(): boolean { return runtimeDebug ?? ENV_DEBUG; } +// One physical line per entry (issue #326): multi-line field values (nudge +// text, error stacks) must not spill onto untagged lines or grep of an event +// name stops at the headline. JSON.stringify already escapes control chars. +function escapeLine(s: string): string { + return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n"); +} + function fmt(v: unknown): string { - if (typeof v === "string") return v; - if (v instanceof Error) return v.stack || String(v); + if (typeof v === "string") return escapeLine(v); + if (v instanceof Error) return escapeLine(v.stack || String(v)); try { return JSON.stringify(v); } catch { - return String(v); + return escapeLine(String(v)); } } diff --git a/src/messages.ts b/src/messages.ts index 35e9d21..a78f156 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -22,6 +22,14 @@ const TRAILING_REF_TAG = new RegExp(`\\n*${REF_TAG_SOURCE}\\s*$`); // session, but never projected into the sent view. export const ACP_STATUS_CUSTOM_TYPE = "acp-status"; +// Nudge persistence record (issue #326): written via pi.appendEntry as a +// type:"custom" entry — never projected into the sent view (see above), so +// the compact one-liner stays out of model context while surviving restarts. +export const ACP_NUDGE_CUSTOM_TYPE = "acp-nudge"; +export interface AcpNudgeRecord { + text: string; +} + export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] { const out: CoreMessage[] = []; for (const entry of entries) { diff --git a/src/runtime.ts b/src/runtime.ts index dcf95f9..01aa8ac 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -61,6 +61,11 @@ export interface AcpRuntime { setPrompts(prompts: Prompts): void; markNudgeShown(turnKey: string): void; nudgeShownFor(turnKey: string): boolean; + /** Like the nudgeShown* pair but tracks the persisted display-only session + * entry (issue #326): at most one record per user turn even when the + * emergency nudge re-injects on every LLM call. */ + markNudgeRecorded(turnKey: string): void; + nudgeRecordedFor(turnKey: string): boolean; /** Process compress toolResults for the CURRENT user turn only (the caller * scopes the list — see collectCompressOutcomes in src/index.ts); idempotent * per toolCallId. Outcome classes: isError or noop (0-block panel) → @@ -252,6 +257,7 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let lastUserConfigKey: string | undefined; let promptsRef: Prompts = defaultPrompts; const nudgeShownTurns = new Set(); + const nudgeRecordedTurns = new Set(); // Per-session overflow self-heal state (learned window + armed emergency). const overflowEpisodes = new Map(); function overflowFor(sid: string): OverflowEpisode { @@ -405,4 +411,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { } let refused = false; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), markNudgeRecorded: (k) => { nudgeRecordedTurns.add(k); }, nudgeRecordedFor: (k) => nudgeRecordedTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeRecordedTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };} diff --git a/tests/log.test.ts b/tests/log.test.ts index fed5ca4..77d1468 100644 --- a/tests/log.test.ts +++ b/tests/log.test.ts @@ -69,6 +69,23 @@ test("logThrow records message and stack as error", async () => { await rm(path.dirname(file), { recursive: true, force: true }); }); +test("multi-line field values stay on one physical line (issue #326)", async () => { + const file = await freshLog(); + const log = await loadLogger(file); + log.setDebugEnabled(true); + log.debug.event("nudge", { event: "nudge-injected", text: "⚠️ Context limit reached\nTier 1 ranges:\n m00001–m00050: 12K tokens" }); + log.logThrow("ctx", new Error("boom"), { sid: "s9" }); + log.closeLogStream(); + const content = await readFile(file, "utf8"); + const lines = content.split("\n").filter((l) => l.length > 0); + for (const l of lines) { + assert.match(l, /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[/, `every entry is one tagged physical line: ${l}`); + } + assert.ok(lines.some((l) => l.includes("text=⚠️ Context limit reached\\nTier 1 ranges:\\n m00001–m00050: 12K tokens")), "escaped newlines keep the nudge body self-contained for grep"); + assert.ok(lines.some((l) => /\[error\] \[ctx\]/.test(l) && l.includes("stack=Error: boom\\n")), "stack trace stays on the same physical line"); + await rm(path.dirname(file), { recursive: true, force: true }); +}); + test("log lines carry ISO timestamp, level and scope", async () => { const file = await freshLog(); const log = await loadLogger(file); diff --git a/tests/nudge-record.test.ts b/tests/nudge-record.test.ts new file mode 100644 index 0000000..a497006 --- /dev/null +++ b/tests/nudge-record.test.ts @@ -0,0 +1,130 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAcpExtension, formatNudgeRecord } from "../src/index.js"; +import type { NudgeDecision } from "acp-kernel"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { setRunNpmForTest } from "../src/update.js"; +import { ACP_NUDGE_CUSTOM_TYPE } from "../src/messages.js"; + +// Headless handlers await the update check — keep this file hermetic too. +setRunNpmForTest(async (args) => ({ code: 0, stdout: args[0] === "view" ? "0.0.1\n" : "", stderr: "" })); +process.env.ACP_UPDATE_THROTTLE_FILE = join(tmpdir(), `acp-test-nudge-record-throttle-${process.pid}`); + +function captureApi() { + const handlers = new Map any)[]>(); + const entryRenderers = new Map any)[]>(); + const appendedEntries: Array<{ customType: string; data?: unknown }> = []; + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + registerEntryRenderer(customType: string, renderer: any) { entryRenderers.set(customType, renderer); }, + appendEntry(customType: string, data?: unknown) { appendedEntries.push({ customType, data }); }, + }; + return { api, handlers, entryRenderers, appendedEntries }; +} + +function fakeCtx(getEntries: () => any[], stateFile: string) { + return { + mode: "rpc", + hasUI: false, + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 200_000, id: "test-model" }, + getContextUsage: () => null, + sessionManager: { + buildContextEntries: () => getEntries(), + getSessionId: () => "nudge-record-session", + getSessionFile: () => stateFile, + }, + }; +} + +// ─── unit: record format ──────────────────────────────────────────────────── + +test("formatNudgeRecord: compact one-liner matching the issue #326 example", () => { + const base = { + shouldInject: true, + reason: "context pressure", + contextUsage: 0.953, + tier: null, + compressibleRanges: [ + { startRef: "m00001", endRef: "m00010", count: 10, tokens: 500 }, + { startRef: "m00120", endRef: "m00168", count: 48, tokens: 3000 }, + ], + breakdown: {}, + } as unknown as NudgeDecision; + assert.equal(formatNudgeRecord(base, true), "[ACP nudge] EMERGENCY 95% · T1 · top range m00120–m00168"); + assert.equal(formatNudgeRecord(base, false), "[ACP nudge] 95% · T1 · top range m00120–m00168"); + assert.equal(formatNudgeRecord({ ...base, tier: 2 } as NudgeDecision, false), "[ACP nudge] 95% · T2 · top range m00120–m00168"); + assert.equal(formatNudgeRecord({ ...base, compressibleRanges: [] } as NudgeDecision, true), "[ACP nudge] EMERGENCY 95% · T1"); +}); + +// ─── integration: persistence wiring ──────────────────────────────────────── + +test("nudge injection persists exactly one display-only entry per user turn (issue #326)", async () => { + const { api, handlers, entryRenderers, appendedEntries } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + const stateFile = join(tmpdir(), `pai-acp-nudge-record-${process.pid}.session.json`); + await rm(`${stateFile}.acp.json`, { force: true }); + + assert.ok(entryRenderers.has(ACP_NUDGE_CUSTOM_TYPE), "entry renderer registered at factory time"); + + // ~270K tokens of sent view vs a 180K window → kernel goes EMERGENCY and + // the nudge re-injects on every context fire (dedup bypass). + const MID = "lorem ".repeat(3000); + const roleMsg = (id: string, role: string, text: string) => ({ + type: "message", id, parentId: null, timestamp: "", + message: { role, content: text, timestamp: Date.now() }, + }); + let entries: any[] = [roleMsg("u0", "user", "start " + MID)]; + for (let i = 1; i <= 59; i++) entries.push(roleMsg(`e${i}`, i % 2 ? "assistant" : "user", `f${i} ` + MID)); + const ctx = fakeCtx(() => entries, stateFile); + const fire = () => handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + const nudgeCount = (r: any) => + (r?.messages ?? []).filter((m: any) => m.role === "user" && /Context limit reached/.test(JSON.stringify(m.content))).length; + + const r0 = await fire(); + assert.ok(nudgeCount(r0) >= 1, "emergency nudge fires on real overflow"); + assert.equal(appendedEntries.length, 1, "one persisted record on first injection"); + assert.equal(appendedEntries[0].customType, ACP_NUDGE_CUSTOM_TYPE); + assert.match(String(appendedEntries[0].data?.text), /^\[ACP nudge\] EMERGENCY \d+% · T\d+( · top range m\d+–m\d+)?$/); + + // Second LLM call of the SAME turn: the emergency nudge re-injects into + // context, but the session file must not gain a second record. + const r1 = await fire(); + assert.ok(nudgeCount(r1) >= 1, "emergency re-injection still reaches the model"); + assert.equal(appendedEntries.length, 1, "no duplicate record within the same turn"); + + // New user message → new turn key → next injection records again. + entries = [...entries, roleMsg("u1", "user", "next " + MID)]; + const r2 = await fire(); + assert.ok(nudgeCount(r2) >= 1, "nudge fires for the new turn"); + assert.equal(appendedEntries.length, 2, "new turn gets its own record"); + + await rm(`${stateFile}.acp.json`, { force: true }); +}); + +test("acp-nudge entry renderer builds a dim boxed component", () => { + const { api, entryRenderers } = captureApi(); + createAcpExtension({})(api as any); + const renderer = entryRenderers.get(ACP_NUDGE_CUSTOM_TYPE)!; + const theme = { + fg: (_c: string, t: string) => t, + bg: (_c: string, t: string) => t, + } as Theme; + + const comp = renderer({ customType: ACP_NUDGE_CUSTOM_TYPE, data: { text: "[ACP nudge] EMERGENCY 95% · T1" } }, { expanded: false }, theme); + assert.ok(comp, "well-formed record renders a component"); + assert.equal(comp.children.length, 1, "box holds the text child"); + + assert.equal(renderer({ customType: ACP_NUDGE_CUSTOM_TYPE, data: {} }, { expanded: false }, theme), undefined, "missing text → invisible but persisted"); +});