Compare commits
16 Commits
7665096601
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f666cab6a | |||
| d126efe907 | |||
| c12a3717cd | |||
| c007116316 | |||
| 32e6f72eee | |||
| 84535def76 | |||
| c98a1ff7be | |||
| 223836f41a | |||
| 24a3d35c06 | |||
| 37dd2211d7 | |||
| 11d0659a25 | |||
| a6fb292dc3 | |||
| eba5b5dc99 | |||
| 20429a02ab | |||
| a07ddefb23 | |||
| ca97660bac |
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.ndjson
|
||||
*.jsonl
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|---|---|
|
||||
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
|
||||
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
|
||||
| Статус | **Фазы 0–13 завершены** (v1); E2E compaction — см. [BACKLOG B-001](./BACKLOG.md) |
|
||||
| Статус | **Фазы 0–15 завершены** (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
@@ -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 п.2–5, §12.3 п.3–7, §13 п.3/9/10 — auto compaction → re-inject tracked skills; `/skill-reinject now` как контроль
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -106,8 +106,9 @@
|
||||
| 12 | Edge cases и полировка | 7–11 | §11 |
|
||||
| 13 | Приёмка и документация | 0–12 | §12–13 |
|
||||
| 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.4–6.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, только при отдельном запросе:
|
||||
|
||||
@@ -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).
|
||||
Executable
+82
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user