From edf6a9d25531b44d4e5a2b17ff2d8eb48d580226 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Fri, 11 Sep 2026 00:22:25 +0800 Subject: [PATCH 1/3] feat(host): unified turn boundary + child-session state derivation (#364) - src/turn-boundary.ts: single isTurnBoundary(entry, policy) predicate + lastTurnBoundaryId/Index; tokens.ts lastUserMessageId, index.ts turnStartIndex, and messages.ts projection all converge on it - hostSession config (boolean | {countCustomMessages}), default off = pi-native behavior, existing single-session cadence byte-for-byte unchanged - deriveChildState(parentState): inherit blocks/messageRefs/tokenSnapshot/ counters, reset nudge/stats/absorbed; one-time derivedFrom marker persisted to the independent child sidecar; runtime.deriveChildState(childRef, parentRef) with guards (own blocks / empty parent / no file / already derived) - pi-native delegate (separate-process) path untouched - docs/host-adapter.md + CONFIGURATION.md(+zh-CN) + CHANGELOG entries - tests: tests/turn-boundary.test.ts, tests/derive-child-state.test.ts, config/user-config additions; 671 pass / 0 fail / 3 skip --- CHANGELOG.md | 1 + CONFIGURATION.md | 33 +++++ CONFIGURATION.zh-CN.md | 33 +++++ docs/host-adapter.md | 138 ++++++++++++++++++ src/commands.ts | 2 +- src/compress-tool.ts | 6 +- src/config.ts | 57 ++++++-- src/index.ts | 22 +-- src/messages.ts | 7 +- src/runtime.ts | 53 ++++++- src/state.ts | 67 ++++++++- src/tokens.ts | 10 +- src/turn-boundary.ts | 73 ++++++++++ src/user-config.ts | 4 +- tests/config.test.ts | 21 ++- tests/derive-child-state.test.ts | 243 +++++++++++++++++++++++++++++++ tests/messages.test.ts | 3 +- tests/tokens.test.ts | 30 +--- tests/turn-boundary.test.ts | 120 +++++++++++++++ tests/user-config.test.ts | 13 ++ 20 files changed, 856 insertions(+), 80 deletions(-) create mode 100644 docs/host-adapter.md create mode 100644 src/turn-boundary.ts create mode 100644 tests/derive-child-state.test.ts create mode 100644 tests/turn-boundary.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fec20..bc452bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **feat(host): 宿主多会话支持(二) — 回合边界判定统一 + 子会话状态继承(closes #364)** — #317 遗留的两个结构性缺口(记账隔离已由 #327 修复)。① 回合边界("什么消息算新回合起点")此前在三处独立判定且互不一致(tokens.ts 的 lastUserMessageId / index.ts 的 turnKey+turnStartIndex / messages.ts 的上下文条目投影):宿主以 custom_message 注入的 agent 回合进入 LLM 上下文但不算回合起点 → 多个真实回合塌缩进同一 turnKey(nudge 账本格子错位、重试上限与节流周期统计失真)。现收敛为单一谓词 `isTurnBoundary(entry, policy)` + 两个扫描助手(`src/turn-boundary.ts`),三处全部走它;新增 `hostSession` 配置(boolean 简写或 `{countCustomMessages}`,默认关闭 = pi 原生行为,存量单会话用户逐字节不变——单测以 legacy user-role-only 扫描为 oracle 断言等价)。② 内联同进程子会话(Prime RLM 等)的状态派生契约 `deriveChildState(parentState)`:继承 blocks(深拷贝)/messageRefs/tokenSnapshot(原始消息索引)/nextBlockId/nextRunId(保证继承块可 decompress/search、新块 id 不冲突),重置 nudge 节奏基线/stats/absorbed(子会话重新起算);一次性迁移标记 `derivedFrom:{parentSessionId,derivedAt}` 持久化进子 sidecar(沿用 `.jsonl.acp.json`,与父文件独立),拒绝重复派生;护栏:子会话已有自有非派生块 / 父无块 / 子无 sessionFile 时拒绝且不改任何状态;显式派生优先于隐式 parentSession 头继承(恰好升级一次)。pi 原生 delegate(独立进程)路径零变化。文档:新增 `docs/host-adapter.md`(回界契约 + 子会话派生契约),CONFIGURATION.md(+zh-CN) 增 `hostSession` 节。测试:新增 `tests/turn-boundary.test.ts`(谓词矩阵 + id/index 双视图一致性 + 默认策略 ≡ legacy 扫描回归)与 `tests/derive-child-state.test.ts`(继承/重置矩阵、深拷贝隔离、marker 往返、各护栏拒绝、内联子代理 header 场景) - **fix(degeneration): thinking/text 单字符退化熔断 + 一次性恢复通知(closes #351)** — 长会话末尾模型偶发退化为单字符长连击(实测:thinking 块末尾 4655 个连续「【」,跨轮升级直至 turn abort、会话停死)。根因链已代码级验证:pi 的 openai-completions 转换把历史 assistant thinking 在**每个后续请求**中回传 provider(`reasoning_content`,或 requiresThinkingAsText 时转纯文本),aborted turn 的部分消息又持久化在会话日志里 → 退化尾部随每轮 prompt 重放 → 模型看到自己上一轮以数千个重复字符结尾 → 续写偏置再次触发退化 → 连环 abort。新增 `src/degeneration.ts`:每个 context 事件对出站视图的 assistant text/thinking 块折叠 ≥`minRun`(默认 200,下限 8,codepoint/代理对安全)的单 codepoint 连击为短标记(保留 ≤3 份样本;纯函数、幂等——标记固定文案无相邻重复码点、fail-safe;持久化历史不改,toolCall 参数不动以免与实际执行脱钩);当最近一条 assistant 消息已退化时追加一次性 `[ACP recovery notice]`(位置自限:模型产出新 turn 后自动消失,无持久状态不累积,#223 教训)。检测走持久化 originals 而非出站视图:thinking-only aborted turn 会被 projectMessage 丢弃(空文本在 OpenAI 兼容 provider 400),但它仍是模型的"上一轮",通知必须照发。acp.json 新键 `degenerationGuard`(boolean 或 `{enabled,minRun}`,默认开;`false` 为 kill-switch)。附带修复:`repetitionGuard` 此前不在 user-config KNOWN 白名单内,acp.json 中配置被静默丢弃(dead key),本次补入。测试 `tests/degeneration.test.ts`(31 例:单元 + context transform 端到端 wiring) - **fix(reasoning): 闭合判定改按回合证据——无用户消息的长 agent 会话不再永久保留 compress thinking(closes #348)** — 原门控“compress 调用之后存在真实用户消息才算闭合”在长 agent 会话不可达(整个会话只有开头 1–2 条用户消息,后续 30 个 compress 全部被永久视为活跃回合,观察会话 0 次触发,thinking 地板 20.6K/8.4K/10.6K 字符全部滞留)。现在闭合判定改为:消息内**每个** compress toolCall 的 toolResult(role `toolResult`、`toolCallId` 匹配)已出现在更晚位置,且其后至少还有一条消息(回合已实际推进)。安全门不变:结果未返回或结果仍是最后一条消息(在飞中)绝不动;nudge 在 drop 之后才注入,不可能光当“结果后的消息”闭合在飞回合;per-provider `compress.providers..reasoning.drop=false` 逃生阀保留(GLM 等 reasoning 回显模型)。测试重写 + 新增 #348 场景(无用户消息的助手链闭合、result 悬置、result 在 call 之前、多 toolCall 部分闭合、误 id 不闭合) - **fix(overflow): output headroom 预留按窗口比例封顶,默认 25%(closes #207)** — `reserveOutputHeadroom` 原按模型注册表 maxTokens **全额**预留输出预算:maxTokens 占窗口比例大的模型(qwen3.8-27b:262144 窗口 / 131072 maxTokens)输入预算被砍半,kernel 75% 强制压缩带在完整窗口 ~37% 处触发(host pct 仅 ~34%,两个口径不同加剧误导)。现在预留量 = min(maxTokens, `outputHeadroomMaxPct` × window):新增 acp.json 配置键 `outputHeadroomMaxPct`(默认 0.25,接受比例或 `"N%"`;0 完全禁用预留,≥1 恢复旧的全额行为)。小预留不受影响(同窗口 int4 版 32K maxTokens 保持原样),超出预留的超长回复溢出一次后由既有 overflow self-heal(learned window + armed emergency)下一轮恢复。可观测性:`[turn]` 日志新增 `fullWindow` 字段(仅当 limit 被预留削减时出现,= 本轮 recenter 后的完整窗口),消除 pct(完整窗口口径)vs limit(预留后口径)混淆;`output-headroom` 事件日志新增 `cap` 字段;`/acp` 面板与 `acp_status` 分母经 `applyOutputHeadroom` 同步使用同一封顶值(#267 统一口径不回归) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 19d18d7..b11970a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -105,6 +105,7 @@ All keys below are currently **ACTIVE**. | `throttleRetry` | boolean \| object | `true` | 🟢 ACTIVE | Auto-retry provider token rate-limit errors with progressive backoff. | | `repetitionGuard` | boolean \| object | `true` | 🟢 ACTIVE | Break infinite loops of byte-identical tool calls (warn at 3 consecutive, block + abort at 5). | | `degenerationGuard` | boolean \| object | `true` | 🟢 ACTIVE | Collapse degenerate single-codepoint runs (e.g. 4655×「【」) in assistant text/thinking of the outgoing view and inject a one-shot recovery notice — breaks the abort loop where pi replays degenerated thinking back to the provider on every request (#351). | +| `hostSession` | boolean \| object | `false` | 🟢 ACTIVE | Turn-boundary policy for multi-session hosts: count injected `custom_message` entries as turn starts. Off by default (pi-native behavior). | **Delegate keys** @@ -485,6 +486,38 @@ Tool-call arguments are never rewritten (rewriting them would desync the model's --- +## Host Multi-Session + +The `hostSession` key controls **turn-boundary detection** for hosts that run several sessions inside one process (e.g. Prime with inline RLM sub/sibling sessions). The full contract — including child-session state derivation (`deriveChildState`) — is documented in **[docs/host-adapter.md](./docs/host-adapter.md)**. + +**Background.** ACP's per-turn ledgers (nudge-shown tracking, compress retry caps, outcome scoping) are keyed by the start of the current *turn*. Under Pi-native semantics a turn starts only at a genuine user-role message. Inline multi-session hosts additionally inject agent turns into the session log as `custom_message` entries; those are projected into LLM context (Pi-native semantics) but — under the default policy — start no turn, so several real host turns collapse into one turn key: nudge cadence cells misalign and retry-cap/throttle cycle statistics distort. Every turn-boundary decision in the adapter goes through the single predicate `isTurnBoundary(entry, policy)` (`src/turn-boundary.ts`). + +### `hostSession` + +- **Type:** boolean \| object +- **Default:** `false` (off) +- **Status:** 🟢 ACTIVE +- **Description:** Turn-boundary policy for host-injected messages. `hostSession: true` is shorthand for `{ "countCustomMessages": true }`. Object form (any subset): + + ```json + { + "hostSession": { + "countCustomMessages": true + } + } + ``` + + **Default-off keeps existing single-session behavior byte-for-byte** — enable this only if your host actually injects agent turns into session logs. + +### `hostSession.countCustomMessages` + +- **Type:** boolean +- **Default:** `false` +- **Status:** 🟢 ACTIVE +- **Description:** Count host-injected `custom_message` entries (except UI-only `acp-status` panels) as turn boundaries for all per-turn ledgers. Does not change LLM-context projection — those entries were already projected as user-role messages under Pi-native semantics. + +--- + ## Compression Tuning The `compress` sub-object groups the three thresholds that form a **three-tier escalation** for context management. They control *when* the model is nudged to compress and *when* large outputs are forcibly truncated to keep the session alive. Lower thresholds mean the extension compresses earlier and more aggressively. diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 8db5539..acab0fa 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -104,6 +104,7 @@ | `throttleRetry` | boolean \| object | `true` | 🟢 ACTIVE | 自动重试 provider 侧 token 限流错误(递进退避)。 | | `repetitionGuard` | boolean \| object | `true` | 🟢 ACTIVE | 打断字节级完全相同的工具调用死循环(连续 3 次告警,连续 5 次拦截并中止本轮)。 | | `degenerationGuard` | boolean \| object | `true` | 🟢 ACTIVE | 折叠出站视图中 assistant text/thinking 里的单字符退化连击(如 4655×「【」)并注入一次性恢复通知——打破 pi 每轮请求都回传退化 thinking 导致的连环 abort 死循环(#351)。 | +| `hostSession` | boolean \| object | `false` | 🟢 ACTIVE | 多会话宿主的回合边界策略:是否把注入的 `custom_message` 计为回合起点。默认关闭(pi 原生行为)。 | **delegate 键** @@ -477,6 +478,38 @@ --- +## 宿主多会话 + +`hostSession` 键控制**回合边界判定**,面向在单进程内运行多个会话的宿主(如 Prime 的内联 RLM 子/兄弟会话)。完整契约——包括子会话状态派生(`deriveChildState`)——见 **[docs/host-adapter.md](./docs/host-adapter.md)**。 + +**背景。** ACP 的按回合账本(nudge 已展示追踪、compress 重试上限、结果归口)以"当前回合起点"为键。pi 原生语义下,只有真正的 user-role 消息开启新回合。内联多会话宿主还会把 agent 回合以 `custom_message` 条目注入会话日志;这些条目会进入 LLM 上下文(pi 原生投影),但在默认策略下**不开启回合**——多个真实宿主回合塌缩进同一个 turnKey:nudge 节奏格子错位、按回合 compress 重试上限跨回合失真、节流/溢出周期统计失真。适配器中所有回合边界判定都走同一谓词 `isTurnBoundary(entry, policy)`(`src/turn-boundary.ts`)。 + +### `hostSession` + +- **类型:** boolean \| object +- **默认值:** `false`(关闭) +- **状态:** 🟢 ACTIVE +- **说明:** 宿主注入消息的回合边界策略。`hostSession: true` 等价于 `{ "countCustomMessages": true }`。object 形式(任意子集): + + ```json + { + "hostSession": { + "countCustomMessages": true + } + } + ``` + + **默认关闭保证存量单会话行为逐字节不变**——只有当你的宿主确实向会话日志注入 agent 回合时才启用。 + +### `hostSession.countCustomMessages` + +- **类型:** boolean +- **默认值:** `false` +- **状态:** 🟢 ACTIVE +- **说明:** 把宿主注入的 `custom_message` 条目(UI-only 的 `acp-status` 面板除外)计为所有按回合账本的回合起点。不改变 LLM 上下文投影——这些条目的 user-role 投影本就是 pi 原生行为。 + +--- + ## 压缩调优 `compress` 子对象包含三个阈值,构成上下文管理的**三级递进**。它们控制模型*何时*被 nudge 压缩,以及大输出*何时*被强制截断以维持会话存活。阈值越低,扩展压缩得越早、越激进。 diff --git a/docs/host-adapter.md b/docs/host-adapter.md new file mode 100644 index 0000000..cb274cb --- /dev/null +++ b/docs/host-adapter.md @@ -0,0 +1,138 @@ +# Host Adapter Contract — Multi-Session Hosts + +Audience: hosts that embed Pi **in-process** and run several sessions concurrently in one +process (e.g. Prime with inline RLM sub/sibling sessions). Single-session users of Pi need +nothing from this document — every contract here defaults to Pi-native behavior. + +Issue: [ranxianglei/billion-context-pi#364](https://github.com/ranxianglei/billion-context-pi/issues/364) +(leftover seams 1 + 2 of the #317 Prime integration report; ledger isolation itself was fixed by #327). + +--- + +## 1. Turn-boundary policy + +### The single predicate + +"Which entry starts a new turn?" is decided in exactly one place — +`isTurnBoundary(entry, policy)` in `src/turn-boundary.ts`. All three former call sites go +through it (and through its two scan helpers `lastTurnBoundaryId` / `lastTurnBoundaryIndex`): + +| Former site | Used for | +|---|---| +| per-turn token estimation key (`src/tokens.ts`) | per-turn token bookkeeping | +| context transform (`src/index.ts`) | `turnKey` for nudge-shown ledgers + compress-outcome scoping | +| compress tool (`src/compress-tool.ts`) | retry-cap key (`MAX_COMPRESS_ATTEMPTS = 3` per turn) | + +The context-entry projection in `src/messages.ts` shares the same building block +(`isCustomMessageEntry`) so "what enters LLM context" and "what counts as a host-injected +message" can never drift apart again. + +### Rules + +1. A genuine **user-role message always starts a turn** (Pi-native; unaffected by policy). +2. Assistant / toolResult / compaction / branch-summary entries never start a turn. +3. Host-injected `custom_message` entries start a turn **only when the policy opts in**. + UI-only `acp-status` panels (the `/acp` slash-command output) are excluded even under + the opt-in — they never enter LLM context either. +4. LLM-context projection is **independent of this policy**: `custom_message` entries were + and remain projected as user-role messages (Pi-native semantics). The policy only changes + *turn accounting*, not what the model sees. + +### Enabling the policy + +```json +{ "hostSession": true } +``` +or equivalently +```json +{ "hostSession": { "countCustomMessages": true } } +``` +in `~/.pi/acp.json` / `/.pi/acp.json`, or programmatically on the adapter config +passed to `createAcpExtension(adapter)`. Invalid values warn and fall back to off; they +never fail a session. + +**Default-off guarantee:** with no `hostSession` key the predicate reduces to the exact +pre-#364 rule (user-role only). This is pinned by unit tests that compare against the +legacy scan verbatim — existing single-session users' nudge cadence is byte-for-byte +unchanged. + +**Who should enable it:** inline multi-session hosts whose agent turns arrive as injected +`custom_message` entries. Without it, N real host turns collapse into one `turnKey`, so +nudge cells misalign, the per-turn compress retry cap spans multiple real turns, and +throttle/overflow cycle statistics distort. + +--- + +## 2. Child-session state derivation (`deriveChildState`) + +### Two kinds of child sessions — different rules + +| Kind | How pi tracks it | Adapter behavior | +|---|---|---| +| **Separate-process delegate** (Pi-native sub-agents) | child session file's JSONL header carries `parentSession` | On load, the adapter inherits the parent's state **verbatim** (blocks *and* rhythm). **Unchanged by #364.** Do NOT call `deriveChildState` for these. | +| **Inline same-process child** (Prime RLM & co.) | whatever the host creates; may or may not carry a header | Fresh state by default. Call `deriveChildState` once to inherit blocks with reset rhythm. | + +Why derive at all for inline children? If the child starts empty, `decompress` and +`search_context` cannot find any block the parent already created — yet the model sees +parent-created refs in inherited context. Deriving fixes retrieval without copying the +parent's pacing clocks. + +### The contract + +Inherited (child can decompress/search everything the parent could): + +| Field | Semantics | +|---|---| +| `blocks` | deep-copied (blocks carry mutable fields — the copy must not alias the parent) | +| `messageRefs` (`byRaw` / `byRef`) | copied | +| `tokenSnapshot` (original-message index) | copied | +| `nextBlockId` / `nextRunId` | **carried over** — resetting them would make new child block ids collide with inherited ones | + +Reset (the child starts its own clock): + +| Ledger | Where it lives | +|---|---| +| nudge cadence (`baselineTokens`, `lastShownByTier`, anchors, per-message stamps) | persisted state `nudge` | +| stats counters (`tokensCompressed`, `compressionCount`, absorbed tokens) | persisted state `stats` | +| absorb records | persisted state `absorbed` (records reference parent-log toolCallIds absent from the child log) | +| nudge-shown turns, compress-failure tracking, throttle episodes, overflow episodes, token-scale trackers | runtime maps keyed by session id — never copied across sessions | + +### API surfaces + +1. **Pure transformation** — `deriveChildState(parentState)` exported from `src/state.js`: + `CompressionState → CompressionState`. For hosts that manage state objects themselves. +2. **Orchestrated** — `runtime.deriveChildState(childRef, parentRef) → Promise` on + the extension runtime, where a ref is `{ sessionId: string; sessionFile?: string }`. + It loads the parent state, applies the pure transformation, writes the one-time marker, + and persists to the child sidecar. Because it operates on on-disk sidecars through + session refs (no live contexts needed), it works even when the two sessions belong to + different runtime instances. + +Host-side usage (once, before the child's first context event): + +```ts +await runtime.deriveChildState( + { sessionId: childSm.getSessionId(), sessionFile: childSm.getSessionFile() ?? undefined }, + { sessionId: parentSm.getSessionId(), sessionFile: parentSm.getSessionFile() ?? undefined }, +); +``` + +### Guards (all return `false`, change nothing) + +- **One-time marker**: the derived sidecar persists `derivedFrom: { parentSessionId, derivedAt }`. + Any later call (same or new process) refuses — re-derivation would clobber blocks the + child created in the meantime. +- **Child owns real blocks**: if the child sidecar already contains non-derived blocks, + derivation refuses — self-compressed history is never overwritten. +- **Parent has no blocks**: nothing to inherit. +- **File-less child**: in-memory sessions have no sidecar to persist the derivation into. +- **Explicit beats implicit**: if the child's JSONL header declares `parentSession`, plain + loads auto-inherit the parent verbatim (old behavior). An explicit `deriveChildState` + call upgrades that implicit state to inherit-blocks/reset-rhythm exactly once. + +### Persistence + +The child keeps its own independent sidecar — `~/.pi/agent/sessions/.acp.json` +— written atomically like every other sidecar. The parent file is never touched. Subsequent +loads of the child read the derived sidecar normally (marker included); no further action +required. diff --git a/src/commands.ts b/src/commands.ts index 2d254e2..07d517a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand, SessionEntry } from "@earendil-works/pi-coding-agent"; import type { AcpRuntime } from "./runtime.js"; -import { ACP_STATUS_CUSTOM_TYPE } from "./messages.js"; +import { ACP_STATUS_CUSTOM_TYPE } from "./turn-boundary.js"; import { defaultCountTokens, parseBlockIdArg, collectBlockContent } from "acp-kernel"; import { getSystemPromptText } from "./compat.js"; import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 542e117..bf79e02 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -7,7 +7,9 @@ import type { import type { AcpRuntime } from "./runtime.js"; import { MAX_COMPRESS_ATTEMPTS } from "./runtime.js"; import { debug, logError, logInfo, logThrow, logWarn } from "./log.js"; -import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, lastUserMessageId, adjustedTokenCount } from "./tokens.js"; +import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; +import { lastTurnBoundaryId } from "./turn-boundary.js"; +import { resolveHostSession } from "./config.js"; import { defaultCountTokens, parseCompressArgs, viableRanges, formatRanges, type CompressionBlock, type CompressionState, type CompressParseDiagnostics, type NudgeDecision } from "acp-kernel"; import { countUnicodeEscapes, findUnverifiableUserQuote, sanitizeSummary } from "./summary-sanitize.js"; import { getSystemPromptText } from "./compat.js"; @@ -339,7 +341,7 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte } return s.text === r.summary ? r : { ...r, summary: s.text }; }); - const turnKey = lastUserMessageId(entries) ?? sid; + const turnKey = lastTurnBoundaryId(entries, resolveHostSession(runtime.adapter)) ?? sid; const snapshot = compressibleSnapshotText(turn.nudge); if (runtime.compressRetryCappedFor(sid, turnKey)) { logWarn("compress", { sid, event: "capped-reject", turnKey }); diff --git a/src/config.ts b/src/config.ts index 4314281..457bda0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -242,15 +242,23 @@ export interface AdapterConfig { * warn=3, abort=5. Stops greedy small models looping on byte-identical * tool calls (issue #308). */ repetitionGuard?: boolean | RepetitionGuardConfig; - /** Character-level degenerate-repeat guard (see DegenerationGuardConfig). - * Collapses long single-codepoint runs (e.g. 4655×「【」) in assistant - * text/thinking of the outgoing view and injects a one-shot recovery notice - * after a degenerated turn — breaking the abort loop where pi replays the - * degenerated thinking back to the provider on every request (issue #351). - * Distinct from `repetitionGuard`, which is tool-call level. Accepts a - * boolean shorthand (`false` disables) or an object. Default: enabled, - * minRun=200. */ - degenerationGuard?: boolean | DegenerationGuardConfig; + /** Character-level degenerate-repeat guard (see DegenerationGuardConfig). + * Collapses long single-codepoint runs (e.g. 4655×「【」) in assistant + * text/thinking of the outgoing view and injects a one-shot recovery notice + * after a degenerated turn — breaking the abort loop where pi replays the + * degenerated thinking back to the provider on every request (issue #351). + * Distinct from `repetitionGuard`, which is tool-call level. Accepts a + * boolean shorthand (`false` disables) or an object. Default: enabled, + * minRun=200. */ + degenerationGuard?: boolean | DegenerationGuardConfig; + /** Host multi-session turn-boundary policy (#364). Accepts a boolean + * shorthand (`true` → count host-injected custom_message entries as turn + * boundaries) or a HostSessionConfig object. Default: off — pi-native + * behavior where only genuine user-role messages start a turn, so existing + * single-session users' nudge cadence is unchanged. Enable for inline + * multi-session hosts (Prime RLM & co.) whose injected agent messages must + * delimit real turns. See docs/host-adapter.md. */ + hostSession?: boolean | HostSessionConfig; /** Legacy flat alias for `delegate.displayUsage`. Kept for backward * compatibility with existing acp.json files. Prefer `delegate.displayUsage`. */ displayUsage?: "merged" | "separate"; @@ -366,6 +374,37 @@ export function resolveRepetitionGuard(adapter: AdapterConfig): { enabled: boole return { enabled: true, warn: REPETITION_GUARD_DEFAULTS.warn, abort: REPETITION_GUARD_DEFAULTS.abort }; } +/** Host multi-session turn-boundary policy (#364). See TurnBoundaryPolicy in + * src/turn-boundary.ts for the semantics this resolves. */ +export interface HostSessionConfig { + /** Count host-injected custom_message entries (agent_message) as turn + * boundaries. Default: false (pi-native behavior). */ + countCustomMessages?: boolean; +} + +export interface ResolvedHostSession { + countCustomMessages: boolean; +} + +/** Resolve the host-session turn-boundary policy from the adapter, handling + * the boolean shorthand (`true` enables countCustomMessages). Invalid values + * fall back to the pi-native default (off) with a logged warning — they never + * fail the session. */ +export function resolveHostSession(adapter: AdapterConfig): ResolvedHostSession { + const h = adapter.hostSession; + if (h === true) return { countCustomMessages: true }; + if (h && typeof h === "object") { + if (typeof h.countCustomMessages !== "boolean") { + logWarn("config", { event: "host-session-invalid", field: "countCustomMessages", value: String(h.countCustomMessages), fallback: "false" }); + } + return { countCustomMessages: h.countCustomMessages === true }; + } + if (h !== undefined && h !== false) { + logWarn("config", { event: "host-session-invalid", value: String(h), fallback: "off" }); + } + return { countCustomMessages: false }; +} + /** Per-field deepest-wins merge of the three compression levels (global → * provider → model). An undefined field at a deeper level does NOT clear a * value set at a shallower level — only a defined value overrides. */ diff --git a/src/index.ts b/src/index.ts index 3fe4c96..b54bbdc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { CoreMessage, NudgeDecision, CompressionBlock, Prompts } from "acp-kernel"; import { renderNudgeText, resolvePrompts, defaultPrompts, viableRanges } from "acp-kernel"; -import { type AdapterConfig, resolveDelegate, DEFAULT_DELEGATE_POLICY } from "./config.js"; +import { type AdapterConfig, resolveDelegate, resolveHostSession, DEFAULT_DELEGATE_POLICY } from "./config.js"; import { createRuntime, type AcpRuntime } from "./runtime.js"; import { makeCompressTool, isCompressSuccessText, isCompressNoopText } from "./compress-tool.js"; import { makeDecompressTool } from "./decompress-tool.js"; @@ -26,7 +26,8 @@ import { delegateStatusWidget } from "./fleet-widget.js"; import { openFleetInspector } from "./fleet-inspector.js"; import { wireToolGuardrails } from "./tool-guardrails.js"; import { debug, logError, logInfo, logWarn, logThrow, closeLogStream } from "./log.js"; -import { collectCoveredMessageIds, estimateTokens, lastUserMessageId, collectImageTokens, modelSupportsImages, sentViewTokenCount } from "./tokens.js"; +import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, sentViewTokenCount } from "./tokens.js"; +import { lastTurnBoundaryId, lastTurnBoundaryIndex } from "./turn-boundary.js"; import { usageAnchorPredatesCompression } from "./floor-stale.js"; import { checkForUpdate } from "./update.js"; import { @@ -464,7 +465,10 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf } const debugOn = debug.enabled; - const turnKey = lastUserMessageId(entries) ?? sid; + // #364: one policy for all turn-boundary decisions this event (turnKey + + // outcome scoping); default-off keeps pi-native boundaries. + const turnPolicy = resolveHostSession(runtime.adapter); + const turnKey = lastTurnBoundaryId(entries, turnPolicy) ?? sid; // Compress-outcome tracking feeds ONLY the nudge circuit breaker below: // failed/no-op attempts are counted (capped at MAX_COMPRESS_ATTEMPTS per @@ -476,7 +480,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // CURRENT user turn are considered; processed BEFORE the nudge block so // the cap suppression sees the newest outcome (a success on this fire // must lift the cap on this same fire). - const compressOutcomes = collectCompressOutcomes(entries, turnStartIndex(entries)); + const compressOutcomes = collectCompressOutcomes(entries, lastTurnBoundaryIndex(entries, turnPolicy)); const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(sid, turnKey, compressOutcomes) : null; // Growth-aware re-inject bookkeeping (issue #269) runs on EVERY context @@ -734,16 +738,6 @@ function collectOriginals(entries: Array<{ type: string; id: string; message?: A return map; } -// Index of the last user-role entry — the start of the current turn. -// Everything strictly AFTER this index belongs to the current turn; -1 when -// the session has no user message yet. -function turnStartIndex(entries: Array<{ type: string; message?: { role?: string } }>): number { - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i]!.message?.role === "user") return i; - } - return -1; -} - // Compress toolResults from the CURRENT user turn only — the raw material for // the nudge circuit breaker above. Scoping matters: feeding the whole session // would keep an old failure counting against the current turn's budget diff --git a/src/messages.ts b/src/messages.ts index 7928415..7fbbc19 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,6 +1,7 @@ import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; import { defaultCountTokens, type CoreMessage } from "acp-kernel"; import { rewriteTagTokens } from "./tag-tokens.js"; +import { ACP_STATUS_CUSTOM_TYPE, isCustomMessageEntry } from "./turn-boundary.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -18,17 +19,13 @@ const REF_TAG_SOURCE = "(?:\x3cacp\\s[^>]*\x3em\\d{5}\x3c/acp\x3e|\\[m\\d{1,5}\\ const REF_TAG = new RegExp(`^${REF_TAG_SOURCE}\\s?\\n?`); const TRAILING_REF_TAG = new RegExp(`\\n*${REF_TAG_SOURCE}\\s*$`); -// /acp panels are UI-only transcript output (issue #255): persistent in the -// session, but never projected into the sent view. -export const ACP_STATUS_CUSTOM_TYPE = "acp-status"; - export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] { const out: CoreMessage[] = []; for (const entry of entries) { if (entry.type !== "message") { // custom_message participates in LLM context per Pi native semantics // (session-manager.d.ts) — project it as a user message. - if (entry.type === "custom_message" && entry.customType !== ACP_STATUS_CUSTOM_TYPE) { + if (isCustomMessageEntry(entry)) { const text = extractText(entry.content); if (text.length > 0) { out.push({ id: entry.id, role: "user", contentType: "text", text }); diff --git a/src/runtime.ts b/src/runtime.ts index ff44632..cbbe9c3 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,4 +1,5 @@ import type { ExtensionContext, SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; +import { readFileSync } from "node:fs"; import { createCore, defaultCountTokens, @@ -11,7 +12,7 @@ import { import { resolveCompress, resolveConfig, type AdapterConfig } from "./config.js"; import { resolveReasoningDrop, type CompressReasoningConfig } from "./reasoning-drop.js"; import { entriesToCoreMessages, extractText, matchesStoredText, messageIdentity, messageRef } from "./messages.js"; -import { SessionStateStore, type LiveRefOrigin } from "./state.js"; +import { SessionStateStore, deriveChildState, type LiveRefOrigin } from "./state.js"; import { hasCompressHistory, rebuildStateFromLog } from "./state-rebuild.js"; import { loadUserConfig, applyUserConfig } from "./user-config.js"; import { ThrottleEpisode } from "./throttle-retry.js"; @@ -40,6 +41,14 @@ export function isPiHost(sm: ExtensionContext["sessionManager"]): boolean { return typeof source.buildContextEntries === "function"; } +/** Minimal identity of a session for state operations that don't need a live + * ExtensionContext (hosts deriving inline child sessions build these from + * whatever session handles they hold). */ +export interface SessionRef { + sessionId: string; + sessionFile?: string; +} + export interface AcpRuntime { core: CompressionCore; /** Set when the host is unsupported (currently: OMP / oh-my-pi) or when the @@ -103,6 +112,16 @@ export interface AcpRuntime { reloadConfig(cwd: string): Promise; stateFor(ctx: ExtensionContext, liveMessages?: AgentMessage[]): Promise<{ state: CompressionState; coreMessages: ReturnType; entries: SessionEntry[] }>; save(state: CompressionState, ctx: ExtensionContext): Promise; + /** #364 inline child sessions (same process, e.g. Prime RLM): derive the + * child's compression state from another session's. Inherits blocks / + * message refs / token snapshot so decompress + search_context keep working + * on inherited blocks; resets every rhythm ledger (nudge cadence, stats, + * absorb). One-time: writes a derivation marker into the child sidecar and + * refuses to run again; also refuses when the child already owns real + * (non-derived) blocks or when the parent has no blocks. Separate-process + * pi-native delegates must NOT call this — their parentSession header + * already inherits verbatim. Returns true when the child state was derived. */ + deriveChildState(child: SessionRef, parent: SessionRef): Promise; acquireLock(sid: string): Promise<() => void>; /** Per-session overflow self-heal state (learned window + armed emergency). * Keyed by session id so concurrent sessions cannot share an episode. */ @@ -496,6 +515,36 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { await store.save(state, sm.getSessionFile() ?? undefined, sm.getSessionId()); } + // Own-sidecar check (not cache/load) because load() may have already filled + // the slot via implicit parentSession-header inheritance — that implicit + // state is replaceable by an explicit derivation, but real self-compressed + // blocks are not. + function ownSidecarHasBlocks(sessionFile: string): boolean { + try { + const parsed = JSON.parse(readFileSync(`${sessionFile}.acp.json`, "utf8")) as { blocks?: unknown }; + return Array.isArray(parsed.blocks) && parsed.blocks.length > 0; + } catch { + return false; + } + } + + async function deriveChild(child: SessionRef, parent: SessionRef): Promise { + if (!child.sessionFile) return false; + if (ownSidecarHasBlocks(child.sessionFile)) return false; + // Materialize the child cache slot (also surfaces any implicit header + // inheritance or a marker persisted by an earlier process) so the marker + // below has a slot to attach to and save() persists it. + await store.load(child.sessionFile, child.sessionId); + if (store.getDerivedFrom(child.sessionFile, child.sessionId)) return false; + const parentState = await store.load(parent.sessionFile, parent.sessionId); + if (parentState.blocks.length === 0) return false; + const derived = deriveChildState(parentState); + store.setDerivedFrom(child.sessionFile, child.sessionId, { parentSessionId: parent.sessionId, derivedAt: Date.now() }); + await store.save(derived, child.sessionFile, child.sessionId); + logInfo("state", { sid: child.sessionId, event: "child-state-derived", parentSid: parent.sessionId, blocks: derived.blocks.length }); + return true; + } + let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown, nudgeShownFor, nudgeShownTokensFor, clearNudgeTracking, clearNudgeTokenStamps, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown, nudgeShownFor, nudgeShownTokensFor, clearNudgeTracking, clearNudgeTokenStamps, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, deriveChildState: deriveChild, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} diff --git a/src/state.ts b/src/state.ts index 7d9ba13..bd246cb 100644 --- a/src/state.ts +++ b/src/state.ts @@ -10,9 +10,18 @@ export interface LiveRefOrigin { identity: string; } +/** One-time marker persisted in a child sidecar when deriveChildState ran + * (#364): proves this session's state was explicitly derived from a parent, + * so later loads never re-derive. */ +export interface DerivedFrom { + parentSessionId: string; + derivedAt: number; +} + interface StateCacheSlot { state: CompressionState; liveRefOrigins: LiveRefOrigin[]; + derivedFrom: DerivedFrom | null; } function stateFileFor(sessionFile: string | undefined): string | null { @@ -57,13 +66,15 @@ export class SessionStateStore { if (cached) return cached.state; let state = createInitialState(); let liveRefOrigins: LiveRefOrigin[] = []; + let derivedFrom: DerivedFrom | null = null; if (file) { try { const raw = await fs.readFile(file, "utf8"); - const parsed = JSON.parse(raw) as CompressionState & { liveRefOrigins?: unknown }; + const parsed = JSON.parse(raw) as CompressionState & { liveRefOrigins?: unknown; derivedFrom?: unknown }; if (parsed && Array.isArray(parsed.blocks)) { state = mergeInitialState(parsed); liveRefOrigins = parseLiveRefOrigins(parsed.liveRefOrigins); + derivedFrom = parseDerivedFrom(parsed.derivedFrom); } } catch (e) { const code = (e as NodeJS.ErrnoException).code; @@ -80,19 +91,21 @@ export class SessionStateStore { if (parentState) state = parentState; } } - this.cache.set(key, { state, liveRefOrigins }); + this.cache.set(key, { state, liveRefOrigins, derivedFrom }); return state; } async save(state: CompressionState, sessionFile: string | undefined, sessionId: string): Promise { const file = stateFileFor(sessionFile); const key = cacheKey(sessionFile, sessionId); - const liveRefOrigins = this.cache.get(key)?.liveRefOrigins ?? []; + const prev = this.cache.get(key); + const liveRefOrigins = prev?.liveRefOrigins ?? []; + const derivedFrom = prev?.derivedFrom ?? null; // Cache update is unconditional: file-less (in-memory) sessions have no // sidecar to persist, but their state must still survive across turns in // this process — otherwise every compress result is dropped and the model // re-compresses the same original context forever (issue #322). - this.cache.set(key, { state, liveRefOrigins }); + this.cache.set(key, { state, liveRefOrigins, derivedFrom }); if (!file) return; const dir = path.dirname(file); await fs.mkdir(dir, { recursive: true }).catch((e: unknown) => { @@ -100,7 +113,9 @@ export class SessionStateStore { }); const tmp = path.join(dir, `.acp-tmp-${path.basename(file)}`); try { - await fs.writeFile(tmp, JSON.stringify({ ...state, liveRefOrigins }), "utf8"); + const payload: Record = { ...state, liveRefOrigins }; + if (derivedFrom) payload.derivedFrom = derivedFrom; + await fs.writeFile(tmp, JSON.stringify(payload), "utf8"); await fs.rename(tmp, file); } catch (e) { logError("state", { event: "save-failed", file, error: e instanceof Error ? e.message : String(e) }); @@ -114,7 +129,17 @@ export class SessionStateStore { setLiveRefOrigins(sessionFile: string | undefined, sessionId: string, origins: LiveRefOrigin[]): void { const key = cacheKey(sessionFile, sessionId); const slot = this.cache.get(key); - if (slot) this.cache.set(key, { state: slot.state, liveRefOrigins: [...origins] }); + if (slot) this.cache.set(key, { state: slot.state, liveRefOrigins: [...origins], derivedFrom: slot.derivedFrom }); + } + + getDerivedFrom(sessionFile: string | undefined, sessionId: string): DerivedFrom | null { + return this.cache.get(cacheKey(sessionFile, sessionId))?.derivedFrom ?? null; + } + + setDerivedFrom(sessionFile: string | undefined, sessionId: string, mark: DerivedFrom | null): void { + const key = cacheKey(sessionFile, sessionId); + const slot = this.cache.get(key); + if (slot) this.cache.set(key, { state: slot.state, liveRefOrigins: slot.liveRefOrigins, derivedFrom: mark }); } invalidate(): void { @@ -150,6 +175,36 @@ export class SessionStateStore { } } +function parseDerivedFrom(value: unknown): DerivedFrom | null { + if (!value || typeof value !== "object") return null; + const mark = value as { parentSessionId?: unknown; derivedAt?: unknown }; + if (typeof mark.parentSessionId !== "string" || typeof mark.derivedAt !== "number") return null; + return { parentSessionId: mark.parentSessionId, derivedAt: mark.derivedAt }; +} + +/** #364: derive an INLINE child session's compression state from its parent's + * (same-process sub-sessions, e.g. Prime RLM). Inherits exactly what makes + * inherited blocks usable — blocks (deep-copied: they carry mutable fields), + * message refs, the per-message token snapshot, and the id counters so new + * child blocks cannot collide with inherited ids — and resets every rhythm + * ledger (nudge cadence baseline, stats counters, absorb records) so the + * child starts its own clock. Separate-process pi-native delegates must NOT + * use this: their session files carry a parentSession header that already + * inherits the parent state verbatim. */ +export function deriveChildState(parent: CompressionState): CompressionState { + const fresh = createInitialState(); + return { + blocks: structuredClone(parent.blocks), + messageRefs: { byRaw: { ...parent.messageRefs.byRaw }, byRef: { ...parent.messageRefs.byRef } }, + tokenSnapshot: { ...parent.tokenSnapshot }, + nudge: fresh.nudge, + stats: fresh.stats, + absorbed: [], + nextBlockId: parent.nextBlockId, + nextRunId: parent.nextRunId, + }; +} + function parseLiveRefOrigins(value: unknown): LiveRefOrigin[] { if (!Array.isArray(value)) return []; return value.filter((item): item is LiveRefOrigin => { diff --git a/src/tokens.ts b/src/tokens.ts index 361e1cb..5550c37 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -93,12 +93,4 @@ export function adjustedTokenCount( return view.drifted ? view.viewTokens : prelim; } -/** Id of the last user-role entry — used as a per-turn key so a nudge prints at - * most once per turn. Returns undefined if there is no user message yet. */ -export function lastUserMessageId(entries: { id: string; message?: { role?: string } }[]): string | undefined { - for (let i = entries.length - 1; i >= 0; i--) { - const e = entries[i]!; - if (e.message?.role === "user") return e.id; - } - return undefined; -} + diff --git a/src/turn-boundary.ts b/src/turn-boundary.ts new file mode 100644 index 0000000..6366bbb --- /dev/null +++ b/src/turn-boundary.ts @@ -0,0 +1,73 @@ +/** Single source of truth for turn-boundary decisions (#364). + * + * "What counts as a new-turn start" used to be decided independently in three + * places (per-turn token estimation, turnKey scoping, context-entry + * projection) and disagreed on host-injected custom_message entries: they + * enter LLM context but started no turn, so multiple real turns collapsed + * into one turnKey — nudge ledger cells misaligned, retry-cap and throttle + * cycle stats distorted, reasoning-drop closing against the wrong turn. + * Every turn-boundary check in the adapter goes through isTurnBoundary. + */ + +/** Host-injected status panel custom messages (src/commands.ts) — UI-only, + * never projected into LLM context, so they never count as user-like entries. */ +export const ACP_STATUS_CUSTOM_TYPE = "acp-status"; + +/** Minimal structural shape of a session-log entry for boundary checks. Pi's + * SessionEntry and the narrower arrays used by token accounting both satisfy + * it without casts. */ +export interface TurnBoundaryEntry { + type?: string; + id?: string; + customType?: string; + content?: unknown; + message?: { role?: string }; +} + +/** Host multi-session policy (#364). Unset/false = pi-native behavior: only + * genuine user-role messages start a turn. Inline multi-session hosts (Prime + * RLM & co.) inject agent turns as custom_message entries; set + * countCustomMessages to make those delimit turns too. Default-off keeps + * standalone pi users' nudge cadence unchanged. */ +export interface TurnBoundaryPolicy { + countCustomMessages?: boolean; +} + +/** True for host-injected custom_message entries that participate in LLM + * context — every custom_message except the UI-only acp-status panels. + * Type guard so callers keep narrowing the session-entry union (shared by + * the context projection in src/messages.ts and the policy-aware boundary + * check below so the two can never drift apart). */ +export function isCustomMessageEntry(entry: TurnBoundaryEntry): entry is TurnBoundaryEntry & { type: "custom_message" } { + return entry.type === "custom_message" && entry.customType !== ACP_STATUS_CUSTOM_TYPE; +} + +/** The ONE turn-boundary predicate (#364): does this entry start a new turn? + * Genuine user-role messages always do (pi-native); host-injected + * custom_message entries do only when the host opts in via policy. */ +export function isTurnBoundary(entry: TurnBoundaryEntry, policy: TurnBoundaryPolicy = {}): boolean { + if (entry.message?.role === "user") return true; + return policy.countCustomMessages === true && isCustomMessageEntry(entry); +} + +/** Id of the last turn-boundary entry — the per-turn key for nudge + * accounting, retry caps and outcome scoping. Defaults to pi-native + * boundaries (user-role only); pass a host policy to also count injected + * custom_message entries. Returns undefined when no boundary exists yet. */ +export function lastTurnBoundaryId(entries: readonly TurnBoundaryEntry[], policy?: TurnBoundaryPolicy): string | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]!; + if (isTurnBoundary(e, policy)) return e.id; + } + return undefined; +} + +/** Index of the last turn-boundary entry — the start of the current turn for + * compress-outcome scoping. Same policy semantics as lastTurnBoundaryId; + * -1 when no boundary has been seen yet. */ +export function lastTurnBoundaryIndex(entries: readonly TurnBoundaryEntry[], policy?: TurnBoundaryPolicy): number { + for (let i = entries.length - 1; i >= 0; i--) { + if (isTurnBoundary(entries[i]!, policy)) return i; + } + return -1; +} diff --git a/src/user-config.ts b/src/user-config.ts index 04613cb..c09f756 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -3,7 +3,7 @@ import * as path from "node:path"; import { homedir } from "node:os"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import type { Prompts } from "acp-kernel"; -import type { AdapterConfig, CompressConfig, DelegateConfig, RepetitionGuardConfig } from "./config.js"; +import type { AdapterConfig, CompressConfig, DelegateConfig, HostSessionConfig, RepetitionGuardConfig } from "./config.js"; import type { DegenerationGuardConfig } from "./degeneration.js"; import type { ThrottleRetryConfig } from "./throttle-retry.js"; import { debug, logWarn } from "./log.js"; @@ -27,6 +27,7 @@ export interface UserAcpConfig { displayUsage?: "merged" | "separate"; prompts?: Partial; acknowledgePromptsRisk?: boolean; + hostSession?: boolean | HostSessionConfig; } /** Read global + project acp.json, project overrides global. Returns {} on any @@ -64,6 +65,7 @@ const KNOWN = new Set([ "outputHeadroomMaxPct", "repetitionGuard", "degenerationGuard", "prompts", "acknowledgePromptsRisk", + "hostSession", ]); function pickKnown(parsed: Record): UserAcpConfig { diff --git a/tests/config.test.ts b/tests/config.test.ts index c962832..0525a44 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { resolveConfig, resolveCompress, mergeCompress, resolveDelegate, resolveRepetitionGuard, REPETITION_GUARD_DEFAULTS, type AdapterConfig } from "../src/config.js"; +import { resolveConfig, resolveCompress, mergeCompress, resolveDelegate, resolveRepetitionGuard, resolveHostSession, REPETITION_GUARD_DEFAULTS, type AdapterConfig } from "../src/config.js"; const EMPTY: AdapterConfig = {}; @@ -415,3 +415,22 @@ test("resolveRepetitionGuard keeps custom thresholds while honoring enabled:fals assert.equal(r.warn, 4); assert.equal(r.abort, 9); }); + +test("resolveHostSession defaults to pi-native (off) when unset or false", () => { + assert.deepEqual(resolveHostSession(EMPTY), { countCustomMessages: false }); + assert.deepEqual(resolveHostSession({ hostSession: false }), { countCustomMessages: false }); +}); + +test("resolveHostSession boolean true shorthand enables countCustomMessages", () => { + assert.deepEqual(resolveHostSession({ hostSession: true }), { countCustomMessages: true }); +}); + +test("resolveHostSession object form honors explicit values", () => { + assert.deepEqual(resolveHostSession({ hostSession: { countCustomMessages: true } }), { countCustomMessages: true }); + assert.deepEqual(resolveHostSession({ hostSession: { countCustomMessages: false } }), { countCustomMessages: false }); +}); + +test("resolveHostSession falls back to off for invalid values", () => { + assert.deepEqual(resolveHostSession({ hostSession: "yes" as unknown as boolean }), { countCustomMessages: false }); + assert.deepEqual(resolveHostSession({ hostSession: { countCustomMessages: "yes" as unknown as boolean } }), { countCustomMessages: false }); +}); diff --git a/tests/derive-child-state.test.ts b/tests/derive-child-state.test.ts new file mode 100644 index 0000000..8cb3bb2 --- /dev/null +++ b/tests/derive-child-state.test.ts @@ -0,0 +1,243 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { activeBlocks, blockById, createInitialState, type CompressionBlock, type CompressionState } from "acp-kernel"; +import { SessionStateStore, deriveChildState } from "../src/state.js"; +import { createRuntime } from "../src/runtime.js"; + +function tempDir(): Promise { + return mkdtemp(path.join(tmpdir(), "acp-child-")); +} + +function makeBlock(id: string, active = true): CompressionBlock { + return { blockId: id, runId: 0, tier: 1, generation: "young", active, summary: `summary ${id}`, directMessageIds: ["msg-a"], effectiveMessageIds: ["msg-a"], survivedCount: 1, createdAt: Date.now() }; +} + +function makeParentState(): CompressionState { + const s = createInitialState(); + s.blocks.push(makeBlock("b0"), makeBlock("b1", false)); + s.nextBlockId = 3; + s.nextRunId = 7; + s.messageRefs.byRaw["msg-a"] = "m00001"; + s.messageRefs.byRef["m00001"] = "msg-a"; + s.tokenSnapshot["m00001"] = 1234; + s.nudge.baselineTokens = 5000; + s.nudge.lastShownByTier[1] = 4000; + s.stats.tokensCompressed = 999; + s.stats.compressionCount = 2; + return s; +} + +test("deriveChildState inherits blocks deep-copied (no shared mutation)", () => { + const parent = makeParentState(); + const child = deriveChildState(parent); + + assert.equal(child.blocks.length, 2); + assert.deepEqual(child.blocks.map((b) => b.blockId), ["b0", "b1"]); + assert.equal(child.blocks[0]!.summary, "summary b0"); + assert.notEqual(child.blocks[0], parent.blocks[0], "blocks must be copied, not shared"); + child.blocks[0]!.survivedCount = 99; + assert.equal(parent.blocks[0]!.survivedCount, 1, "child mutation must not leak into parent"); + parent.blocks[1]!.active = false; + assert.equal(child.blocks[1]!.active, false); +}); + +test("deriveChildState inherits message refs and token snapshot as copies", () => { + const parent = makeParentState(); + const child = deriveChildState(parent); + + assert.equal(child.messageRefs.byRaw["msg-a"], "m00001"); + assert.equal(child.messageRefs.byRef["m00001"], "msg-a"); + assert.equal(child.tokenSnapshot["m00001"], 1234); + assert.notEqual(child.messageRefs, parent.messageRefs); + assert.notEqual(child.tokenSnapshot, parent.tokenSnapshot); + child.messageRefs.byRaw["msg-x"] = "m00002"; + assert.equal(parent.messageRefs.byRaw["msg-x"], undefined, "ref-map mutation must not leak into parent"); +}); + +test("deriveChildState carries the id counters so new blocks cannot collide", () => { + const child = deriveChildState(makeParentState()); + assert.equal(child.nextBlockId, 3); + assert.equal(child.nextRunId, 7); +}); + +test("deriveChildState resets every rhythm ledger", () => { + const child = deriveChildState(makeParentState()); + const fresh = createInitialState(); + assert.deepEqual(child.nudge, fresh.nudge, "nudge cadence baseline must restart"); + assert.deepEqual(child.stats, fresh.stats, "stats counters must restart"); + assert.deepEqual(child.absorbed, []); +}); + +test("inherited blocks stay usable by kernel lookups (decompress/search viability)", () => { + const child = deriveChildState(makeParentState()); + assert.deepEqual(activeBlocks(child).map((b) => b.blockId), ["b0"], "only inherited ACTIVE blocks surface"); + assert.equal(blockById(child, "b1")?.summary, "summary b1"); +}); + +test("empty parent derives to a pristine initial state", () => { + assert.deepEqual(deriveChildState(createInitialState()), createInitialState()); +}); + +// ── Runtime orchestration (marker + guards + persistence) ────────────────── + +async function writeSessionHeader(file: string, opts: { parentSession?: string } = {}) { + const header = { type: "session", version: 3, id: "test-sid", timestamp: new Date().toISOString(), cwd: "/tmp", ...opts }; + await writeFile(file, JSON.stringify(header) + "\n", "utf8"); +} + +test("runtime.deriveChildState: derives, persists marker, resets rhythm on disk", async () => { + const dir = await tempDir(); + const parentJsonl = path.join(dir, "parent.jsonl"); + const childJsonl = path.join(dir, "child.jsonl"); + await writeSessionHeader(parentJsonl); + await writeSessionHeader(childJsonl); + + const store = new SessionStateStore(); + await store.save(makeParentState(), parentJsonl, "parent-sid"); + const runtime = createRuntime({}); + + const derived = await runtime.deriveChildState( + { sessionId: "child-sid", sessionFile: childJsonl }, + { sessionId: "parent-sid", sessionFile: parentJsonl }, + ); + assert.equal(derived, true); + + const raw = JSON.parse(await readFile(`${childJsonl}.acp.json`, "utf8")) as { + blocks: unknown[]; + nextBlockId: number; + nudge: { baselineTokens: number }; + stats: { tokensCompressed: number }; + derivedFrom?: { parentSessionId: string; derivedAt: number }; + }; + assert.equal(raw.blocks.length, 2, "blocks persisted into the independent child sidecar"); + assert.equal(raw.nextBlockId, 3); + assert.equal(raw.nudge.baselineTokens, 0, "rhythm baseline reset in persisted state"); + assert.equal(raw.stats.tokensCompressed, 0); + assert.ok(raw.derivedFrom, "one-time derivation marker persisted"); + assert.equal(raw.derivedFrom.parentSessionId, "parent-sid"); + assert.equal(typeof raw.derivedFrom.derivedAt, "number"); + + const reloaded = new SessionStateStore(); + const state = await reloaded.load(childJsonl, "child-sid"); + assert.equal(state.blocks.length, 2); + assert.equal(state.nudge.baselineTokens, 0); + assert.equal(reloaded.getDerivedFrom(childJsonl, "child-sid")?.parentSessionId, "parent-sid"); + await rm(dir, { recursive: true, force: true }); +}); + +test("runtime.deriveChildState is one-time: second call refused", async () => { + const dir = await tempDir(); + const parentJsonl = path.join(dir, "parent.jsonl"); + const childJsonl = path.join(dir, "child.jsonl"); + await writeSessionHeader(parentJsonl); + await writeSessionHeader(childJsonl); + + const store = new SessionStateStore(); + await store.save(makeParentState(), parentJsonl, "parent-sid"); + const runtime = createRuntime({}); + const child = { sessionId: "child-sid", sessionFile: childJsonl }; + const parent = { sessionId: "parent-sid", sessionFile: parentJsonl }; + + assert.equal(await runtime.deriveChildState(child, parent), true); + assert.equal(await runtime.deriveChildState(child, parent), false, "marker present → no re-derivation"); + const state = await runtime.store.load(childJsonl, "child-sid"); + assert.equal(state.blocks.length, 2, "state untouched by the refused call"); + await rm(dir, { recursive: true, force: true }); +}); + +test("runtime.deriveChildState refuses when the child owns real blocks", async () => { + const dir = await tempDir(); + const parentJsonl = path.join(dir, "parent.jsonl"); + const childJsonl = path.join(dir, "child.jsonl"); + await writeSessionHeader(parentJsonl); + await writeSessionHeader(childJsonl); + + const store = new SessionStateStore(); + await store.save(makeParentState(), parentJsonl, "parent-sid"); + const own = createInitialState(); + own.blocks.push(makeBlock("b-own")); + own.nextBlockId = 2; + await store.save(own, childJsonl, "child-sid"); + + const runtime = createRuntime({}); + const derived = await runtime.deriveChildState( + { sessionId: "child-sid", sessionFile: childJsonl }, + { sessionId: "parent-sid", sessionFile: parentJsonl }, + ); + assert.equal(derived, false, "never clobber real self-compressed history"); + const state = await runtime.store.load(childJsonl, "child-sid"); + assert.deepEqual(state.blocks.map((b) => b.blockId), ["b-own"]); + assert.equal(runtime.store.getDerivedFrom(childJsonl, "child-sid"), null); + await rm(dir, { recursive: true, force: true }); +}); + +test("runtime.deriveChildState refuses when the parent has no blocks", async () => { + const dir = await tempDir(); + const parentJsonl = path.join(dir, "parent.jsonl"); + const childJsonl = path.join(dir, "child.jsonl"); + await writeSessionHeader(parentJsonl); + await writeSessionHeader(childJsonl); + + const store = new SessionStateStore(); + await store.save(createInitialState(), parentJsonl, "parent-sid"); + const runtime = createRuntime({}); + const derived = await runtime.deriveChildState( + { sessionId: "child-sid", sessionFile: childJsonl }, + { sessionId: "parent-sid", sessionFile: parentJsonl }, + ); + assert.equal(derived, false); + assert.equal(runtime.store.getDerivedFrom(childJsonl, "child-sid"), null); + await rm(dir, { recursive: true, force: true }); +}); + +test("runtime.deriveChildState refuses file-less (in-memory) children", async () => { + const dir = await tempDir(); + const parentJsonl = path.join(dir, "parent.jsonl"); + await writeSessionHeader(parentJsonl); + + const store = new SessionStateStore(); + await store.save(makeParentState(), parentJsonl, "parent-sid"); + const runtime = createRuntime({}); + const derived = await runtime.deriveChildState( + { sessionId: "in-memory-child" }, + { sessionId: "parent-sid", sessionFile: parentJsonl }, + ); + assert.equal(derived, false, "in-memory sessions have no sidecar to persist the derivation into"); + await rm(dir, { recursive: true, force: true }); +}); + +// The Prime RLM case: pi writes a parentSession header into inline sub-agent +// session files too, so load() auto-inherits the parent VERBATIM (old behavior, +// rhythm included). An explicit derivation must upgrade that implicit state to +// inherit-blocks/reset-rhythm semantics exactly once. +test("explicit derivation upgrades implicit header inheritance (inline sub-agent case)", async () => { + const dir = await tempDir(); + const parentJsonl = path.join(dir, "parent.jsonl"); + const childJsonl = path.join(dir, "child.jsonl"); + await writeSessionHeader(parentJsonl); + await writeSessionHeader(childJsonl, { parentSession: parentJsonl }); + + const store = new SessionStateStore(); + await store.save(makeParentState(), parentJsonl, "parent-sid"); + + const implicit = await store.load(childJsonl, "child-sid"); + assert.equal(implicit.blocks.length, 2, "header inheritance kicks in first (pi-native path)"); + assert.equal(implicit.nudge.baselineTokens, 5000, "verbatim inheritance carries parent rhythm — the #364 gap"); + + const runtime = createRuntime({}); + const derived = await runtime.deriveChildState( + { sessionId: "child-sid", sessionFile: childJsonl }, + { sessionId: "parent-sid", sessionFile: parentJsonl }, + ); + assert.equal(derived, true, "explicit derivation wins over implicit header inheritance"); + + store.invalidate(); + const after = await store.load(childJsonl, "child-sid"); + assert.equal(after.blocks.length, 2, "blocks still inherited"); + assert.equal(after.nudge.baselineTokens, 0, "rhythm restarted"); + assert.equal(store.getDerivedFrom(childJsonl, "child-sid")?.parentSessionId, "parent-sid"); + await rm(dir, { recursive: true, force: true }); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts index f33c91f..09d62ff 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { entriesToCoreMessages, coreOutToAgentMessages, matchesStoredText, messageIdentity, ACP_STATUS_CUSTOM_TYPE } from "../src/messages.js"; +import { entriesToCoreMessages, coreOutToAgentMessages, matchesStoredText, messageIdentity } from "../src/messages.js"; +import { ACP_STATUS_CUSTOM_TYPE } from "../src/turn-boundary.js"; import type { CoreMessage } from "acp-kernel"; import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; diff --git a/tests/tokens.test.ts b/tests/tokens.test.ts index 9e75a9a..6ebab20 100644 --- a/tests/tokens.test.ts +++ b/tests/tokens.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { estimateTokens, lastUserMessageId } from "../src/tokens.js"; +import { estimateTokens } from "../src/tokens.js"; test("estimateTokens matches kernel defaultCountTokens (CJK 1:1 + chars/4)", () => { const msgs = [ @@ -38,32 +38,4 @@ test("estimateTokens skips covered (already-compressed) message ids", () => { assert.equal(estimateTokens(msgs, covered), 4); }); -test("lastUserMessageId returns the id of the last user-role entry", () => { - const entries = [ - { id: "a", message: { role: "user" } }, - { id: "b", message: { role: "assistant" } }, - { id: "c", message: { role: "user" } }, - { id: "d", message: { role: "toolResult" } }, - ]; - assert.equal(lastUserMessageId(entries), "c", "last user message is c"); -}); - -test("lastUserMessageId returns undefined when no user message exists", () => { - const entries = [ - { id: "a", message: { role: "assistant" } }, - { id: "b", message: { role: "toolResult" } }, - ]; - assert.equal(lastUserMessageId(entries), undefined); -}); -test("lastUserMessageId returns undefined for empty entries", () => { - assert.equal(lastUserMessageId([]), undefined); -}); - -test("lastUserMessageId handles entries without message field", () => { - const entries = [ - { id: "a" }, - { id: "b", message: { role: "user" } }, - ]; - assert.equal(lastUserMessageId(entries), "b", "skips entries without message"); -}); diff --git a/tests/turn-boundary.test.ts b/tests/turn-boundary.test.ts new file mode 100644 index 0000000..914c725 --- /dev/null +++ b/tests/turn-boundary.test.ts @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ACP_STATUS_CUSTOM_TYPE, + isCustomMessageEntry, + isTurnBoundary, + lastTurnBoundaryId, + lastTurnBoundaryIndex, + type TurnBoundaryEntry, +} from "../src/turn-boundary.js"; + +const user = (id: string): TurnBoundaryEntry => ({ type: "message", id, message: { role: "user" } }); +const assistant = (id: string): TurnBoundaryEntry => ({ type: "message", id, message: { role: "assistant" } }); +const toolResult = (id: string): TurnBoundaryEntry => ({ type: "message", id, message: { role: "toolResult" } }); +const custom = (id: string, content = "injected agent turn"): TurnBoundaryEntry => ({ type: "custom_message", id, content }); +const statusPanel = (id: string): TurnBoundaryEntry => ({ type: "custom_message", id, customType: ACP_STATUS_CUSTOM_TYPE, content: "panel" }); + +test("isTurnBoundary: user-role entries always start a turn", () => { + assert.equal(isTurnBoundary(user("m00001")), true); + assert.equal(isTurnBoundary(user("m00001"), { countCustomMessages: true }), true); +}); + +test("isTurnBoundary: assistant/toolResult entries never start a turn", () => { + assert.equal(isTurnBoundary(assistant("m00002")), false); + assert.equal(isTurnBoundary(toolResult("m00003"), { countCustomMessages: true }), false); +}); + +test("isTurnBoundary: injected custom_message starts a turn only under policy", () => { + assert.equal(isTurnBoundary(custom("m00004")), false, "default = pi-native"); + assert.equal(isTurnBoundary(custom("m00004"), { countCustomMessages: false }), false); + assert.equal(isTurnBoundary(custom("m00004"), { countCustomMessages: true }), true); +}); + +test("isTurnBoundary: acp-status panels never start a turn, even under policy", () => { + assert.equal(isTurnBoundary(statusPanel("m00005")), false); + assert.equal(isTurnBoundary(statusPanel("m00005"), { countCustomMessages: true }), false); +}); + +test("isTurnBoundary: non-message entries without a message field are not boundaries", () => { + assert.equal(isTurnBoundary({ type: "compaction", id: "x" }, { countCustomMessages: true }), false); +}); + +test("isCustomMessageEntry: matches the context-projection condition exactly", () => { + assert.equal(isCustomMessageEntry(custom("a")), true); + assert.equal(isCustomMessageEntry(statusPanel("b")), false); + assert.equal(isCustomMessageEntry(user("c")), false); +}); + +// Interleaved battery covering every entry kind, used for the cross-view +// consistency assertions below (acceptance: the three former call sites must +// agree on where the current turn starts). +const BATTERY: TurnBoundaryEntry[] = [ + user("u1"), + assistant("a1"), + custom("c1"), + toolResult("t1"), + statusPanel("s1"), + user("u2"), + custom("c2"), +]; + +// The pre-#364 rule, kept verbatim as the regression oracle: pi-native +// behavior counts only genuine user-role messages. +function legacyLastUserId(entries: readonly TurnBoundaryEntry[]): string | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i]!.message?.role === "user") return entries[i]!.id; + } + return undefined; +} + +function legacyLastUserIndex(entries: readonly TurnBoundaryEntry[]): number { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i]!.message?.role === "user") return i; + } + return -1; +} + +test("consistency: id view and index view of the boundary agree for both policies", () => { + for (const policy of [undefined, { countCustomMessages: true }] as const) { + const idx = lastTurnBoundaryIndex(BATTERY, policy); + const id = lastTurnBoundaryId(BATTERY, policy); + assert.ok(idx >= 0); + assert.equal(id, BATTERY[idx]?.id, `policy=${JSON.stringify(policy)}: id/index views must agree`); + } +}); + +test("regression: default policy is byte-for-byte the legacy user-role-only scan", () => { + assert.equal(lastTurnBoundaryId(BATTERY), legacyLastUserId(BATTERY)); + assert.equal(lastTurnBoundaryIndex(BATTERY), legacyLastUserIndex(BATTERY)); + assert.equal(lastTurnBoundaryId([], undefined), undefined); + assert.equal(lastTurnBoundaryIndex([], undefined), -1); +}); + +test("policy on: the latest injected custom_message becomes the boundary", () => { + assert.equal(lastTurnBoundaryId(BATTERY, { countCustomMessages: true }), "c2"); + assert.equal(lastTurnBoundaryIndex(BATTERY, { countCustomMessages: true }), BATTERY.findIndex((e) => e.id === "c2")); +}); + +test("lastTurnBoundaryId: migrated legacy expectations hold under default policy", () => { + const entries: TurnBoundaryEntry[] = [ + { id: "a", message: { role: "user" } }, + { id: "b", message: { role: "assistant" } }, + { id: "c", message: { role: "user" } }, + { id: "d", message: { role: "toolResult" } }, + ]; + assert.equal(lastTurnBoundaryId(entries), "c", "last user message is c"); + + const noUser: TurnBoundaryEntry[] = [ + { id: "a", message: { role: "assistant" } }, + { id: "b", message: { role: "toolResult" } }, + ]; + assert.equal(lastTurnBoundaryId(noUser), undefined); + assert.equal(lastTurnBoundaryId([]), undefined); + + const sparse: TurnBoundaryEntry[] = [ + { id: "a" }, + { id: "b", message: { role: "user" } }, + ]; + assert.equal(lastTurnBoundaryId(sparse), "b", "skips entries without message"); +}); diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts index 15f417c..74a0b35 100644 --- a/tests/user-config.test.ts +++ b/tests/user-config.test.ts @@ -118,6 +118,19 @@ test("loadUserConfig reads outputHeadroomMaxPct (ratio and percent string)", asy } }); +test("loadUserConfig reads hostSession (object form survives pickKnown)", async () => { + const tmpDir = path.join(os.tmpdir(), `acp-test-hostsession-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + await writeConfig(tmpDir, { hostSession: { countCustomMessages: true }, unknownKey: "nope" }); + try { + const config = await loadUserConfig(tmpDir); + assert.deepEqual(config.hostSession, { countCustomMessages: true }, "hostSession is a known key"); + assert.equal((config as Record).unknownKey, undefined, "unknown keys still filtered"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test("loadUserConfig ignores unknown keys", async () => { const tmpDir = path.join(os.tmpdir(), `acp-test-unknown-${Date.now()}`); await fs.mkdir(tmpDir, { recursive: true }); From 95db150aa1ff705df985f96a60cbfba9d921192b Mon Sep 17 00:00:00 2001 From: ework-agent Date: Fri, 11 Sep 2026 10:20:48 +0800 Subject: [PATCH 2/3] fix(host): loadable + recognized on Pi-fork hosts like Prime (#364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/config-dir.ts: namespace import of the pi package + feature-detect CONFIG_DIR_NAME with ".pi" fallback — safe under both link-time and runtime missing-export failure modes; sole value import from pi pkg (tool-guardrails vendors its one guard locally, same pattern as the existing isBashToolResult vendoring) - src/host.ts: entrySourceOf / isDeclaredForkHost (PI_ACP_FORK_HOST=1|true) / isUnsupportedHost — OMP stand-down protection stays default; declared Pi-compatible forks are accepted and get the existing live-message merge - omp.ts: UNSUPPORTED_HOST_MESSAGE guidance (fork opt-in + billion-context proxy); session_start gate uses isUnsupportedHost - turn boundary: empty custom_message no longer starts a turn (same extractText gate as projection; #364 acceptance c) — isCustomMessageEntry moves back to messages.ts so projection and predicate share one definition - docs: host-adapter.md §3 detection/entry-source contract + §4 config-dir responsibility boundary; omp.md(+zh); CONFIGURATION(+zh) env table --- CHANGELOG.md | 1 + CONFIGURATION.md | 3 +- CONFIGURATION.zh-CN.md | 3 +- docs/host-adapter.md | 77 +++++++++++++++++++++++++++++++++-- docs/omp.md | 6 ++- docs/omp.zh-CN.md | 6 ++- src/commands.ts | 2 +- src/compress-tool.ts | 4 +- src/config-dir.ts | 20 +++++++++ src/decompress-tool.ts | 4 +- src/host.ts | 36 ++++++++++++++++ src/index.ts | 28 +++++++------ src/log.ts | 2 +- src/messages.ts | 21 +++++++++- src/omp.ts | 36 ++++++---------- src/search-tool.ts | 4 +- src/setup-subagent-tools.ts | 2 +- src/status-tool.ts | 4 +- src/tool-guardrails.ts | 12 +++++- src/turn-boundary.ts | 13 +----- src/update.ts | 2 +- src/user-config.ts | 2 +- tests/host-detection.test.ts | 79 ++++++++++++++++++++++++++++++++++++ tests/messages.test.ts | 14 ++++++- tests/omp-refuse.test.ts | 33 +++++++++++---- tests/turn-boundary.test.ts | 9 +++- 26 files changed, 339 insertions(+), 84 deletions(-) create mode 100644 src/config-dir.ts create mode 100644 src/host.ts create mode 100644 tests/host-detection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bc452bb..842ce1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **fix(host): Pi-fork 宿主(Prime)可加载 + 可识别(#364 验收补充)** — Prime 0.9.4 实测两个启动阻断:① Prime loader 把 `@earendil-works/pi-coding-agent` alias 到自身构建,公共入口不导出 `CONFIG_DIR_NAME`,ESM interop 下缺失命名导出在运行时表现为 `undefined`(而非链接错误)→ `path.join()` 直接抛 "The path argument must be of type string"。新增 `src/config-dir.ts`:feature-detect 缺失/非法导出时回退 Pi 规范值 `.pi`,五个路径使用点(log/index/user-config/setup-subagent-tools/update)全部收敛到该常量;责任边界写入 docs/host-adapter.md §4(导出归宿主、回退归适配器)。② `isOmpHost = !isPiHost` 把一切无 `buildContextEntries()` 的宿主(含 Prime)一律判为 OMP 并禁用 ACP——但 OMP 与 Prime 同为 Pi fork、同为 getBranch-only 形状,形状本身无法区分。新 `src/host.ts`:`entrySourceOf`(buildContextEntries > getBranch)、`isDeclaredForkHost`(`PI_ACP_FORK_HOST=1/true`,调用时读取)、`isUnsupportedHost = !isPiHost && !declared`;OMP 默认保护不变(不声明即拒绝),声明的 fork 走既有 `!isPiHost` live-merge 补偿 getBranch 落后一条消息;delegate-tool 的 CLI flag 语义保持严格 isPiHost(fork 宿主不会用 pi-only flag 拉起子进程)。`OMP_UNSUPPORTED_MESSAGE` → `UNSUPPORTED_HOST_MESSAGE`,文案同时指向两条出路(fork opt-in / billion-context proxy);docs/omp.md(+zh)同步;CONFIGURATION(+zh)env 表增 `PI_ACP_FORK_HOST`。测试:`tests/host-detection.test.ts`(Prime-shaped fixture 矩阵)+ `tests/omp-refuse.test.ts` 增 opt-in 用例。③ 回合谓词同步收紧(验收项 c):空内容 `custom_message`(纯控制信号)不再开启新回合——与投影共用同一 `extractText` 判据,`isCustomMessageEntry` 因此移回 `src/messages.ts` 与投影同源(经 type-only import 保持无环),非空 agent_message 开启新回合、UI/control/synthetic 策略在 host-adapter §1 写明 - **feat(host): 宿主多会话支持(二) — 回合边界判定统一 + 子会话状态继承(closes #364)** — #317 遗留的两个结构性缺口(记账隔离已由 #327 修复)。① 回合边界("什么消息算新回合起点")此前在三处独立判定且互不一致(tokens.ts 的 lastUserMessageId / index.ts 的 turnKey+turnStartIndex / messages.ts 的上下文条目投影):宿主以 custom_message 注入的 agent 回合进入 LLM 上下文但不算回合起点 → 多个真实回合塌缩进同一 turnKey(nudge 账本格子错位、重试上限与节流周期统计失真)。现收敛为单一谓词 `isTurnBoundary(entry, policy)` + 两个扫描助手(`src/turn-boundary.ts`),三处全部走它;新增 `hostSession` 配置(boolean 简写或 `{countCustomMessages}`,默认关闭 = pi 原生行为,存量单会话用户逐字节不变——单测以 legacy user-role-only 扫描为 oracle 断言等价)。② 内联同进程子会话(Prime RLM 等)的状态派生契约 `deriveChildState(parentState)`:继承 blocks(深拷贝)/messageRefs/tokenSnapshot(原始消息索引)/nextBlockId/nextRunId(保证继承块可 decompress/search、新块 id 不冲突),重置 nudge 节奏基线/stats/absorbed(子会话重新起算);一次性迁移标记 `derivedFrom:{parentSessionId,derivedAt}` 持久化进子 sidecar(沿用 `.jsonl.acp.json`,与父文件独立),拒绝重复派生;护栏:子会话已有自有非派生块 / 父无块 / 子无 sessionFile 时拒绝且不改任何状态;显式派生优先于隐式 parentSession 头继承(恰好升级一次)。pi 原生 delegate(独立进程)路径零变化。文档:新增 `docs/host-adapter.md`(回界契约 + 子会话派生契约),CONFIGURATION.md(+zh-CN) 增 `hostSession` 节。测试:新增 `tests/turn-boundary.test.ts`(谓词矩阵 + id/index 双视图一致性 + 默认策略 ≡ legacy 扫描回归)与 `tests/derive-child-state.test.ts`(继承/重置矩阵、深拷贝隔离、marker 往返、各护栏拒绝、内联子代理 header 场景) - **fix(degeneration): thinking/text 单字符退化熔断 + 一次性恢复通知(closes #351)** — 长会话末尾模型偶发退化为单字符长连击(实测:thinking 块末尾 4655 个连续「【」,跨轮升级直至 turn abort、会话停死)。根因链已代码级验证:pi 的 openai-completions 转换把历史 assistant thinking 在**每个后续请求**中回传 provider(`reasoning_content`,或 requiresThinkingAsText 时转纯文本),aborted turn 的部分消息又持久化在会话日志里 → 退化尾部随每轮 prompt 重放 → 模型看到自己上一轮以数千个重复字符结尾 → 续写偏置再次触发退化 → 连环 abort。新增 `src/degeneration.ts`:每个 context 事件对出站视图的 assistant text/thinking 块折叠 ≥`minRun`(默认 200,下限 8,codepoint/代理对安全)的单 codepoint 连击为短标记(保留 ≤3 份样本;纯函数、幂等——标记固定文案无相邻重复码点、fail-safe;持久化历史不改,toolCall 参数不动以免与实际执行脱钩);当最近一条 assistant 消息已退化时追加一次性 `[ACP recovery notice]`(位置自限:模型产出新 turn 后自动消失,无持久状态不累积,#223 教训)。检测走持久化 originals 而非出站视图:thinking-only aborted turn 会被 projectMessage 丢弃(空文本在 OpenAI 兼容 provider 400),但它仍是模型的"上一轮",通知必须照发。acp.json 新键 `degenerationGuard`(boolean 或 `{enabled,minRun}`,默认开;`false` 为 kill-switch)。附带修复:`repetitionGuard` 此前不在 user-config KNOWN 白名单内,acp.json 中配置被静默丢弃(dead key),本次补入。测试 `tests/degeneration.test.ts`(31 例:单元 + context transform 端到端 wiring) - **fix(reasoning): 闭合判定改按回合证据——无用户消息的长 agent 会话不再永久保留 compress thinking(closes #348)** — 原门控“compress 调用之后存在真实用户消息才算闭合”在长 agent 会话不可达(整个会话只有开头 1–2 条用户消息,后续 30 个 compress 全部被永久视为活跃回合,观察会话 0 次触发,thinking 地板 20.6K/8.4K/10.6K 字符全部滞留)。现在闭合判定改为:消息内**每个** compress toolCall 的 toolResult(role `toolResult`、`toolCallId` 匹配)已出现在更晚位置,且其后至少还有一条消息(回合已实际推进)。安全门不变:结果未返回或结果仍是最后一条消息(在飞中)绝不动;nudge 在 drop 之后才注入,不可能光当“结果后的消息”闭合在飞回合;per-provider `compress.providers..reasoning.drop=false` 逃生阀保留(GLM 等 reasoning 回显模型)。测试重写 + 新增 #348 场景(无用户消息的助手链闭合、result 悬置、result 在 call 之前、多 toolCall 部分闭合、误 id 不闭合) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index b11970a..fdd8b9a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -165,6 +165,7 @@ All keys below are currently **ACTIVE**. | `ACP_MODEL_CONTEXT_LIMIT` | Override the context limit (takes highest precedence). | | `ACP_DEBUG` | Set to `1` / `true` to enable debug logging. | | `ACP_LOG_FILE` | Override the log file path (default `~/.pi/acp.log`). | +| `PI_ACP_FORK_HOST` | Set to `1` / `true` to declare a Pi-compatible fork host (no `buildContextEntries()`) as supported. OMP stays refused by default. See [docs/host-adapter.md](./docs/host-adapter.md). | | `PI_ACP_DELEGATE_MAX_DEPTH` | Override `delegate.maxDepth`. | | `PI_ACP_DELEGATE_SYNC_TIMEOUT_MINUTES` | Override `delegate.syncTimeoutMinutes`; `0` disables the sync hard timeout. | | `PI_ACP_DELEGATE_IDLE_TIMEOUT_MINUTES` | Override `delegate.idleTimeoutMinutes`; `0` disables the idle watchdog. | @@ -514,7 +515,7 @@ The `hostSession` key controls **turn-boundary detection** for hosts that run se - **Type:** boolean - **Default:** `false` - **Status:** 🟢 ACTIVE -- **Description:** Count host-injected `custom_message` entries (except UI-only `acp-status` panels) as turn boundaries for all per-turn ledgers. Does not change LLM-context projection — those entries were already projected as user-role messages under Pi-native semantics. +- **Description:** Count host-injected `custom_message` entries with non-empty text (except UI-only `acp-status` panels) as turn boundaries for all per-turn ledgers; empty injections are pure control signals and start no turn. Does not change LLM-context projection — those entries were already projected as user-role messages under Pi-native semantics. --- diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index acab0fa..a3f98b9 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -164,6 +164,7 @@ | `ACP_MODEL_CONTEXT_LIMIT` | 覆盖上下文窗口大小(优先级最高)。 | | `ACP_DEBUG` | 设为 `1` / `true` 开启调试日志。 | | `ACP_LOG_FILE` | 覆盖日志文件路径(默认 `~/.pi/acp.log`)。 | +| `PI_ACP_FORK_HOST` | 设为 `1` / `true` 声明当前宿主是兼容 Pi 的 fork(无 `buildContextEntries()`),使其被识别为受支持宿主。OMP 默认仍被拒绝。见 [docs/host-adapter.md](./docs/host-adapter.md)。 | | `PI_ACP_DELEGATE_MAX_DEPTH` | 覆盖 `delegate.maxDepth`。 | | `PI_ACP_DELEGATE_SYNC_TIMEOUT_MINUTES` | 覆盖 `delegate.syncTimeoutMinutes`;`0` 禁用同步硬超时。 | | `PI_ACP_DELEGATE_IDLE_TIMEOUT_MINUTES` | 覆盖 `delegate.idleTimeoutMinutes`;`0` 禁用闲置看门狗。 | @@ -506,7 +507,7 @@ - **类型:** boolean - **默认值:** `false` - **状态:** 🟢 ACTIVE -- **说明:** 把宿主注入的 `custom_message` 条目(UI-only 的 `acp-status` 面板除外)计为所有按回合账本的回合起点。不改变 LLM 上下文投影——这些条目的 user-role 投影本就是 pi 原生行为。 +- **说明:** 把宿主注入的非空文本 `custom_message` 条目(UI-only 的 `acp-status` 面板除外)计为所有按回合账本的回合起点;空内容注入是纯控制信号,不开启回合。不改变 LLM 上下文投影——这些条目的 user-role 投影本就是 pi 原生行为。 --- diff --git a/docs/host-adapter.md b/docs/host-adapter.md index cb274cb..a192516 100644 --- a/docs/host-adapter.md +++ b/docs/host-adapter.md @@ -31,9 +31,11 @@ message" can never drift apart again. 1. A genuine **user-role message always starts a turn** (Pi-native; unaffected by policy). 2. Assistant / toolResult / compaction / branch-summary entries never start a turn. -3. Host-injected `custom_message` entries start a turn **only when the policy opts in**. - UI-only `acp-status` panels (the `/acp` slash-command output) are excluded even under - the opt-in — they never enter LLM context either. +3. Host-injected `custom_message` entries start a turn **only when the policy opts in**, + and only if they carry non-empty text (the same `extractText` gate projection uses). + Empty injections are pure control signals: they never enter LLM context, so they + start no turn either. UI-only `acp-status` panels (the `/acp` slash-command output) + are excluded even under the opt-in. 4. LLM-context projection is **independent of this policy**: `custom_message` entries were and remain projected as user-role messages (Pi-native semantics). The policy only changes *turn accounting*, not what the model sees. @@ -136,3 +138,72 @@ The child keeps its own independent sidecar — `~/.pi/agent/sessions/= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n); @@ -58,7 +58,7 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? UNSUPPORTED_HOST_MESSAGE }] }; let result: string; try { result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId); diff --git a/src/config-dir.ts b/src/config-dir.ts new file mode 100644 index 0000000..cd775e5 --- /dev/null +++ b/src/config-dir.ts @@ -0,0 +1,20 @@ +import * as piModule from "@earendil-works/pi-coding-agent"; + +type PiNamespace = { CONFIG_DIR_NAME?: unknown }; + +/** + * Config directory name with a host feature-detection fallback (#364). + * + * Pi exports CONFIG_DIR_NAME (".pi"). Hosts that alias the pi package to their own build + * (e.g. Prime) may not re-export it. A static named import of it fails differently per + * resolver — link-time SyntaxError under plain Node ESM→CJS interop, or `undefined` at + * runtime (which then breaks `path.join()`) under loader-based aliasing — so this module + * uses a namespace import (safe in both cases) and falls back to Pi's canonical ".pi" + * when the export is absent or not a non-empty string. It is the ONLY value import from + * the pi package; everything else is type-only. + * Contract & responsibility boundary: docs/host-adapter.md → "Config directory". + */ +const PI_CONFIG_DIR_NAME: unknown = (piModule as unknown as PiNamespace).CONFIG_DIR_NAME; + +export const CONFIG_DIR_NAME: string = + typeof PI_CONFIG_DIR_NAME === "string" && PI_CONFIG_DIR_NAME.length > 0 ? PI_CONFIG_DIR_NAME : ".pi"; diff --git a/src/decompress-tool.ts b/src/decompress-tool.ts index ec72830..2e48f41 100644 --- a/src/decompress-tool.ts +++ b/src/decompress-tool.ts @@ -4,7 +4,7 @@ import type { AcpRuntime } from "./runtime.js"; import { debug, logError, logInfo, logThrow } from "./log.js"; import { parseBlockIdArg, collectBlockContent, type CompressionBlock } from "acp-kernel"; import { entriesToCoreMessages } from "./messages.js"; -import { OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; +import { UNSUPPORTED_HOST_MESSAGE } from "./omp.js"; import { writeFile, mkdir } from "node:fs/promises"; import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs"; import { resolve, relative, isAbsolute, join, basename, dirname } from "node:path"; @@ -46,7 +46,7 @@ export function makeDecompressTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? UNSUPPORTED_HOST_MESSAGE }] }; let result: string; try { result = await handleDecompress(params as DecompressArgs, runtime, ctx); diff --git a/src/host.ts b/src/host.ts new file mode 100644 index 0000000..0b2ddac --- /dev/null +++ b/src/host.ts @@ -0,0 +1,36 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { isPiHost } from "./runtime.js"; + +/** Which session-entry source API the host exposes (same precedence as readContextEntries). */ +export type EntrySourceKind = "buildContextEntries" | "getBranch"; + +type SessionEntrySource = { + buildContextEntries?: unknown; + getBranch?: unknown; +}; + +export function entrySourceOf(sm: ExtensionContext["sessionManager"]): EntrySourceKind | null { + const source = sm as unknown as SessionEntrySource | null | undefined; + if (!source) return null; + if (typeof source.buildContextEntries === "function") return "buildContextEntries"; + if (typeof source.getBranch === "function") return "getBranch"; + return null; +} + +/** + * Whether the host declared itself a Pi-compatible fork via environment. + * + * The SessionManager shape alone cannot distinguish e.g. Prime from OMP — both are + * Pi forks exposing `getBranch()` but not `buildContextEntries()` — so an + * unsupported shape requires an explicit declaration before the adapter runs. + * Read at call time so hosts can set it per-launch. See docs/host-adapter.md. + */ +export function isDeclaredForkHost(): boolean { + const v = process.env.PI_ACP_FORK_HOST; + return v === "1" || v?.toLowerCase() === "true"; +} + +/** Unsupported host: not Pi-shaped and not declared as a fork. OMP stays blocked by default. */ +export function isUnsupportedHost(sm: ExtensionContext["sessionManager"]): boolean { + return !isPiHost(sm) && !isDeclaredForkHost(); +} diff --git a/src/index.ts b/src/index.ts index b54bbdc..eb3a80e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import type { ExtensionFactory, SessionMessageEntry, } from "@earendil-works/pi-coding-agent"; -import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME } from "./config-dir.js"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -42,7 +42,8 @@ import { import { defaultCountTokens } from "acp-kernel"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; import { applyOutputHeadroom, inspectOverflowMessage, resolveOutputHeadroomCap } from "./overflow-selfheal.js"; -import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; +import { UNSUPPORTED_HOST_MESSAGE } from "./omp.js"; +import { isUnsupportedHost } from "./host.js"; import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -159,20 +160,23 @@ function wireDelegateReadTracking(pi: ExtensionAPI): void { function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIfProxied: (ctx: ExtensionContext) => boolean): void { let ompWarned = false; pi.on("session_start", async (_event, ctx) => { - // OMP (oh-my-pi) is not supported: its in-process live-entries integration - // diverges the nudge's example refs from the session's real refs, so - // compress calls fail with "does not exist in this session". Stand down — - // refuse service and point the user at the billion-context proxy. session_start - // always precedes the first context/before_agent_start event, so setting - // `refused` here reliably gates every downstream handler for the session. - if (isOmpHost(ctx.sessionManager)) { + // Unsupported hosts stand down (#234 / #364): any host without Pi's + // buildContextEntries() API is refused unless it declared itself a + // Pi-compatible fork via PI_ACP_FORK_HOST=1. OMP (oh-my-pi) stays blocked + // by default — its in-process live-entries integration diverges the nudge's + // example refs from the session's real refs, so compress calls fail with + // "does not exist in this session". Refuse service and point the user at + // the fork opt-in or the billion-context proxy. session_start always + // precedes the first context/before_agent_start event, so setting `refused` + // here reliably gates every downstream handler for the session. + if (isUnsupportedHost(ctx.sessionManager)) { runtime.refused = true; if (!ompWarned) { ompWarned = true; const sid = ctx.sessionManager.getSessionId(); - logWarn("host", { event: "omp-unsupported", sid, action: "refused" }); - if (ctx.hasUI) ctx.ui.notify(OMP_UNSUPPORTED_MESSAGE, "warning"); - else console.error(OMP_UNSUPPORTED_MESSAGE); + logWarn("host", { event: "host-unsupported", sid, action: "refused" }); + if (ctx.hasUI) ctx.ui.notify(UNSUPPORTED_HOST_MESSAGE, "warning"); + else console.error(UNSUPPORTED_HOST_MESSAGE); } return; } diff --git a/src/log.ts b/src/log.ts index 935aa8c..16e8eb1 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,7 +1,7 @@ import { appendFileSync, mkdirSync, statSync, renameSync, existsSync } from "node:fs"; import * as path from "node:path"; import { homedir } from "node:os"; -import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME } from "./config-dir.js"; const MAX_BYTES = 10 * 1024 * 1024; diff --git a/src/messages.ts b/src/messages.ts index 7fbbc19..84c6994 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,7 +1,7 @@ import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; import { defaultCountTokens, type CoreMessage } from "acp-kernel"; import { rewriteTagTokens } from "./tag-tokens.js"; -import { ACP_STATUS_CUSTOM_TYPE, isCustomMessageEntry } from "./turn-boundary.js"; +import type { TurnBoundaryEntry } from "./turn-boundary.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -19,6 +19,25 @@ const REF_TAG_SOURCE = "(?:\x3cacp\\s[^>]*\x3em\\d{5}\x3c/acp\x3e|\\[m\\d{1,5}\\ const REF_TAG = new RegExp(`^${REF_TAG_SOURCE}\\s?\\n?`); const TRAILING_REF_TAG = new RegExp(`\\n*${REF_TAG_SOURCE}\\s*$`); +/** Host-injected status-panel custom messages (written by src/commands.ts) — + * UI-only, never projected into LLM context, so never user-like entries. */ +export const ACP_STATUS_CUSTOM_TYPE = "acp-status"; + +/** True for host-injected custom_message entries that participate in LLM + * context: non-empty custom_message except UI-only acp-status panels + * (Pi-native projection semantics, session-manager.d.ts). The non-empty gate + * uses the exact same extractText check as the projection below, so an entry + * either enters context or it doesn't — and only entries that enter context + * can delimit a turn (#364 acceptance c: empty control signals start none). + * Shared by the turn-boundary predicate in src/turn-boundary.ts so the two + * views can never drift apart; defined here (not there) because it needs + * extractText — importing that back would create a cycle. */ +export function isCustomMessageEntry(entry: TurnBoundaryEntry): entry is TurnBoundaryEntry & { type: "custom_message" } { + return entry.type === "custom_message" + && entry.customType !== ACP_STATUS_CUSTOM_TYPE + && extractText(entry.content).length > 0; +} + export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] { const out: CoreMessage[] = []; for (const entry of entries) { diff --git a/src/omp.ts b/src/omp.ts index 88021b4..d8285f6 100644 --- a/src/omp.ts +++ b/src/omp.ts @@ -1,30 +1,18 @@ -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { isPiHost } from "./runtime.js"; - /** - * Positive OMP (oh-my-pi) host detection. + * Shown (and logged) when the extension detects an unsupported host and stands down. * - * pi exposes `sessionManager.buildContextEntries()`; omp only exposes - * `getBranch()`. `isPiHost` feature-detects the former, so its negation is the - * established "omp path" used throughout the adapter (see runtime.stateFor). - * We reuse that semantic so the refusal and the (now dormant) best-effort omp - * path can never disagree about which host we are on. + * Unsupported = no Pi `buildContextEntries()` API AND no `PI_ACP_FORK_HOST` declaration + * (see ./host.ts). OMP (oh-my-pi) falls into this by default: its in-process live-entries + * integration diverges the nudge's example refs from the session's real refs, so compress + * calls fail with "does not exist in this session" ([#234]). The billion-context proxy runs + * compression server-side (it owns the ref coordinate space) and works on OMP. */ -export function isOmpHost(sm: ExtensionContext["sessionManager"]): boolean { - return !isPiHost(sm); -} - -/** - * Shown (and logged) when the extension detects an OMP host and stands down. - * OMP's in-process live-entries integration is unreliable: the nudge's example - * refs diverge from the session's real refs, so compress calls fail with - * "does not exist in this session". The billion-context proxy runs compression - * server-side (it owns the ref coordinate space) and works on OMP. - */ -export const OMP_UNSUPPORTED_MESSAGE = [ - "[billion-context-pi] This host is OMP (oh-my-pi), which is NOT supported — ACP has been disabled for this session.", - "The in-process compression path is unreliable on OMP: the nudge's example refs diverge from the session's real refs, so compress calls fail with \"does not exist in this session\".", - "Use the billion-context proxy instead — it runs compression server-side and works on OMP:", +export const UNSUPPORTED_HOST_MESSAGE = [ + "[billion-context-pi] Unsupported host: no buildContextEntries() API and no PI_ACP_FORK_HOST declaration — ACP has been disabled for this session.", + "Pi-compatible fork (e.g. Prime) running sessions in-process? Opt in explicitly:", + " PI_ACP_FORK_HOST=1 ", + "Contract: docs/host-adapter.md → \"Supported-host detection\".", + "OMP (oh-my-pi)? Use the billion-context proxy instead — it runs compression server-side and works on OMP:", " npm install -g billion-context", " bili omp", "Docs: https://github.com/ranxianglei/billion-context", diff --git a/src/search-tool.ts b/src/search-tool.ts index 085683c..d152df4 100644 --- a/src/search-tool.ts +++ b/src/search-tool.ts @@ -4,7 +4,7 @@ import { searchBlocks, type SearchResult } from "acp-kernel"; import type { AcpRuntime } from "./runtime.js"; import { buildSearchDocs } from "./search-index.js"; import { logThrow } from "./log.js"; -import { OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; +import { UNSUPPORTED_HOST_MESSAGE } from "./omp.js"; const SearchParams = Type.Object({ query: Type.String({ description: "Keywords to locate detail folded into compressed summaries or historical messages." }), @@ -27,7 +27,7 @@ export function makeSearchTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? UNSUPPORTED_HOST_MESSAGE }] }; let result: string; try { result = await handleSearch(params as SearchArgs, runtime, ctx); diff --git a/src/setup-subagent-tools.ts b/src/setup-subagent-tools.ts index c4ddfeb..05da00c 100644 --- a/src/setup-subagent-tools.ts +++ b/src/setup-subagent-tools.ts @@ -12,7 +12,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME } from "./config-dir.js"; /** The four ACP tools to ensure on every pi-subagents builtin agent. */ export const ACP_TOOLS = ["compress", "decompress", "search_context", "acp_status"] as const; diff --git a/src/status-tool.ts b/src/status-tool.ts index f56ecc5..d0350df 100644 --- a/src/status-tool.ts +++ b/src/status-tool.ts @@ -9,7 +9,7 @@ import { getSystemPromptText } from "./compat.js"; import { logThrow } from "./log.js"; import { getDelegateUsage } from "./delegate-tool.js"; import { resolveDelegate } from "./config.js"; -import { OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; +import { UNSUPPORTED_HOST_MESSAGE } from "./omp.js"; const StatusParams = Type.Object({ scope: Type.Optional(Type.Union([Type.Literal("compressed"), Type.Literal("uncompressed")], { description: '"compressed" = drill into blocks; "uncompressed" = show visible messages/ranges. Default: overview.' })), @@ -35,7 +35,7 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? UNSUPPORTED_HOST_MESSAGE }] }; let result: string; try { result = await handleStatus(params as StatusArgs, runtime, ctx); diff --git a/src/tool-guardrails.ts b/src/tool-guardrails.ts index 1bc1e78..adf354e 100644 --- a/src/tool-guardrails.ts +++ b/src/tool-guardrails.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { - isToolCallEventType, type ExtensionAPI, + type ToolCallEvent, type ToolResultEvent, } from "@earendil-works/pi-coding-agent"; import { DEFAULT_TOOL_BASH_TIMEOUT, DEFAULT_TOOL_OUTPUT_MAX_BYTES, resolveRepetitionGuard } from "./config.js"; @@ -16,6 +16,14 @@ export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { return e.toolName === "bash"; } +// Vendored locally rather than imported: pi exports isToolCallEventType, but +// aliased host bundles (Prime) may not re-export it, and a missing named export +// fails module load (#364). The body is just event.toolName === "bash". +type BashToolCallEvent = Extract; +function isBashToolCall(event: ToolCallEvent): event is BashToolCallEvent { + return event.toolName === "bash"; +} + type ContentPart = ToolResultEvent["content"][number]; export function resolveBashTimeout( @@ -194,7 +202,7 @@ export function wireToolGuardrails(pi: ExtensionAPI, runtime: AcpRuntime): void const pendingWarns = new Map(); pi.on("tool_call", (event, ctx) => { - if (isToolCallEventType("bash", event)) { + if (isBashToolCall(event)) { const t = resolveBashTimeout(event.input, runtime.adapter.toolBashDefaultTimeout); if (t !== undefined) { event.input.timeout = t; diff --git a/src/turn-boundary.ts b/src/turn-boundary.ts index 6366bbb..8bb1131 100644 --- a/src/turn-boundary.ts +++ b/src/turn-boundary.ts @@ -9,9 +9,7 @@ * Every turn-boundary check in the adapter goes through isTurnBoundary. */ -/** Host-injected status panel custom messages (src/commands.ts) — UI-only, - * never projected into LLM context, so they never count as user-like entries. */ -export const ACP_STATUS_CUSTOM_TYPE = "acp-status"; +import { isCustomMessageEntry } from "./messages.js"; /** Minimal structural shape of a session-log entry for boundary checks. Pi's * SessionEntry and the narrower arrays used by token accounting both satisfy @@ -33,15 +31,6 @@ export interface TurnBoundaryPolicy { countCustomMessages?: boolean; } -/** True for host-injected custom_message entries that participate in LLM - * context — every custom_message except the UI-only acp-status panels. - * Type guard so callers keep narrowing the session-entry union (shared by - * the context projection in src/messages.ts and the policy-aware boundary - * check below so the two can never drift apart). */ -export function isCustomMessageEntry(entry: TurnBoundaryEntry): entry is TurnBoundaryEntry & { type: "custom_message" } { - return entry.type === "custom_message" && entry.customType !== ACP_STATUS_CUSTOM_TYPE; -} - /** The ONE turn-boundary predicate (#364): does this entry start a new turn? * Genuine user-role messages always do (pi-native); host-injected * custom_message entries do only when the host opts in via policy. */ diff --git a/src/update.ts b/src/update.ts index 0dc67c7..1eea617 100644 --- a/src/update.ts +++ b/src/update.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; -import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME } from "./config-dir.js"; import { debug, logInfo, logWarn } from "./log.js"; declare const CURRENT_VERSION: string; diff --git a/src/user-config.ts b/src/user-config.ts index c09f756..6d934e7 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; import { homedir } from "node:os"; -import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME } from "./config-dir.js"; import type { Prompts } from "acp-kernel"; import type { AdapterConfig, CompressConfig, DelegateConfig, HostSessionConfig, RepetitionGuardConfig } from "./config.js"; import type { DegenerationGuardConfig } from "./degeneration.js"; diff --git a/tests/host-detection.test.ts b/tests/host-detection.test.ts new file mode 100644 index 0000000..26dfdda --- /dev/null +++ b/tests/host-detection.test.ts @@ -0,0 +1,79 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { entrySourceOf, isDeclaredForkHost, isUnsupportedHost } from "../src/host.js"; +import { UNSUPPORTED_HOST_MESSAGE } from "../src/omp.js"; + +type SM = ExtensionContext["sessionManager"]; + +// Prime-shaped / OMP-shaped host: getBranch only, no buildContextEntries (#364). +const piShaped = { buildContextEntries: () => [], getBranch: () => [] } as unknown as SM; +const forkShaped = { getBranch: () => [] } as unknown as SM; +const bare = {} as unknown as SM; + +function withForkEnv(value: string | undefined, fn: () => void): void { + const prev = process.env.PI_ACP_FORK_HOST; + if (value === undefined) delete process.env.PI_ACP_FORK_HOST; + else process.env.PI_ACP_FORK_HOST = value; + try { + fn(); + } finally { + if (prev === undefined) delete process.env.PI_ACP_FORK_HOST; + else process.env.PI_ACP_FORK_HOST = prev; + } +} + +describe("entrySourceOf", () => { + test("pi host → buildContextEntries (takes precedence when both exist)", () => { + assert.equal(entrySourceOf(piShaped), "buildContextEntries"); + }); + test("Prime/OMP-shaped host → getBranch", () => { + assert.equal(entrySourceOf(forkShaped), "getBranch"); + }); + test("no known API → null", () => { + assert.equal(entrySourceOf(bare), null); + }); + test("null sessionManager → null", () => { + assert.equal(entrySourceOf(null as unknown as SM), null); + }); +}); + +describe("isDeclaredForkHost", () => { + test("unset → false", () => withForkEnv(undefined, () => assert.equal(isDeclaredForkHost(), false))); + test("\"1\" → true", () => withForkEnv("1", () => assert.equal(isDeclaredForkHost(), true))); + test("\"true\"/\"TRUE\" → true", () => { + withForkEnv("true", () => assert.equal(isDeclaredForkHost(), true)); + withForkEnv("TRUE", () => assert.equal(isDeclaredForkHost(), true)); + }); + test("\"0\"/\"yes\"/\"\" → false (strict)", () => { + withForkEnv("0", () => assert.equal(isDeclaredForkHost(), false)); + withForkEnv("yes", () => assert.equal(isDeclaredForkHost(), false)); + withForkEnv("", () => assert.equal(isDeclaredForkHost(), false)); + }); +}); + +describe("isUnsupportedHost", () => { + test("pi host never unsupported, declared or not", () => { + withForkEnv(undefined, () => assert.equal(isUnsupportedHost(piShaped), false)); + withForkEnv("1", () => assert.equal(isUnsupportedHost(piShaped), false)); + }); + test("fork-shaped host refused by default (OMP protection intact)", () => { + withForkEnv(undefined, () => assert.equal(isUnsupportedHost(forkShaped), true)); + }); + test("fork-shaped host accepted once declared", () => { + withForkEnv("1", () => assert.equal(isUnsupportedHost(forkShaped), false)); + }); + test("bare sessionManager refused regardless of declaration", () => { + withForkEnv(undefined, () => assert.equal(isUnsupportedHost(bare), true)); + withForkEnv("1", () => assert.equal(isUnsupportedHost(bare), false)); + }); +}); + +describe("UNSUPPORTED_HOST_MESSAGE", () => { + test("points at both remedies: fork opt-in and OMP proxy", () => { + assert.ok(UNSUPPORTED_HOST_MESSAGE.includes("PI_ACP_FORK_HOST")); + assert.ok(UNSUPPORTED_HOST_MESSAGE.includes("buildContextEntries")); + assert.ok(UNSUPPORTED_HOST_MESSAGE.includes("bili omp")); + assert.ok(UNSUPPORTED_HOST_MESSAGE.includes("billion-context")); + }); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts index 09d62ff..50cbe0a 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { entriesToCoreMessages, coreOutToAgentMessages, matchesStoredText, messageIdentity } from "../src/messages.js"; -import { ACP_STATUS_CUSTOM_TYPE } from "../src/turn-boundary.js"; +import { entriesToCoreMessages, coreOutToAgentMessages, isCustomMessageEntry, matchesStoredText, messageIdentity } from "../src/messages.js"; +import { ACP_STATUS_CUSTOM_TYPE } from "../src/messages.js"; +import { isTurnBoundary } from "../src/turn-boundary.js"; import type { CoreMessage } from "acp-kernel"; import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; @@ -174,6 +175,15 @@ test("entriesToCoreMessages drops custom_message with empty content", () => { assert.deepEqual(core.map((m) => m.id), ["a", "c"], "empty custom_message skipped"); }); +test("empty custom_message neither projects nor starts a turn (#364 acceptance c)", () => { + const empty = customEntry("b", "subagent_result", ""); + assert.equal(isCustomMessageEntry(empty), false, "not a context-participating entry"); + assert.equal(isTurnBoundary(empty, { countCustomMessages: true }), false, "empty control signal starts no turn"); + const full = customEntry("d", "subagent_result", "injected agent turn"); + assert.equal(isCustomMessageEntry(full), true); + assert.equal(isTurnBoundary(full, { countCustomMessages: true }), true, "non-empty injected turn starts a turn"); +}); + test("entriesToCoreMessages extracts only text blocks from array content", () => { const entries: SessionEntry[] = [ customEntry("a", "subagent_result", [ diff --git a/tests/omp-refuse.test.ts b/tests/omp-refuse.test.ts index d709133..15e12e0 100644 --- a/tests/omp-refuse.test.ts +++ b/tests/omp-refuse.test.ts @@ -1,7 +1,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { createAcpExtension } from "../src/index.js"; -import { OMP_UNSUPPORTED_MESSAGE } from "../src/omp.js"; +import { UNSUPPORTED_HOST_MESSAGE } from "../src/omp.js"; import { setRunNpmForTest } from "../src/update.js"; // Hermetic session_start: the pi (non-OMP) path runs the auto-update check, so @@ -9,6 +9,7 @@ import { setRunNpmForTest } from "../src/update.js"; setRunNpmForTest(async (args) => ({ code: 0, stdout: args[0] === "view" ? "0.0.1\n" : "", stderr: "" })); process.env.ACP_AUTO_UPDATE = "false"; delete process.env.BILLION_CONTEXT_PROXY; +delete process.env.PI_ACP_FORK_HOST; // Mock Pi's ExtensionAPI — captures the event handlers + tools the factory wires. function captureApi() { @@ -69,7 +70,7 @@ function piCtx(notify: Notify) { const startSession = (handlers: any, ctx: any) => handlers.get("session_start")![0]!({ type: "session_start", reason: "startup" }, ctx); -describe("OMP host refusal (issue #234)", () => { +describe("Unsupported-host refusal (issue #234 / #364)", () => { test("detects OMP at session_start, refuses service, warns once via UI", async () => { const { api, handlers } = captureApi(); createAcpExtension()(api as any); @@ -80,7 +81,7 @@ describe("OMP host refusal (issue #234)", () => { await startSession(handlers, ctx); assert.equal(notes.length, 1, "warns exactly once"); - assert.equal(notes[0]!.msg, OMP_UNSUPPORTED_MESSAGE); + assert.equal(notes[0]!.msg, UNSUPPORTED_HOST_MESSAGE); assert.equal(notes[0]!.type, "warning"); // Stands down: does not cancel the host's own compaction. @@ -106,7 +107,7 @@ describe("OMP host refusal (issue #234)", () => { await startSession(handlers, ctx); assert.equal(notes.length, 1, "second session_start must not re-warn"); - assert.equal(notes[0], OMP_UNSUPPORTED_MESSAGE); + assert.equal(notes[0], UNSUPPORTED_HOST_MESSAGE); }); test("all four ACP tools refuse service on OMP", async () => { @@ -119,7 +120,7 @@ describe("OMP host refusal (issue #234)", () => { const tool = api.tools.find((t: any) => t.name === name); assert.ok(tool, `${name} tool is registered`); const res = await (tool as any).execute("t1", {}, undefined, undefined, ctx); - assert.equal((res.content[0] as any).text, OMP_UNSUPPORTED_MESSAGE, `${name} refuses service`); + assert.equal((res.content[0] as any).text, UNSUPPORTED_HOST_MESSAGE, `${name} refuses service`); } }); @@ -140,7 +141,7 @@ describe("OMP host refusal (issue #234)", () => { } assert.equal(errs.length, 1, "exactly one stderr line"); - assert.equal(errs[0], OMP_UNSUPPORTED_MESSAGE); + assert.equal(errs[0], UNSUPPORTED_HOST_MESSAGE); }); test("does NOT refuse on a pi host (buildContextEntries present)", async () => { @@ -152,10 +153,28 @@ describe("OMP host refusal (issue #234)", () => { await startSession(handlers, ctx); - assert.equal(notes.filter((m) => m === OMP_UNSUPPORTED_MESSAGE).length, 0, "no OMP warning on a pi host"); + assert.equal(notes.filter((m) => m === UNSUPPORTED_HOST_MESSAGE).length, 0, "no OMP warning on a pi host"); assert.deepEqual(handlers.get("session_before_compact")![0]!({}, {}), { cancel: true }); const sp = handlers.get("before_agent_start")![0]!({ systemPrompt: "BASE" }, {}); assert.ok(sp.systemPrompt.startsWith("BASE")); assert.ok(sp.systemPrompt.includes("compress")); }); + + test("declared Pi-compatible fork (PI_ACP_FORK_HOST=1) is NOT refused (#364)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const notes: string[] = []; + const notify: Notify = (msg) => notes.push(msg); + const ctx = ompCtx(notify, true); + + process.env.PI_ACP_FORK_HOST = "1"; + try { + await startSession(handlers, ctx); + } finally { + delete process.env.PI_ACP_FORK_HOST; + } + + assert.equal(notes.filter((m) => m === UNSUPPORTED_HOST_MESSAGE).length, 0, "no refusal when fork declared"); + assert.deepEqual(handlers.get("session_before_compact")![0]!({}, {}), { cancel: true }); + }); }); diff --git a/tests/turn-boundary.test.ts b/tests/turn-boundary.test.ts index 914c725..b92c476 100644 --- a/tests/turn-boundary.test.ts +++ b/tests/turn-boundary.test.ts @@ -1,8 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { ACP_STATUS_CUSTOM_TYPE, isCustomMessageEntry } from "../src/messages.js"; import { - ACP_STATUS_CUSTOM_TYPE, - isCustomMessageEntry, isTurnBoundary, lastTurnBoundaryId, lastTurnBoundaryIndex, @@ -46,6 +45,12 @@ test("isCustomMessageEntry: matches the context-projection condition exactly", ( assert.equal(isCustomMessageEntry(user("c")), false); }); +test("isCustomMessageEntry: empty content never enters context, so never counts (#364 c)", () => { + assert.equal(isCustomMessageEntry(custom("e1", "")), false); + assert.equal(isCustomMessageEntry(custom("e2", [{ type: "image" }])), false); + assert.equal(isTurnBoundary(custom("e1", ""), { countCustomMessages: true }), false, "empty control signal starts no turn even under policy"); +}); + // Interleaved battery covering every entry kind, used for the cross-view // consistency assertions below (acceptance: the three former call sites must // agree on where the current turn starts). From 1b062bd67f9efb5c23e073ad43fe1fa626703b89 Mon Sep 17 00:00:00 2001 From: ework-daemon Date: Fri, 11 Sep 2026 01:04:03 +0800 Subject: [PATCH 3/3] fix(host): expose createRuntime, SessionRef, deriveChildState from package entrypoint (#367) --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 2 + docs/host-adapter.md | 22 ++++++---- src/index.ts | 8 ++++ tests/host-api.test.ts | 89 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 tests/host-api.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c4f0a..affe8e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,10 @@ jobs: cache: npm - run: npm ci - run: npm run typecheck - - run: npm test + # build before test: tests/host-api.test.ts resolves "billion-context-pi" + # via Node self-reference to dist/index.js (package-name-only import contract) - run: npm run build + - run: npm test pr-validation: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 842ce1f..55c6320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (master, since v0.1.38) - **fix(host): Pi-fork 宿主(Prime)可加载 + 可识别(#364 验收补充)** — Prime 0.9.4 实测两个启动阻断:① Prime loader 把 `@earendil-works/pi-coding-agent` alias 到自身构建,公共入口不导出 `CONFIG_DIR_NAME`,ESM interop 下缺失命名导出在运行时表现为 `undefined`(而非链接错误)→ `path.join()` 直接抛 "The path argument must be of type string"。新增 `src/config-dir.ts`:feature-detect 缺失/非法导出时回退 Pi 规范值 `.pi`,五个路径使用点(log/index/user-config/setup-subagent-tools/update)全部收敛到该常量;责任边界写入 docs/host-adapter.md §4(导出归宿主、回退归适配器)。② `isOmpHost = !isPiHost` 把一切无 `buildContextEntries()` 的宿主(含 Prime)一律判为 OMP 并禁用 ACP——但 OMP 与 Prime 同为 Pi fork、同为 getBranch-only 形状,形状本身无法区分。新 `src/host.ts`:`entrySourceOf`(buildContextEntries > getBranch)、`isDeclaredForkHost`(`PI_ACP_FORK_HOST=1/true`,调用时读取)、`isUnsupportedHost = !isPiHost && !declared`;OMP 默认保护不变(不声明即拒绝),声明的 fork 走既有 `!isPiHost` live-merge 补偿 getBranch 落后一条消息;delegate-tool 的 CLI flag 语义保持严格 isPiHost(fork 宿主不会用 pi-only flag 拉起子进程)。`OMP_UNSUPPORTED_MESSAGE` → `UNSUPPORTED_HOST_MESSAGE`,文案同时指向两条出路(fork opt-in / billion-context proxy);docs/omp.md(+zh)同步;CONFIGURATION(+zh)env 表增 `PI_ACP_FORK_HOST`。测试:`tests/host-detection.test.ts`(Prime-shaped fixture 矩阵)+ `tests/omp-refuse.test.ts` 增 opt-in 用例。③ 回合谓词同步收紧(验收项 c):空内容 `custom_message`(纯控制信号)不再开启新回合——与投影共用同一 `extractText` 判据,`isCustomMessageEntry` 因此移回 `src/messages.ts` 与投影同源(经 type-only import 保持无环),非空 agent_message 开启新回合、UI/control/synthetic 策略在 host-adapter §1 写明 + +- **fix(host): 包入口导出 `createRuntime` / `SessionRef` / `deriveChildState`,宿主派生契约可达(closes #367)** — #366 的 `docs/host-adapter.md` 要求内联子会话在首个 context 事件前调用 `runtime.deriveChildState(childRef, parentRef)`,但扩展的 runtime 实例是 `createAcpExtension` 工厂闭包内的私有变量:npm `exports` 只暴露 `.` → dist/index.js,`createRuntime` 仅从 src/runtime.ts 导出(dist 无对应 subpath)→ 以 `billion-context-pi` 为依赖的外部宿主(Prime 风格适配器)拿不到文档契约所需的 runtime,显式派生路径不可达,只能退回 parentSession 头隐式继承(连同 nudge 节奏逐字拷贝——正是 #366 要避免的行为)。修复:入口重新导出 `createRuntime` + 类型 `AcpRuntime`/`SessionRef` + 纯函数 `deriveChildState`(派生经 session ref 只触碰磁盘 sidecar,跨 runtime 实例天然成立);docs/host-adapter.md "API surfaces" 改写为从包入口 `import { createRuntime } from "billion-context-pi"`;CI test job 调整为 build 先于 test(新测试经 Node self-reference `import "billion-context-pi"` → dist/index.js,CI 上必须已构建);新增 `tests/host-api.test.ts` 仅从包名导入并端到端执行文档化调用(入口 typeof 检查、纯函数深拷贝/节奏重置、temp-dir sidecar 派生 + 一次性 marker + 重复派生拒绝,dist 缺失时 skip)。存量 pi 用户行为零变化 - **feat(host): 宿主多会话支持(二) — 回合边界判定统一 + 子会话状态继承(closes #364)** — #317 遗留的两个结构性缺口(记账隔离已由 #327 修复)。① 回合边界("什么消息算新回合起点")此前在三处独立判定且互不一致(tokens.ts 的 lastUserMessageId / index.ts 的 turnKey+turnStartIndex / messages.ts 的上下文条目投影):宿主以 custom_message 注入的 agent 回合进入 LLM 上下文但不算回合起点 → 多个真实回合塌缩进同一 turnKey(nudge 账本格子错位、重试上限与节流周期统计失真)。现收敛为单一谓词 `isTurnBoundary(entry, policy)` + 两个扫描助手(`src/turn-boundary.ts`),三处全部走它;新增 `hostSession` 配置(boolean 简写或 `{countCustomMessages}`,默认关闭 = pi 原生行为,存量单会话用户逐字节不变——单测以 legacy user-role-only 扫描为 oracle 断言等价)。② 内联同进程子会话(Prime RLM 等)的状态派生契约 `deriveChildState(parentState)`:继承 blocks(深拷贝)/messageRefs/tokenSnapshot(原始消息索引)/nextBlockId/nextRunId(保证继承块可 decompress/search、新块 id 不冲突),重置 nudge 节奏基线/stats/absorbed(子会话重新起算);一次性迁移标记 `derivedFrom:{parentSessionId,derivedAt}` 持久化进子 sidecar(沿用 `.jsonl.acp.json`,与父文件独立),拒绝重复派生;护栏:子会话已有自有非派生块 / 父无块 / 子无 sessionFile 时拒绝且不改任何状态;显式派生优先于隐式 parentSession 头继承(恰好升级一次)。pi 原生 delegate(独立进程)路径零变化。文档:新增 `docs/host-adapter.md`(回界契约 + 子会话派生契约),CONFIGURATION.md(+zh-CN) 增 `hostSession` 节。测试:新增 `tests/turn-boundary.test.ts`(谓词矩阵 + id/index 双视图一致性 + 默认策略 ≡ legacy 扫描回归)与 `tests/derive-child-state.test.ts`(继承/重置矩阵、深拷贝隔离、marker 往返、各护栏拒绝、内联子代理 header 场景) - **fix(degeneration): thinking/text 单字符退化熔断 + 一次性恢复通知(closes #351)** — 长会话末尾模型偶发退化为单字符长连击(实测:thinking 块末尾 4655 个连续「【」,跨轮升级直至 turn abort、会话停死)。根因链已代码级验证:pi 的 openai-completions 转换把历史 assistant thinking 在**每个后续请求**中回传 provider(`reasoning_content`,或 requiresThinkingAsText 时转纯文本),aborted turn 的部分消息又持久化在会话日志里 → 退化尾部随每轮 prompt 重放 → 模型看到自己上一轮以数千个重复字符结尾 → 续写偏置再次触发退化 → 连环 abort。新增 `src/degeneration.ts`:每个 context 事件对出站视图的 assistant text/thinking 块折叠 ≥`minRun`(默认 200,下限 8,codepoint/代理对安全)的单 codepoint 连击为短标记(保留 ≤3 份样本;纯函数、幂等——标记固定文案无相邻重复码点、fail-safe;持久化历史不改,toolCall 参数不动以免与实际执行脱钩);当最近一条 assistant 消息已退化时追加一次性 `[ACP recovery notice]`(位置自限:模型产出新 turn 后自动消失,无持久状态不累积,#223 教训)。检测走持久化 originals 而非出站视图:thinking-only aborted turn 会被 projectMessage 丢弃(空文本在 OpenAI 兼容 provider 400),但它仍是模型的"上一轮",通知必须照发。acp.json 新键 `degenerationGuard`(boolean 或 `{enabled,minRun}`,默认开;`false` 为 kill-switch)。附带修复:`repetitionGuard` 此前不在 user-config KNOWN 白名单内,acp.json 中配置被静默丢弃(dead key),本次补入。测试 `tests/degeneration.test.ts`(31 例:单元 + context transform 端到端 wiring) - **fix(reasoning): 闭合判定改按回合证据——无用户消息的长 agent 会话不再永久保留 compress thinking(closes #348)** — 原门控“compress 调用之后存在真实用户消息才算闭合”在长 agent 会话不可达(整个会话只有开头 1–2 条用户消息,后续 30 个 compress 全部被永久视为活跃回合,观察会话 0 次触发,thinking 地板 20.6K/8.4K/10.6K 字符全部滞留)。现在闭合判定改为:消息内**每个** compress toolCall 的 toolResult(role `toolResult`、`toolCallId` 匹配)已出现在更晚位置,且其后至少还有一条消息(回合已实际推进)。安全门不变:结果未返回或结果仍是最后一条消息(在飞中)绝不动;nudge 在 drop 之后才注入,不可能光当“结果后的消息”闭合在飞回合;per-provider `compress.providers..reasoning.drop=false` 逃生阀保留(GLM 等 reasoning 回显模型)。测试重写 + 新增 #348 场景(无用户消息的助手链闭合、result 悬置、result 在 call 之前、多 toolCall 部分闭合、误 id 不闭合) diff --git a/docs/host-adapter.md b/docs/host-adapter.md index a192516..b6cf216 100644 --- a/docs/host-adapter.md +++ b/docs/host-adapter.md @@ -101,18 +101,26 @@ Reset (the child starts its own clock): ### API surfaces -1. **Pure transformation** — `deriveChildState(parentState)` exported from `src/state.js`: +Both surfaces are exported from the **package entrypoint** (`import ... from "billion-context-pi"`); +no subpath imports and no access to the extension factory's internal instance: + +1. **Pure transformation** — `deriveChildState(parentState)`: `CompressionState → CompressionState`. For hosts that manage state objects themselves. -2. **Orchestrated** — `runtime.deriveChildState(childRef, parentRef) → Promise` on - the extension runtime, where a ref is `{ sessionId: string; sessionFile?: string }`. - It loads the parent state, applies the pure transformation, writes the one-time marker, - and persists to the child sidecar. Because it operates on on-disk sidecars through - session refs (no live contexts needed), it works even when the two sessions belong to - different runtime instances. +2. **Orchestrated** — `createRuntime(adapter)` returns an `AcpRuntime`; call + `runtime.deriveChildState(childRef, parentRef) → Promise` on it, where a ref is + `{ sessionId: string; sessionFile?: string }` (`SessionRef`). It loads the parent state, + applies the pure transformation, writes the one-time marker, and persists to the child + sidecar. Because it operates on on-disk sidecars through session refs (no live contexts + needed), it works even when the two sessions belong to different runtime instances — + including the extension's own private instance. Pass the same `AdapterConfig` you would + give `createAcpExtension` (an empty object suffices for derivation alone). Host-side usage (once, before the child's first context event): ```ts +import { createRuntime } from "billion-context-pi"; + +const runtime = createRuntime(adapter); // same AdapterConfig as createAcpExtension; {} also works await runtime.deriveChildState( { sessionId: childSm.getSessionId(), sessionFile: childSm.getSessionFile() ?? undefined }, { sessionId: parentSm.getSessionId(), sessionFile: parentSm.getSessionFile() ?? undefined }, diff --git a/src/index.ts b/src/index.ts index eb3a80e..179c3ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,14 @@ import { UNSUPPORTED_HOST_MESSAGE } from "./omp.js"; import { isUnsupportedHost } from "./host.js"; import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js"; +// Host-facing API for multi-session hosts (docs/host-adapter.md, #367): the +// extension keeps its own runtime instance private; hosts build their own via +// createRuntime — derivation works across instances because it only touches +// on-disk sidecars through session refs. +export { createRuntime } from "./runtime.js"; +export type { AcpRuntime, SessionRef } from "./runtime.js"; +export { deriveChildState } from "./state.js"; + type AgentMessage = SessionMessageEntry["message"]; declare const CURRENT_VERSION: string; diff --git a/tests/host-api.test.ts b/tests/host-api.test.ts new file mode 100644 index 0000000..c0baaae --- /dev/null +++ b/tests/host-api.test.ts @@ -0,0 +1,89 @@ +// Host-adapter contract reachability (docs/host-adapter.md, issue #367): every +// symbol under test is imported ONLY from the package name "billion-context-pi" +// (Node self-reference → dist/index.js), never from relative src paths. This +// pins the documented entrypoint surface; acp-kernel is used for fixtures only. +import { test, type TestContext } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInitialState, type CompressionBlock } from "acp-kernel"; + +type Entrypoint = typeof import("billion-context-pi"); + +const DIST_ENTRY = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + +async function loadEntrypoint(t: TestContext): Promise { + if (!existsSync(DIST_ENTRY)) { + t.skip("dist/index.js not found — run `npm run build` first (CI builds before testing)"); + return undefined; + } + return await import("billion-context-pi"); +} + +function makeBlock(id: string, active = true): CompressionBlock { + return { blockId: id, runId: 0, tier: 1, generation: "young", active, summary: `summary ${id}`, directMessageIds: ["msg-a"], effectiveMessageIds: ["msg-a"], survivedCount: 1, createdAt: Date.now() }; +} + +test("entrypoint exports the documented host-facing API (#367)", async (t) => { + const bcp = await loadEntrypoint(t); + if (!bcp) return; + assert.equal(typeof bcp.createAcpExtension, "function", "createAcpExtension"); + assert.equal(typeof bcp.createRuntime, "function", "createRuntime must be reachable from the package entrypoint"); + assert.equal(typeof bcp.deriveChildState, "function", "pure deriveChildState must be reachable from the package entrypoint"); +}); + +test("pure deriveChildState via entrypoint deep-copies blocks", async (t) => { + const bcp = await loadEntrypoint(t); + if (!bcp) return; + const parent = createInitialState(); + parent.blocks.push(makeBlock("b0"), makeBlock("b1", false)); + parent.nextBlockId = 3; + const child = bcp.deriveChildState(parent); + assert.deepEqual(child.blocks.map((b) => b.blockId), ["b0", "b1"]); + assert.notEqual(child.blocks[0], parent.blocks[0], "blocks copied, not shared"); + assert.equal(child.nudge.baselineTokens, 0, "rhythm ledger reset"); + assert.equal(child.nextBlockId, 3); +}); + +test("documented call: runtime.deriveChildState(childRef, parentRef) round-trips through the entrypoint", async (t) => { + const bcp = await loadEntrypoint(t); + if (!bcp) return; + const dir = await mkdtemp(path.join(tmpdir(), "acp-hostapi-")); + try { + const parentJsonl = path.join(dir, "parent.jsonl"); + const childJsonl = path.join(dir, "child.jsonl"); + const header = (id: string) => JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd: "/tmp" }) + "\n"; + await writeFile(parentJsonl, header("parent-sid"), "utf8"); + await writeFile(childJsonl, header("child-sid"), "utf8"); + + const parentState = createInitialState(); + parentState.blocks.push(makeBlock("b0"), makeBlock("b1", false)); + parentState.nextBlockId = 3; + parentState.nudge.baselineTokens = 5000; + await writeFile(`${parentJsonl}.acp.json`, JSON.stringify({ ...parentState, liveRefOrigins: [] }), "utf8"); + + const runtime = bcp.createRuntime({}); + const childRef = { sessionId: "child-sid", sessionFile: childJsonl }; + const parentRef = { sessionId: "parent-sid", sessionFile: parentJsonl }; + + assert.equal(await runtime.deriveChildState(childRef, parentRef), true, "derivation succeeds"); + + const raw = JSON.parse(await readFile(`${childJsonl}.acp.json`, "utf8")) as { + blocks: unknown[]; + nextBlockId: number; + nudge: { baselineTokens: number }; + derivedFrom?: { parentSessionId: string }; + }; + assert.equal(raw.blocks.length, 2, "inherited blocks persisted into the independent child sidecar"); + assert.equal(raw.nextBlockId, 3, "id counter carried"); + assert.equal(raw.nudge.baselineTokens, 0, "rhythm baseline reset"); + assert.equal(raw.derivedFrom?.parentSessionId, "parent-sid", "one-time derivation marker persisted"); + + assert.equal(await runtime.deriveChildState(childRef, parentRef), false, "marker present → no re-derivation"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +});