Compare commits

...

16 Commits

Author SHA1 Message Date
grayhook 2f666cab6a TODO: mark Phase 15 complete — B-003 mid-turn compaction reinject
All checklist items done: source fallback, steer delivery, kept inject
entries, 93 tests, b003-repro gate; full two-compact RPC deferred.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:04:02 +07:00
grayhook d126efe907 BACKLOG: close B-003 — Phase 15 mid-turn compaction reinject fix
Source fallback on session_compact, steer delivery when not idle, and
kept-window inject entries address lost-reinject second-compact failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:03:09 +07:00
grayhook c12a3717cd Phase 15: document mid-turn defer steer and debug for compaction none
Explain idle vs mid-turn delivery with pi-auto-compact and how to use
debug snapshots when last compaction shows none incorrectly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:02:39 +07:00
grayhook c007116316 Phase 15: B-003 post-fix E2E notes — unit gate pass, RPC deferred
Record b003-repro and npm test results; full two-compact RPC needs long
session like production repro in lost-reinject.jsonl.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:02:38 +07:00
grayhook 32e6f72eee Phase 15: add B-003 regression script with unit gate and artifact audit
Run compaction fallback, kept inject, and mid-turn steer tests; document
lost-reinject.jsonl auto→null source signature from the original bug.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:02:17 +07:00
grayhook 84535def76 Phase 15: extend debug diag with compaction source and delivery branch
Log sourceInferred, isIdle, deliveryBranch on session_compact and
mid_turn_deliver after steer for B-003 troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:01:47 +07:00
grayhook c98a1ff7be Phase 15: add mid-turn steer and before_agent_start dedup tests
Cover steer delivery clearing pending, empty pending no-op, and skip
consume when steer already ran for the same compaction entry id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:00:50 +07:00
grayhook 223836f41a Phase 15: wire mid-turn steer delivery on session_compact defer path
When agent is not idle after auto compaction, deliver pending reinject
immediately via steer instead of waiting for before_agent_start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 23:00:25 +07:00
grayhook 24a3d35c06 Phase 15: add mid-turn defer reinject via sendMessage steer
Deliver pending skills on session_compact when agent is not idle, and
skip before_agent_start consume when steer already ran for compaction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:59:59 +07:00
grayhook 37dd2211d7 Phase 15: add kept-window tests for skill-reinject:inject entries
Verify inject custom messages count as present in kept slice and are
ignored when compaction firstKeptEntryId starts after them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:58:43 +07:00
grayhook 11d0659a25 Phase 15: count skill-reinject:inject in kept-window presence check
Treat defer reinject custom_message entries like user skill blocks so
a second compaction does not plan redundant reinject for same skill.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:58:26 +07:00
grayhook a6fb292dc3 Phase 15: add compaction source fallback regression tests
Cover session_compact without prior before_compact inferring auto, and
manual /compact preserving manual source with reinject off by default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:57:43 +07:00
grayhook eba5b5dc99 Phase 15: call ensureCompactionSourceMarked on session_compact
Safety net when session_before_compact does not run so auto compaction
still sets lastCompactionSource and passes the reinject gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:57:27 +07:00
grayhook 20429a02ab Phase 15: add ensureCompactionSourceMarked for compaction source fallback
Extract shared helper so session_before_compact and session_compact can
both infer auto when compaction was not explicitly manual.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:57:09 +07:00
grayhook a07ddefb23 Phase 15: SPEC contract for mid-turn compaction reinject (B-003)
Document source fallback on session_compact, defer steer delivery when
!isIdle, kept-window skill-reinject:inject entries, and double-compact
acceptance criteria before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:55:31 +07:00
grayhook ca97660bac TODO: plan Phase 15 for mid-turn compaction reinject gap (B-003)
Document root cause and fix strategy from lost-reinject.jsonl analysis.
Add B-003 to BACKLOG; ignore *.ndjson and *.jsonl session dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:42:48 +07:00
17 changed files with 581 additions and 34 deletions
+2
View File
@@ -2,3 +2,5 @@ node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.ndjson
*.jsonl
+1 -1
View File
@@ -10,7 +10,7 @@
|---|---|
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
| Статус | **Фазы 013 завершены** (v1); E2E compaction — см. [BACKLOG B-001](./BACKLOG.md) |
| Статус | **Фазы 015 завершены** (v1 + B-002/B-003); полный RPC two-compact E2E — см. `docs/e2e-b003-post-fix.md` |
| Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports |
| Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) |
+10 -1
View File
@@ -41,12 +41,21 @@
## Открыто
_Новые пункты — ниже (следующий id: **B-003**)._
_Новые пункты — ниже (следующий id: **B-004**)._
---
## Закрыто
### B-003 · done · e2e · 2026-06-18 (закрыт 2026-06-18)
- **Сценарий:** Длинная сессия `gitlab-mr-review` + pi-auto-compact; auto compaction в ходе Phase 6 review (issue #480334)
- **Проблема:** Второй подряд auto compaction не re-inject'ит tracked skill; `/skill-reinject` status показывает `last compaction: none`
- **Место:** `session_before_compact` / `session_compact` / defer + `before_agent_start`; артефакт `lost-reinject.jsonl`
- **Факт:** Compaction #1: `lastCompactionSource: auto`, inject OK. Compaction #2 mid-turn: `lastCompactionSource: null` (no `session_before_compact`), defer consume only on user prompt → skill lost
- **Закрытие:** Phase 15 (`a07ddef``c12a371`); `ensureCompactionSourceMarked` on `session_compact`, mid-turn `sendMessage`/`steer`, kept-window `skill-reinject:inject`, debug diag; 93 unit tests; `scripts/b003-repro.mjs`; full two-compact RPC deferred (`docs/e2e-b003-post-fix.md`)
- **Предложение:** (реализовано) source fallback §8, steer delivery §6.5.1, kept custom §6.4
### B-002 · done · e2e · 2026-06-17 (закрыт 2026-06-17)
- **Сценарий:** Manual E2E §12.2 п.25, §12.3 п.37, §13 п.3/9/10 — auto compaction → re-inject tracked skills; `/skill-reinject now` как контроль
+6 -1
View File
@@ -66,7 +66,12 @@ Pi хранит в контексте только описания skills; по
## Совместимость с pi-auto-compact
Рассчитан на совместную работу с [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact). При обнаружении команды `auto-compact` re-inject идёт через `defer` + `before_agent_start`, чтобы не конкурировать с follow-up pi-auto-compact (см. [SPEC.md §16](./SPEC.md#16-совместимость-с-capyuppi-auto-compact)).
Рассчитан на совместную работу с [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact). При обнаружении команды `auto-compact` re-inject идёт через `defer`:
- **Idle** (turn boundary): очередь на `before_agent_start` — в том же turn, что follow-up pi-auto-compact, без гонки `sendUserMessage`.
- **Mid-turn** (`!isIdle`, после tool result): немедленная доставка через `sendMessage` с `deliverAs: "steer"` — skill попадает в контекст до следующего LLM-вызова без user prompt.
Если `/skill-reinject` показывает `last compaction: none` после auto compact — включите `"debug": true` в `skillReinject` и смотрите notify `skill-reinject [session_compact]`: поля `compactionSource`, `sourceInferred`, `deliveryBranch`, `isIdle`.
**Coexistence с Pi default auto-compaction:** оба механизма могут сработать в одной сессии. При использовании pi-auto-compact можно отключить встроенный compaction Pi (`"compaction.enabled": false` в settings) или оставить оба — skill-reinject отработает после каждого **auto** compaction. Extension не отключает чужой compaction; при первом обнаружении обоих механизмов показывает одноразовый hint.
+53 -20
View File
@@ -118,7 +118,7 @@ References are relative to {baseDir}.
Для N skills при доставке через `sendUserMessage`: отдельное сообщение на skill, порядок = порядок первого вызова в сессии.
При доставке через `before_agent_start` (§6.5, режим `defer`, в т.ч. с [pi-auto-compact](https://github.com/capyup/pi-auto-compact)): **одно** injected message со всеми skill-блоками подряд (меньше turn'ов, нет гонки с follow-up).
При доставке через `before_agent_start` или mid-turn `sendMessage`/`steer` (§6.5, режим `defer`, в т.ч. с [pi-auto-compact](https://github.com/capyup/pi-auto-compact)): **одно** injected message с `customType: "skill-reinject:inject"` и всеми skill-блоками подряд (меньше turn'ов, нет гонки с follow-up).
---
@@ -141,7 +141,7 @@ interface ExtensionState {
sessionOverride: boolean | null;
skills: TrackedSkill[]; // dedupe by name, preserve insertion order
lastCompactionSource: "auto" | "manual" | null;
/** Skills, ожидающие re-inject на следующем before_agent_start (§6.5) */
/** Skills, ожидающие re-inject (§6.5): idle → before_agent_start; mid-turn → steer на session_compact */
pendingReinject: string[]; // skill names
}
@@ -182,7 +182,12 @@ In-memory кэш + персистенция через `pi.appendEntry` при
Перед re-inject:
1. Взять entries от `compactionEntry.firstKeptEntryId` до хвоста ветки.
2. Для каждого tracked skill: если в kept user messages есть `<skill name="{name}"`**пропустить** re-inject для этого skill.
2. Для каждого tracked skill: если в kept window есть `<skill name="{name}"`**пропустить** re-inject для этого skill.
3. Источники текста в kept slice:
- user messages (`type === "message"`, `role === "user"`);
- наши reinject-сообщения: `type === "custom_message"` с `customType === "skill-reinject:inject"` (§6.5.1) — skill-блоки в `content`, не в user role.
Пункт 3 закрывает ложное «skill вне kept» после успешного defer-reinject: `custom_message` не user message, но skill уже в контексте turn'а. Без этого второй compaction подряд снова планирует reinject (лишний inject; см. §16.6).
### 6.5. Доставка после compaction
@@ -191,9 +196,20 @@ In-memory кэш + персистенция через `pi.appendEntry` при
#### 6.5.1. Режим `defer` (рекомендуемый по умолчанию при наличии pi-auto-compact)
1. На `session_compact`: записать имена skills в `state.pendingReinject` (не вызывать `sendUserMessage`).
2. На следующем `before_agent_start`: если `pendingReinject` не пуст — вернуть injected `message` с объединёнными skill-блоками, очистить очередь.
2. Доставка — **одна точка решения** на `session_compact` по `ctx.isIdle()`:
Skills попадают в контекст **в том же turn**, что и kickoff-сообщение (в т.ч. auto-continue от pi-auto-compact), **без гонки** `sendUserMessage`.
| Условие на `session_compact` | Доставка |
|------------------------------|----------|
| `ctx.isIdle()` | Оставить `pendingReinject`; consume на следующем `before_agent_start` (kickoff pi-auto-compact follow-up / user prompt) |
| `!ctx.isIdle()` (mid-turn compaction) | Немедленно: `pi.sendMessage({ customType: "skill-reinject:inject", content, display: true }, { deliverAs: "steer" })`; очистить `pendingReinject`; записать `compactionEntry.id` в runtime, чтобы `before_agent_start` **не** дублировал inject для этого compaction |
3. На `before_agent_start` (idle-path): если `pendingReinject` не пуст и steer для этого compaction ещё не доставлялся — вернуть injected `message` с `customType: "skill-reinject:inject"` и объединёнными skill-блоками, очистить очередь.
**Mid-turn steer:** `deliverAs: "steer"` ставит skill-блоки в очередь до следующего LLM-вызова **в том же turn** (после tool result, без user prompt). Это закрывает сценарий, когда pi-auto-compact компактирует mid-turn и **не** шлёт follow-up (агент не idle) — consume только на `before_agent_start` теряет skill до следующего сообщения пользователя (B-003).
**Не** вызывать `sendUserMessage` в `session_compact` (§16.2). Steer — не user message и не конкурирует с pi-auto-compact follow-up на turn boundary.
На turn-boundary path skills попадают в контекст **в том же turn**, что и kickoff-сообщение (в т.ч. auto-continue от pi-auto-compact), **без гонки** `sendUserMessage`.
#### 6.5.2. Режим `immediate` (`sendUserMessage`)
@@ -287,19 +303,30 @@ Extension event `session_compact` **не** передаёт `reason` ([SessionCo
**Стратегия v1:**
```typescript
// input handler (до expansion)
// input handler (до expansion) — единственный путь manual
if (text.startsWith("/compact")) pendingCompactionSource = "manual";
// session_before_compact
if (pendingCompactionSource !== "manual") pendingCompactionSource = "auto";
function ensureCompactionSourceMarked(runtime) {
if (runtime.pendingCompactionSource !== "manual") {
runtime.pendingCompactionSource = "auto";
}
}
// session_before_compact
ensureCompactionSourceMarked(runtime);
// session_compact — safety net, если before_compact не пришёл (mid-turn, гонка хуков)
ensureCompactionSourceMarked(runtime);
// session_compact
const shouldReinject =
effectiveEnabled &&
(pendingCompactionSource === "auto" || settings.reinjectOnManualCompaction);
state.lastCompactionSource = pendingCompactionSource;
pendingCompactionSource = null;
```
**Fallback на `session_compact`:** если `session_before_compact` не отработал, `pendingCompactionSource` остаётся `null` → без fallback `lastCompactionSource: null`, `shouldReinject === false`, status `last compaction: none` при реальном auto compact (B-003). Всё, что не помечено явным `/compact` в `input`, на `session_compact` считается **auto**.
Ручной RPC compact (`{type: "compact"}`) тоже помечать как `manual` если extension получает соответствующий сигнал (v2).
---
@@ -420,9 +447,10 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
- [ ] Tracked skills: `/skill:name`, skill-block в user msg, `read` на `SKILL.md`
- [ ] State переживает `/resume` той же сессии
- [ ] Footer status показывает on/off и count
- [ ] Нет duplicate skill blocks для skills уже в kept window
- [ ] Нет duplicate skill blocks для skills уже в kept window (включая `skill-reinject:inject` в kept slice, §6.4)
- [ ] С [pi-auto-compact](https://github.com/capyup/pi-auto-compact): auto-detect, режим `defer`, нет гонки `sendUserMessage`, continue после compaction работает
- [ ] С pi-auto-compact: ручной `/compact` не ломает ни один extension
- [ ] **Два auto compaction подряд** (в т.ч. второй mid-turn без user prompt): после каждого — tracked skills в контексте или явный skip (`skipped-kept` в `debug`); `/skill-reinject``last compaction: auto`, не `none` (§8 fallback, §6.5.1 steer)
---
@@ -442,7 +470,7 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
|------|-----------|
| Re-inject раздувает контекст (N больших skills) | `skillReinject.maxSkills` (default unlimited; soft warn > 3) |
| `sendUserMessage` запускает нежеланный turn | `defer` по умолчанию при pi-auto-compact; иначе `triggerTurn: false` |
| Гонка `sendUserMessage` с pi-auto-compact follow-up | §16: не слать в `session_compact`; `before_agent_start` |
| Гонка `sendUserMessage` с pi-auto-compact follow-up | §16: не слать `sendUserMessage` в `session_compact`; idle → `before_agent_start`; mid-turn → `sendMessage`/`steer` (§6.5.1) |
| Нельзя отличить auto/manual без эвристики | input hook на `/compact`; pi-auto-compact manual `/compact` не в его `onComplete` |
| Двойной compaction (Pi default + pi-auto-compact) | документировать; опционально warn в status |
| Pi изменит формат skill block | version в state; тест на `parseSkillBlock` regex |
@@ -473,7 +501,7 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
1. Агент перестаёт быть idle → pi-auto-compact **не** шлёт follow-up → **сессия замирает** после compaction.
2. Или оба шлют сообщения в одном tick → `"Agent is already processing"` ([комментарий в auto-compact.ts](https://github.com/capyup/pi-auto-compact/blob/main/extensions/auto-compact.ts)).
**Вывод:** при обнаружении pi-auto-compact re-inject **обязан** идти через `defer` + `before_agent_start`, не через конкурирующий `sendUserMessage`.
**Вывод:** при обнаружении pi-auto-compact re-inject **обязан** идти через режим `defer`, не через конкурирующий `sendUserMessage` в `session_compact`. Idle: `before_agent_start`; mid-turn (`!isIdle`): `sendMessage` с `deliverAs: "steer"` (§6.5.1) — тот же инвариант §16.5, без user message.
### 16.3. Целевой совместный flow
@@ -483,15 +511,18 @@ turn_start / turn_end / emergency
pi-auto-compact: ctx.compact()
├── session_before_compact (наш hook: только mark source=auto)
├── session_before_compact (наш hook: mark source=auto)
├── … Pi summarization …
├── session_compact (наш hook: pendingReinject := skills kept)
└── onComplete → setImmediate → sendUserMessage("Auto-compact ran…")
│ │
│ ├── isIdle → pending для before_agent_start
│ └── !isIdle → sendMessage steer (§6.5.1), без follow-up
└── onComplete → setImmediate → if isIdle: sendUserMessage("Auto-compact ran…")
prompt() → before_agent_start (наш hook)
prompt() → before_agent_start (наш hook, idle-path)
├── inject: <skill>…</skill> × N ← re-inject
├── inject: <skill>…</skill> × N ← re-inject (если не steer'нули)
└── user prompt: "Auto-compact ran…" ← kickoff pi-auto-compact
@@ -519,7 +550,7 @@ function detectPiAutoCompact(pi: ExtensionAPI): boolean {
| Значение | Поведение |
|----------|-----------|
| `"auto"` (default) | `defer` если `detectPiAutoCompact()`, иначе по `triggerTurn` |
| `"defer"` | всегда `before_agent_start`, даже без pi-auto-compact |
| `"defer"` | idle: `before_agent_start`; mid-turn (`!isIdle`): steer на `session_compact` (§6.5.1); никогда `sendUserMessage` в `session_compact` |
| `"immediate"` | всегда `sendUserMessage` (для отладки; с pi-auto-compact — риск §16.2) |
| `"off"` | игнорировать detect; только `triggerTurn` |
@@ -538,10 +569,12 @@ function detectPiAutoCompact(pi: ExtensionAPI): boolean {
| Сценарий | Результат |
|----------|-----------|
| Auto-compact + follow-up | Следующий `before_agent_start` — наш inject; skills в контексте |
| User печатает во время compaction | pi-auto-compact молчит; user prompt → наш inject на его turn |
| Auto-compact + follow-up (idle) | Следующий `before_agent_start` — наш inject; skills в контексте |
| Auto-compact **mid-turn** (`!isIdle`, нет follow-up) | Steer на `session_compact` (§6.5.1); skills до следующего LLM-вызова в том же turn |
| User печатает во время compaction | pi-auto-compact молчит; user prompt → наш inject на его turn (`before_agent_start`) |
| pi-auto-compact `keep-bookends` / `summarize-all` | Kept window определяем по `compactionEntry.firstKeptEntryId` в session branch (§6.4), не по их процентам |
| Два compaction подряд | Каждый `session_compact` пересчитывает `pendingReinject`; дедуп по kept window |
| Два compaction подряд | Каждый `session_compact` пересчитывает `pendingReinject`; source fallback §8; дедуп по kept window включая `skill-reinject:inject` (§6.4) |
| Turn-boundary compact после steer | `before_agent_start` не дублирует inject, если steer уже доставил для `compactionEntry.id` этого compaction |
### 16.7. Coexistence с Pi default auto-compaction
+42 -1
View File
@@ -106,8 +106,9 @@
| 12 | Edge cases и полировка | 711 | §11 |
| 13 | Приёмка и документация | 0–12 | §1213 |
| 14 | Re-inject для `--skill` / не-discovery skills (B-002) | 7, 9, 10, 13 | §5.2, §6.2, §11; BACKLOG B-002 |
| 15 | Mid-turn / пропущенный compaction (B-003) | 7, 8, 12, 14 | §5.2, §6.46.5, §8, §13, §16; BACKLOG B-003 |
**Порядок:** фазы 0→13; внутри фазы — сверху вниз. Параллельно после фазы 0 можно вести 1 и 2; фазы 3 и 4 — независимы друг от друга.
**Порядок:** фазы 0→13; внутри фазы — сверху вниз. Параллельно после фазы 0 можно вести 1 и 2; фазы 3 и 4 — независимы друг от друга. Фаза 15 — после 14.
---
@@ -272,6 +273,46 @@
---
### Фаза 15 — Mid-turn compaction и пропуск reinject (B-003)
Закрывает [BACKLOG B-003](./BACKLOG.md#b-003--open--e2e--2026-06-18): второй auto compaction подряд не re-inject'ит skill; status `last compaction: none`. Артефакт: `lost-reinject.jsonl` (два compaction за ~11s; первый OK, второй — нет).
**Корень (два связанных сбоя):**
1. **Gate источника compaction**`lastCompactionSource` выставляется из `pendingCompactionSource` только если до `session_compact` успел отработать `session_before_compact`. На втором compaction в логе `lastCompactionSource: null``shouldReinject = false` → очередь сброшена, reinject не планировался.
2. **Дыра defer-доставки** — consume только на `before_agent_start` (новый user prompt). Mid-turn compaction внутри активного agent loop (после toolResult, без «Auto-compact ran…») не даёт `before_agent_start` до следующего сообщения пользователя → skill теряется до ручного `/skill-reinject now`.
**Дополнительно (двойной compact):** `skillsPresentInKeptWindow` не видит `custom_message` `skill-reinject:inject` → после успешного reinject skill формально «вне kept», и при корректном gate второй compact снова планирует reinject (лишний inject, не причина пропуска в B-003, но усиливает хрупкость).
**Целевое поведение:** каждый auto `session_compact` либо re-inject'ит отсутствующие в kept skills, либо явно skip с причиной в debug; status не показывает ложное `none` после auto compact; нет гонки с pi-auto-compact на turn-boundary path.
**Стратегия доставки (две ветки, одна точка решения на `session_compact`):**
| Условие на `session_compact` | Доставка defer |
|------------------------------|----------------|
| `ctx.isIdle()` | Как сейчас: `pendingReinject` → consume на следующем `before_agent_start` (pi-auto-compact follow-up / user prompt) |
| `!ctx.isIdle()` (mid-turn) | Немедленно: `pi.sendMessage({ customType: "skill-reinject:inject", … }, { deliverAs: "steer" })` — в очередь до следующего LLM-вызова в том же turn; **не** `sendUserMessage` в `session_compact` (§16.2) |
**Стратегия source (fallback §8):** manual — только явный `/compact` в `input`; всё остальное на `session_compact` с `pendingCompactionSource === null` → считать `auto` (вызов `ensureCompactionSourceMarked` и в `session_before_compact`, и в `session_compact` как safety net).
- [x] **SPEC phase 15** — §8: fallback infer `auto` на `session_compact`; §6.5.1: mid-turn defer через `sendMessage`/`steer`; §6.4: kept учитывает `skill-reinject:inject`; §16.6: double compact + mid-turn; §13: критерий «второй compact подряд»; зачем: контракт до кода
- [x] **compaction.ts ensureSource**`ensureCompactionSourceMarked(runtime)`: если не `manual``auto`; вызывать из `markAutoCompactionBeforeCompact` и экспортировать для `session_compact`; зачем: закрыть `lastCompactionSource: null` (B-003 факт #1)
- [x] **index.ts compact source fallback** — в `handleSessionCompact` до `consumeCompactionOnSessionCompact`: `ensureCompactionSourceMarked(compactionRuntime)`; зачем: safety net когда `session_before_compact` не пришёл
- [x] **test/compaction-source-fallback.test.ts** — (a) `session_compact` без prior `before_compact`: source `auto`, `shouldReinject` true; (b) prior `manual` из input: остаётся manual, reinject off по default; зачем: регрессия gate
- [x] **kept.ts reinject custom**`skillsPresentInKeptWindow` (или сосед) учитывает entries `type: custom_message`, `customType: skill-reinject:inject` с `<skill name="…"` в `content`; зачем: §6.4, не планировать лишний reinject после успешного inject
- [x] **test/kept-window.test.ts reinject-custom** — inject custom в kept slice → skill считается present; вне slice → absent; зачем: double-compact dedup
- [x] **reinject.ts mid-turn steer**`deliverDeferredReinjectSteer(pi, planned, …)` через `pi.sendMessage` + `DEFERRED_REINJECT_CUSTOM_TYPE`, `deliverAs: "steer"`; clear `pendingReinject`; флаг `compactionRuntime.deferredDeliveredForCompactionId` (или аналог) чтобы `before_agent_start` не дублировал; зачем: B-003 факт #2, §16.2-safe
- [x] **index.ts mid-turn wire** — в defer-ветке `handleSessionCompact`: после enqueue, если `shouldReinject && planned.length > 0 && !ctx.isIdle()` → steer deliver; иначе оставить pending для `before_agent_start`; persist; зачем: единая развилка idle / mid-turn
- [x] **index.ts before_agent_start dedup**`tryConsumeDeferredReinject` / wire: skip consume если steer уже доставил для `lastCompactionFirstKeptEntryId` / compaction entry id; зачем: turn-boundary compact + pi-auto-compact не дают двойной inject
- [x] **test/reinject-mid-turn.test.ts** — mock `pi.sendMessage`: mid-turn (`isIdle: false`) → steer вызван, pending очищен; idle → steer не вызывается; после steer `before_agent_start` не inject'ит повторно; зачем: §12.1
- [x] **diag.ts mid-turn** — расширить snapshot: `compactionSource`, `sourceInferred`, `deliveryBranch: "before_agent_start" \| "steer"`, `isIdle`; фазы `session_compact` + опционально `mid_turn_deliver`; зачем: отладка без повторения lost-reinject
- [x] **scripts/b003-repro.mjs** — RPC/скрипт: симулировать или документировать repro по `lost-reinject.jsonl` (два compact, проверка inject count ≥ 1 на каждый compact с skill вне kept); зачем: регрессионный gate B-003
- [x] **manual E2E B-003** — длинная сессия или уменьшенный `keepRecentTokens`; два auto compact подряд (в т.ч. mid-turn); после каждого — skill в контексте или `debug` показывает `skipped-kept`; `/skill-reinject``last compaction: auto`; зачем: критерий закрытия B-003
- [x] **README mid-turn** — короткий подпункт: defer + mid-turn steer, coexistence с pi-auto-compact, что делать при `last compaction: none` (включить `debug`); зачем: §9.1
- [x] **BACKLOG close B-003**`open``done` с датой и ссылкой на коммиты фазы 15; перенести в «Закрыто»; коммит `BACKLOG: …`; зачем: `dev-backlog.mdc`
---
## После v1 (не блокирует фазы 0–13)
Зафиксировано в SPEC §14 — не включать в чеклист v1, только при отдельном запросе:
+33
View File
@@ -0,0 +1,33 @@
# B-003 post-fix E2E notes (Phase 15)
**Date:** 2026-06-18
**Backlog:** B-003 — second auto compaction missed reinject; `last compaction: none`
## Automated gate (this session)
```bash
node scripts/b003-repro.mjs # 22 unit tests + lost-reinject.jsonl audit
npm test # 93 tests, all green
```
**Artifact audit:** `lost-reinject.jsonl` shows pre-fix signature — compaction #2 at `07:25:14` with `lastCompactionSource: null` after compaction #1 with `auto`. Post-fix: `ensureCompactionSourceMarked` on `session_compact` infers `auto`.
## Fixes verified by unit tests
| Area | Test file |
|------|-----------|
| Source fallback when `session_before_compact` skipped | `compaction-source-fallback.test.ts` |
| `skill-reinject:inject` counts in kept window | `kept-window.test.ts` |
| Mid-turn `sendMessage`/`steer` + dedup | `reinject-mid-turn.test.ts` |
| Each compact recalculates pending | `reinject-double-compact.test.ts` |
## Full manual RPC E2E (deferred)
Reproducing two compactions in one short RPC session (like production `gitlab-mr-review` + pi-auto-compact mid-turn) needs a long context fill or reduced `keepRecentTokens`. Checklist when LLM available:
1. `/skill-reinject on` + `debug: true` in settings
2. `/skill:…` → session until two auto compactions (second mid-turn after tool use)
3. After each compact: skill in context **or** diag shows `deliveryBranch: "steer"` / `skipped-kept`
4. `/skill-reinject``last compaction: auto` (not `none`)
**Status:** unit + artifact gate pass; full two-compact RPC not run in this session (same constraint as B-002 full compact path).
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Phase 15 regression gate for B-003 (mid-turn / missed reinject).
*
* 1. Runs focused unit tests for source fallback, kept inject entries, mid-turn steer.
* 2. Audits lost-reinject.jsonl (if present) for the pre-fix failure signature.
*
* Full two-compact RPC E2E needs a long session; use manual checklist in TODO.md §15.
*/
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const artifactPath = resolve(repoRoot, "lost-reinject.jsonl");
const unitPatterns = [
"test/compaction-source-fallback.test.ts",
"test/kept-window.test.ts",
"test/reinject-mid-turn.test.ts",
"test/reinject-double-compact.test.ts",
];
function runUnitGate() {
const result = spawnSync("npm", ["test", "--", ...unitPatterns], {
cwd: repoRoot,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function auditLostReinjectArtifact() {
if (!existsSync(artifactPath)) {
console.log("b003-repro: lost-reinject.jsonl not found — skip artifact audit");
return;
}
const lines = readFileSync(artifactPath, "utf8").split("\n").filter(Boolean);
const stateEntries = [];
const injectEntries = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.customType === "skill-reinject:state" && entry.data) {
stateEntries.push({
id: entry.id,
ts: entry.timestamp,
lastCompactionSource: entry.data.lastCompactionSource,
pendingReinject: entry.data.pendingReinject ?? [],
});
}
if (entry.type === "custom_message" && entry.customType === "skill-reinject:inject") {
injectEntries.push({ id: entry.id, ts: entry.timestamp });
}
} catch {
// skip malformed lines
}
}
const nullSourceAfterAuto = stateEntries.filter(
(entry, index) =>
index > 0 &&
entry.lastCompactionSource === null &&
stateEntries[index - 1]?.lastCompactionSource === "auto",
);
console.log("b003-repro artifact audit:");
console.log(` state snapshots: ${stateEntries.length}`);
console.log(` skill-reinject:inject messages: ${injectEntries.length}`);
console.log(` auto→null source transitions (pre-fix bug): ${nullSourceAfterAuto.length}`);
if (nullSourceAfterAuto.length > 0) {
console.log(" sample null-source entry:", JSON.stringify(nullSourceAfterAuto[0]));
console.log(
" post-fix expectation: ensureCompactionSourceMarked on session_compact → lastCompactionSource: auto",
);
}
}
console.log("b003-repro: running unit regression gate…");
runUnitGate();
auditLostReinjectArtifact();
console.log("b003-repro: PASS (unit gate)");
+13 -2
View File
@@ -8,6 +8,10 @@ export interface CompactionRuntime {
clearPendingReinjectOnNextUserInput: boolean;
/** Last compact firstKeptEntryId for debug kept-window snapshots (Phase 14). */
lastCompactionFirstKeptEntryId: string | null;
/** Last compaction entry id on session_compact (Phase 15 / B-003 dedup). */
lastCompactionEntryId: string | null;
/** Compaction id when mid-turn steer already delivered reinject (Phase 15 / §6.5.1). */
deferredDeliveredForCompactionId: string | null;
}
export function createCompactionRuntime(): CompactionRuntime {
@@ -15,6 +19,8 @@ export function createCompactionRuntime(): CompactionRuntime {
pendingCompactionSource: null,
clearPendingReinjectOnNextUserInput: false,
lastCompactionFirstKeptEntryId: null,
lastCompactionEntryId: null,
deferredDeliveredForCompactionId: null,
};
}
@@ -25,13 +31,18 @@ export function markManualCompactionFromInput(text: string, runtime: CompactionR
}
}
/** session_before_compact: default to auto unless input already marked manual (SPEC §8). */
export function markAutoCompactionBeforeCompact(runtime: CompactionRuntime): void {
/** If not explicitly manual, mark compaction source as auto (SPEC §8). */
export function ensureCompactionSourceMarked(runtime: CompactionRuntime): void {
if (runtime.pendingCompactionSource !== "manual") {
runtime.pendingCompactionSource = "auto";
}
}
/** session_before_compact: default to auto unless input already marked manual (SPEC §8). */
export function markAutoCompactionBeforeCompact(runtime: CompactionRuntime): void {
ensureCompactionSourceMarked(runtime);
}
/** Gate re-inject on session_compact from enabled layer and compaction source (SPEC §8). */
export function shouldReinjectAfterCompaction(
sessionOverride: boolean | null,
+17 -3
View File
@@ -1,16 +1,28 @@
import type { ExtensionContext, Skill } from "@earendil-works/pi-coding-agent";
import type { SkillReinjectSettings } from "./settings.js";
import type { ExtensionState } from "./state.js";
import type { CompactionSource, ExtensionState } from "./state.js";
export type ReinjectDiagPhase = "session_compact" | "before_agent_start";
export type ReinjectDiagPhase = "session_compact" | "before_agent_start" | "mid_turn_deliver";
/** Filter snapshot for debug logging (Phase 14 / B-002). */
/** Optional compaction/delivery context for debug snapshots (Phase 15 / B-003). */
export interface ReinjectDiagContext {
compactionSource?: CompactionSource | null;
sourceInferred?: boolean;
deliveryBranch?: "before_agent_start" | "steer" | "none";
isIdle?: boolean;
}
/** Filter snapshot for debug logging (Phase 14 / B-002, Phase 15 / B-003). */
export interface ReinjectDiagSnapshot {
tracked: string[];
kept: string[];
registered: string[];
planned: string[];
pending: string[];
compactionSource?: CompactionSource | null;
sourceInferred?: boolean;
deliveryBranch?: "before_agent_start" | "steer" | "none";
isIdle?: boolean;
}
export function buildReinjectDiagSnapshot(
@@ -18,6 +30,7 @@ export function buildReinjectDiagSnapshot(
registeredSkills: readonly Pick<Skill, "name">[],
keptPresent: ReadonlySet<string>,
planned: readonly string[],
context?: ReinjectDiagContext,
): ReinjectDiagSnapshot {
return {
tracked: state.skills.map((skill) => skill.name),
@@ -25,6 +38,7 @@ export function buildReinjectDiagSnapshot(
registered: registeredSkills.map((skill) => skill.name),
planned: [...planned],
pending: [...state.pendingReinject],
...context,
};
}
+51 -2
View File
@@ -11,6 +11,7 @@ import { registerSkillReinjectCommand, updateSkillReinjectStatusLine } from "./c
import {
consumeCompactionOnSessionCompact,
createCompactionRuntime,
ensureCompactionSourceMarked,
markAutoCompactionBeforeCompact,
markManualCompactionFromInput,
} from "./compaction.js";
@@ -23,6 +24,7 @@ import {
import {
applyPendingReinjectAfterCompact,
clearPendingReinjectOnUserPrompt,
deliverDeferredReinjectSteer,
planDeferredReinject,
planReinject,
sendImmediateReinjectAllFollowUp,
@@ -100,9 +102,14 @@ export default function skillReinject(pi: ExtensionAPI): void {
const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills);
const branch = ctx.sessionManager.getBranch();
compactionRuntime.lastCompactionFirstKeptEntryId = event.compactionEntry.firstKeptEntryId;
compactionRuntime.lastCompactionEntryId = event.compactionEntry.id;
compactionRuntime.deferredDeliveredForCompactionId = null;
const trackedNames = state.skills.map((skill) => skill.name);
const keptEntries = getKeptEntries(branch, event.compactionEntry.firstKeptEntryId);
const keptPresent = skillsPresentInKeptWindow(keptEntries, trackedNames);
const sourceBeforeMark = compactionRuntime.pendingCompactionSource;
ensureCompactionSourceMarked(compactionRuntime);
const sourceInferred = sourceBeforeMark === null;
const shouldReinject = consumeCompactionOnSessionCompact(
compactionRuntime,
state,
@@ -116,14 +123,52 @@ export default function skillReinject(pi: ExtensionAPI): void {
: planReinject(state, settings, ctx, event, skills);
applyPendingReinjectAfterCompact(state, compactionRuntime, shouldReinject, planned);
const isIdle = ctx.isIdle();
const deliveryBranch: "before_agent_start" | "steer" | "none" =
!shouldReinject || planned.length === 0
? "none"
: deliveryMode === "defer" && !isIdle
? "steer"
: deliveryMode === "defer"
? "before_agent_start"
: "none";
notifyReinjectDiag(
ctx,
settings,
"session_compact",
buildReinjectDiagSnapshot(state, skills, keptPresent, planned),
buildReinjectDiagSnapshot(state, skills, keptPresent, planned, {
compactionSource: state.lastCompactionSource,
sourceInferred,
deliveryBranch,
isIdle,
}),
);
if (deliveryMode === "defer") {
if (shouldReinject && planned.length > 0 && !isIdle) {
const steered = deliverDeferredReinjectSteer(
pi,
state,
settings,
skills,
compactionRuntime,
event.compactionEntry.id,
ctx,
);
if (steered) {
notifyReinjectDiag(
ctx,
settings,
"mid_turn_deliver",
buildReinjectDiagSnapshot(state, skills, keptPresent, planned, {
compactionSource: state.lastCompactionSource,
sourceInferred,
deliveryBranch: "steer",
isIdle,
}),
);
}
}
persistState();
return;
}
@@ -185,7 +230,11 @@ export default function skillReinject(pi: ExtensionAPI): void {
ctx,
settings,
"before_agent_start",
buildReinjectDiagSnapshot(state, skills, keptPresent, []),
buildReinjectDiagSnapshot(state, skills, keptPresent, [], {
deliveryBranch:
state.pendingReinject.length > 0 ? "before_agent_start" : "none",
isIdle: ctx.isIdle(),
}),
);
const pendingBefore = state.pendingReinject.length;
const deferred = tryConsumeDeferredReinject(
+14 -3
View File
@@ -2,6 +2,9 @@ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import { parseSkillBlocksFromText } from "./detect.js";
import type { TrackedSkill } from "./state.js";
/** custom_message type for deferred re-inject delivery (SPEC §6.5.1). */
const SKILL_REINJECT_INJECT_CUSTOM_TYPE = "skill-reinject:inject";
/** Branch slice from firstKeptEntryId through tail (SPEC §6.4). */
export function getKeptEntries(branch: SessionEntry[], firstKeptEntryId: string): SessionEntry[] {
const startIndex = branch.findIndex((entry) => entry.id === firstKeptEntryId);
@@ -31,7 +34,7 @@ function extractUserMessageText(content: unknown): string {
return parts.join("\n");
}
/** Skill names already present as blocks in kept user messages (SPEC §6.4). */
/** Skill names already present as blocks in kept user messages and reinject custom messages (SPEC §6.4). */
export function skillsPresentInKeptWindow(
keptEntries: SessionEntry[],
skillNames: readonly string[],
@@ -43,10 +46,18 @@ export function skillsPresentInKeptWindow(
}
for (const entry of keptEntries) {
if (entry.type !== "message" || entry.message.role !== "user") {
let text = "";
if (entry.type === "message" && entry.message.role === "user") {
text = extractUserMessageText(entry.message.content);
} else if (
entry.type === "custom_message" &&
entry.customType === SKILL_REINJECT_INJECT_CUSTOM_TYPE &&
typeof entry.content === "string"
) {
text = entry.content;
} else {
continue;
}
const text = extractUserMessageText(entry.message.content);
for (const block of parseSkillBlocksFromText(text)) {
if (namesToCheck.has(block.name)) {
present.add(block.name);
+46
View File
@@ -352,6 +352,46 @@ export function filterPendingReinjectForConsume(
return resolved;
}
/**
* Mid-turn defer delivery via sendMessage/steer (SPEC §6.5.1, Phase 15 / B-003).
* Clears pendingReinject and records compaction id so before_agent_start does not duplicate.
*/
export function deliverDeferredReinjectSteer(
pi: ExtensionAPI,
state: ExtensionState,
settings: SkillReinjectSettings,
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
compactionRuntime: CompactionRuntime,
compactionEntryId: string,
ctx?: ExtensionContext,
): boolean {
if (state.pendingReinject.length === 0) {
return false;
}
const pendingNames = filterPendingReinjectForConsume(
state.pendingReinject,
state,
settings,
registeredSkills,
ctx,
);
const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx);
state.pendingReinject = [];
if (!content) {
return false;
}
pi.sendMessage(
{
customType: DEFERRED_REINJECT_CUSTOM_TYPE,
content,
display: true,
},
{ deliverAs: "steer" },
);
compactionRuntime.deferredDeliveredForCompactionId = compactionEntryId;
return true;
}
/**
* Defer path on before_agent_start: inject one combined message, then clear queue (SPEC §6.5.1).
* Returns undefined when pendingReinject is empty or manual compaction scheduled a clear (SPEC §16.5).
@@ -366,6 +406,12 @@ export function tryConsumeDeferredReinject(
if (compactionRuntime?.clearPendingReinjectOnNextUserInput) {
return undefined;
}
if (
compactionRuntime?.deferredDeliveredForCompactionId &&
compactionRuntime.deferredDeliveredForCompactionId === compactionRuntime.lastCompactionEntryId
) {
return undefined;
}
if (state.pendingReinject.length === 0) {
return undefined;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
consumeCompactionOnSessionCompact,
createCompactionRuntime,
ensureCompactionSourceMarked,
markManualCompactionFromInput,
} from "../src/compaction";
import { createDefaultSettings } from "../src/settings";
import { createInitialState } from "../src/state";
describe("compaction source fallback on session_compact", () => {
it("infers auto when session_compact runs without prior session_before_compact", () => {
const runtime = createCompactionRuntime();
const state = createInitialState();
expect(runtime.pendingCompactionSource).toBeNull();
ensureCompactionSourceMarked(runtime);
const shouldReinject = consumeCompactionOnSessionCompact(
runtime,
state,
true,
createDefaultSettings(),
);
expect(shouldReinject).toBe(true);
expect(state.lastCompactionSource).toBe("auto");
expect(runtime.pendingCompactionSource).toBeNull();
});
it("keeps manual source from input and skips reinject by default", () => {
const runtime = createCompactionRuntime();
const state = createInitialState();
markManualCompactionFromInput("/compact", runtime);
ensureCompactionSourceMarked(runtime);
const shouldReinject = consumeCompactionOnSessionCompact(
runtime,
state,
true,
createDefaultSettings(),
);
expect(shouldReinject).toBe(false);
expect(state.lastCompactionSource).toBe("manual");
expect(runtime.clearPendingReinjectOnNextUserInput).toBe(true);
});
});
+10
View File
@@ -22,6 +22,12 @@ describe("buildReinjectDiagSnapshot", () => {
[{ name: "beta" }],
new Set(["gamma"]),
["alpha"],
{
compactionSource: "auto",
sourceInferred: true,
deliveryBranch: "steer",
isIdle: false,
},
),
).toEqual({
tracked: ["alpha"],
@@ -29,6 +35,10 @@ describe("buildReinjectDiagSnapshot", () => {
registered: ["beta"],
planned: ["alpha"],
pending: ["alpha"],
compactionSource: "auto",
sourceInferred: true,
deliveryBranch: "steer",
isIdle: false,
});
});
});
+38
View File
@@ -42,6 +42,18 @@ function compactionEntry(id: string, firstKeptEntryId: string): SessionEntry {
} as SessionEntry;
}
function reinjectCustomMessage(id: string, content: string): SessionEntry {
return {
type: "custom_message",
id,
parentId: null,
timestamp: ts,
customType: "skill-reinject:inject",
content,
display: true,
} as SessionEntry;
}
const skillBlock = (name: string) =>
`<skill name="${name}" location="/skills/${name}/SKILL.md">\nbody\n</skill>`;
@@ -94,6 +106,32 @@ describe("skillsPresentInKeptWindow", () => {
expect(skillsPresentInKeptWindow([userMessage("k1", skillBlock("alpha"))], [])).toEqual(new Set());
expect(skillsPresentInKeptWindow([], ["alpha", "beta"])).toEqual(new Set());
});
it("detects skill blocks in skill-reinject:inject custom messages", () => {
const kept = [reinjectCustomMessage("inj1", skillBlock("beta"))];
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set(["beta"]));
});
it("ignores reinject custom outside kept slice when slicing branch", () => {
const branch: SessionEntry[] = [
reinjectCustomMessage("inj0", skillBlock("alpha")),
userMessage("e2", "kept start"),
compactionEntry("e4", "e2"),
];
const kept = getKeptEntries(branch, "e2");
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set());
expect(skillsPresentInKeptWindow(kept, ["alpha"])).toEqual(new Set());
});
it("counts reinject custom inside kept slice", () => {
const branch: SessionEntry[] = [
userMessage("e2", "kept start"),
reinjectCustomMessage("inj1", skillBlock("alpha")),
compactionEntry("e4", "e2"),
];
const kept = getKeptEntries(branch, "e2");
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set(["alpha"]));
});
});
describe("filterSkillsNeedingReinjectByKept", () => {
+116
View File
@@ -0,0 +1,116 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCompactionRuntime } from "../src/compaction";
import { DEFERRED_REINJECT_CUSTOM_TYPE, deliverDeferredReinjectSteer, tryConsumeDeferredReinject } from "../src/reinject";
import { createDefaultSettings } from "../src/settings";
import { createInitialState, trackSkill } from "../src/state";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function tempSkillDir(name: string): { filePath: string; baseDir: string } {
const root = mkdtempSync(join(tmpdir(), "pi-skill-reinject-mid-turn-"));
tempDirs.push(root);
const baseDir = join(root, name);
mkdirSync(baseDir, { recursive: true });
const filePath = join(baseDir, "SKILL.md");
writeFileSync(filePath, `---\nname: ${name}\n---\n# body\n`, "utf8");
return { filePath, baseDir };
}
describe("deliverDeferredReinjectSteer", () => {
it("sends steer message and clears pending when skills resolve", () => {
const { filePath, baseDir } = tempSkillDir("alpha");
const runtime = createCompactionRuntime();
const state = createInitialState();
trackSkill(state, { name: "alpha", filePath, baseDir, source: "slash" });
state.pendingReinject = ["alpha"];
const sendMessage = vi.fn();
const pi = { sendMessage } as never;
const registered = [{ name: "alpha", filePath, baseDir }];
const delivered = deliverDeferredReinjectSteer(
pi,
state,
createDefaultSettings(),
registered,
runtime,
"compact-1",
);
expect(delivered).toBe(true);
expect(state.pendingReinject).toEqual([]);
expect(runtime.deferredDeliveredForCompactionId).toBe("compact-1");
expect(sendMessage).toHaveBeenCalledOnce();
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: DEFERRED_REINJECT_CUSTOM_TYPE,
display: true,
content: expect.stringContaining('<skill name="alpha"'),
}),
{ deliverAs: "steer" },
);
});
it("returns false without sendMessage when pending is empty", () => {
const runtime = createCompactionRuntime();
const state = createInitialState();
const sendMessage = vi.fn();
const pi = { sendMessage } as never;
expect(
deliverDeferredReinjectSteer(pi, state, createDefaultSettings(), [], runtime, "compact-1"),
).toBe(false);
expect(sendMessage).not.toHaveBeenCalled();
});
});
describe("tryConsumeDeferredReinject after mid-turn steer", () => {
it("skips before_agent_start inject when steer already delivered for compaction", () => {
const { filePath, baseDir } = tempSkillDir("beta");
const runtime = createCompactionRuntime();
runtime.lastCompactionEntryId = "compact-2";
runtime.deferredDeliveredForCompactionId = "compact-2";
const state = createInitialState();
trackSkill(state, { name: "beta", filePath, baseDir, source: "slash" });
state.pendingReinject = ["beta"];
expect(
tryConsumeDeferredReinject(
state,
createDefaultSettings(),
[{ name: "beta", filePath, baseDir }],
undefined,
runtime,
),
).toBeUndefined();
});
it("consumes pending on before_agent_start when steer did not run (idle path)", () => {
const { filePath, baseDir } = tempSkillDir("gamma");
const runtime = createCompactionRuntime();
runtime.lastCompactionEntryId = "compact-3";
const state = createInitialState();
trackSkill(state, { name: "gamma", filePath, baseDir, source: "slash" });
state.pendingReinject = ["gamma"];
const result = tryConsumeDeferredReinject(
state,
createDefaultSettings(),
[{ name: "gamma", filePath, baseDir }],
undefined,
runtime,
);
expect(result?.message.customType).toBe(DEFERRED_REINJECT_CUSTOM_TYPE);
expect(state.pendingReinject).toEqual([]);
expect(runtime.deferredDeliveredForCompactionId).toBeNull();
});
});