From 9d7aa1ad7b0784b90fd3cae87eea55b9faa8d3eb Mon Sep 17 00:00:00 2001 From: ework-agent Date: Fri, 4 Sep 2026 19:16:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20tier-aware=20cadence=20reset=20?= =?UTF-8?q?=E2=80=94=20T1=20captures=20no=20longer=20re-arm=20the=20T2/T3?= =?UTF-8?q?=20growthFloor=20wait=20(issue=20#364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-04_t2-cadence-capture-fix/REQ.md | 55 ++++++ .../WORKLOG.md | 42 +++++ lib/messages/inject/inject.ts | 24 ++- lib/messages/query.ts | 75 ++++++++ tests/inject.test.ts | 173 ++++++++++++++++++ tests/query-pure.test.ts | 110 ++++++++++- 6 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 devlog/2026-09-04_t2-cadence-capture-fix/REQ.md create mode 100644 devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md diff --git a/devlog/2026-09-04_t2-cadence-capture-fix/REQ.md b/devlog/2026-09-04_t2-cadence-capture-fix/REQ.md new file mode 100644 index 00000000..e7584fe4 --- /dev/null +++ b/devlog/2026-09-04_t2-cadence-capture-fix/REQ.md @@ -0,0 +1,55 @@ +# REQ - T2 distillation starved by per-compress cadence reset (issue #364 P1) + +## Context + +Source: issue #364 (ranxianglei/opencode-acp). A 21-day hub session (glm-5.3, limit=1M, +v1.14.x) fired Tier-2 distillation only 3 times; tier-1 quality crossed the 50K trigger +threshold 4 times, twice with 12~22h delays while remaining above threshold (23 and 25 +T1 captures in between). + +## Root cause (verified on current master) + +`lib/messages/inject/inject.ts` compress-processing handler (`:117-179`): every NEW +compress message — regardless of tier — reset the tier cadence baselines: + +```ts +state.nudges.lastTier2NudgeTokens = currentTokens +state.nudges.lastTier3NudgeTokens = currentTokens +``` + +The reset was introduced by #235 to stop T2/T3 re-trigger loops (undefined baseline = +"never fired" → immediate re-fire). But it also fires for raw-message T1 captures, +which INCREASE tier-1 quality instead of consuming it. In compression-active sessions +every T1 capture re-arms the growthFloor wait (22.5K on defaults), so T2 can only fire +in the gap between two T1 captures — systematic distillation starvation. + +## Scope decision + +- THIS PR — fix #2 of the issue (tier-aware cadence reset). Confirmed live bug, auto-fixes + the 12~22h delays, no product decisions required. +- DEFERRED — fix #1 (decouple T2/T3 trigger threshold from `nudgeGrowthTokens`, new config + field + default value). Needs the owner's ruling on the default (absolute ~20K vs + anchor-count >= 12 vs dual whichever-first); ~8-file config surface. Fast follow-up. +- DEFERRED — fix #3 (tier checks also run on T1-nudge turns): after this fix T1 nudges + are spaced by growthFloor, so the residual T2 delay is one turn, not hours. +- DEFERRED — fix #4 (pointer-ize consumed anchors): separate issue. + +## Design + +Classify the just-processed compress call by its range-boundary prefix — the convention +already documented in `lib/compress/state.ts:81-83` ("m-prefix = T1 capture; b-prefix = +T2+ distilling summaries"): + +- `mNNNNN` boundaries only → raw-message capture → do NOT touch tier baselines. +- any `bN` boundary → real distillation/condensation → reset baselines (preserve #235). +- unparsable/missing boundaries → conservative: reset (loop-prevention wins). + +## Acceptance criteria + +- [x] T1 capture compress leaves `lastTier2NudgeTokens`/`lastTier3NudgeTokens` unchanged. +- [x] Block-ref distill compress still resets them (and never to `undefined`) — #235 lock. +- [x] Existing #235 regression test (inject.test.ts phase 1-3) still green. +- [x] §5.7: multi-turn, side-effect assertions on baselines, production config + (`preserveRecentMessages > 0`), growth-cycle test. +- [x] New tests FAIL when the fix is reverted (verified by temporarily disabling the guard). +- [x] typecheck + build + full suite green. diff --git a/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md b/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md new file mode 100644 index 00000000..97a5f77e --- /dev/null +++ b/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md @@ -0,0 +1,42 @@ +# WORKLOG - T2 distillation starved by per-compress cadence reset (issue #364 P1) + +## Changes + +| File | Change | +| --- | --- | +| `lib/messages/query.ts` | New `isCaptureOnlyCompress` (boundary-prefix classifier) + `extractCompressBoundaryIds` (tolerant input reader: object or JSON-string). Conservative false on unparsable input. | +| `lib/messages/inject/inject.ts` | Import `isCaptureOnlyCompress`; wrap the tier-baseline reset in `if (!isCaptureOnlyCompress(lastCompressMsg))` — captures no longer move the baselines. | +| `tests/query-pure.test.ts` | +11 unit tests for the classifier (m/b/mixed/string/malformed/whitespace/user/non-compress/undefined). | +| `tests/inject.test.ts` | +3 integration tests: capture multi-turn baseline-hold (§5.7, production preserve-recent knobs), distill reset contrast (#235 lock), full cadence cycle (capture → baseline held → growth → T2 fires). | + +## Verification + +- Full suite: 1091/1091 pass (`npm run test`). +- typecheck (`tsc --noEmit`) + build (`tsup`) green. +- Fail-without-fix (§5.7.3): temporarily set `captureOnly = false` → + `issue #364 P1` and `issue #364 cycle` tests FAIL; the distill contrast test and all + legacy tests stay green; fix then re-applied. +- Existing #235 regression test unaffected: its compress fixture uses `input: {}` → + classifier returns conservative false → reset still happens. + +## Notes / decisions + +- Detection reads the last compress message's tool-part input boundaries; matches the + documented convention in `lib/compress/state.ts:81-83`. No new state fields, no + persisted-format change. +- Mixed `m`+`b` batch treated as distillation (conservative for loop-prevention). +- `lastTier3NudgeTokens` also moves on any real distillation (T2 or T3) — harmless: + the T3 threshold gate (`tier2Tokens >= nudgeGrowthTokens`) independently prevents + premature T3 firing. +- During the cycle test the pre-existing downward baseline correction + (`inject.ts:294-302`) legitimately re-anchors `lastPerMessageNudgeTokens` to + currentTokens — asserted explicitly to document the interaction. +- Environment hiccup during work: workspace volume hit ENOSPC mid-task; resumed after + ~300 MB freed; one edit was silently truncated (lost a `})`) and was repaired by the + syntax-error bisect (esbuild "Unexpected end of file" → structure map → restored). + +## Follow-ups + +- Fix #1 (decouple T2/T3 threshold; new `tierTriggerTokens` config field) — waiting on + owner's default-value ruling (issue #364 discussion; interacts with #300). +- Fix #3 (tier checks on T1-nudge turns) — optional, residual delay is one turn now. diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 35f96a12..c41e4e4c 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -12,6 +12,7 @@ import { isProtectedUserMessage, messageHasCompress, messageHasCompressAttempt, + isCaptureOnlyCompress, } from "../query" import { saveSessionState } from "../../state/persistence" import { @@ -130,14 +131,21 @@ export const injectCompressNudges = ( state.nudges.iterationNudgeAnchors.clear() state.nudges.lastNudgeShownTokens = undefined state.nudges.lastToolOutputNudgeTokens = undefined - // Preserve tier cadence baselines instead of resetting to undefined. - // Resetting to undefined causes T2/T3 to immediately re-trigger on - // the next turn (cadence check treats undefined as "never fired"), - // creating a loop: T2 fires → compress attempted → baseline reset - // → T2 fires again. Set to currentTokens so the growthFloor gate - // applies naturally. - state.nudges.lastTier2NudgeTokens = currentTokens - state.nudges.lastTier3NudgeTokens = currentTokens + // Preserve tier cadence baselines instead of resetting to undefined + // (undefined = "never fired" → T2/T3 re-trigger immediately after + // their own compress — issue #235). Set to currentTokens so the + // growthFloor gate applies from here on. + // + // But only real distillations/condensations (block-ref boundaries) + // may move the baselines. A raw-message T1 capture only ADDS + // tier-1 summaries; resetting after every capture re-arms the + // growthFloor wait — that is what starves T2 in compression-active + // sessions (issue #364 P1). + const captureOnly = isCaptureOnlyCompress(lastCompressMsg) + if (!captureOnly) { + state.nudges.lastTier2NudgeTokens = currentTokens + state.nudges.lastTier3NudgeTokens = currentTokens + } const currentTurnHasSuccessfulCompress = messages .slice(currentTurnStart) diff --git a/lib/messages/query.ts b/lib/messages/query.ts index 7ec7bd16..f09bd979 100644 --- a/lib/messages/query.ts +++ b/lib/messages/query.ts @@ -64,6 +64,81 @@ export const messageHasCompressAttempt = (message: WithParts): boolean => { return parts.some((part) => part.type === "tool" && part.tool === "compress") } +/** + * Classifies a compress tool call by its range boundaries to detect whether it is + * a raw-message capture (T1) or a summary distillation/condensation (T2/T3). + * + * Returns `true` ONLY when the call is positively identified as a T1 capture: at + * least one range boundary is present and NONE of them is a block ref (`bN`). A + * block-ref boundary means the call consumes existing summaries (T2/T3); those + * must keep resetting the tier cadence baselines to prevent re-trigger loops + * (issue #235). A pure message capture (`mNNNNN` boundaries) only ADDS tier-1 + * summaries, so resetting the baselines after every capture is what starves T2 + * distillation (issue #364 P1) — callers skip the reset for these. + * + * Returns `false` when any boundary is a block ref OR when no parsable boundary + * is found (conservative: preserve the loop-prevention reset). + */ +export const isCaptureOnlyCompress = (message: WithParts | undefined): boolean => { + if (!isMessageWithInfo(message)) { + return false + } + if (message.info.role !== "assistant") { + return false + } + + const parts = Array.isArray(message.parts) ? message.parts : [] + let sawBoundary = false + for (const part of parts) { + if (!(part.type === "tool" && part.tool === "compress")) { + continue + } + for (const startId of extractCompressBoundaryIds(part.state?.input)) { + sawBoundary = true + if (/^b\d+$/i.test(startId)) { + return false // any block-ref boundary → T2/T3 distillation/condensation + } + } + } + // >=1 boundary present and none were block refs → pure raw-message T1 capture. + return sawBoundary +} + +/** + * Extracts the startId/endId boundary refs from a compress tool part's input. + * The input may arrive as a parsed object or a JSON string; malformed shapes + * yield an empty list rather than throwing. + */ +function extractCompressBoundaryIds(rawInput: unknown): string[] { + let content: unknown[] = [] + if (typeof rawInput === "string") { + try { + const parsed: unknown = JSON.parse(rawInput) + const c = (parsed as { content?: unknown })?.content + content = Array.isArray(c) ? (c as unknown[]) : [] + } catch { + return [] + } + } else if (rawInput && typeof rawInput === "object") { + const c = (rawInput as { content?: unknown }).content + content = Array.isArray(c) ? (c as unknown[]) : [] + } + + const ids: string[] = [] + for (const entry of content) { + if (!entry || typeof entry !== "object") { + continue + } + const { startId, endId } = entry as { startId?: unknown; endId?: unknown } + for (const sid of [startId, endId]) { + if (typeof sid === "string" && sid.trim() !== "") { + ids.push(sid.trim()) + } + } + } + return ids +} + export const isIgnoredUserMessage = (message: WithParts): boolean => { if (!isMessageWithInfo(message)) { return false diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 46aae64b..1eb39153 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -2600,3 +2600,176 @@ test("issue #344: per-model floor holds in production config across the full gro assert.equal(state.nudges.lastNudgeShownTokens, 400_000, "turn 5: lastNudgeShownTokens KEPT (resetting it reintroduces the nudge loop)") assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 5: baseline NOT reset by nothingToCompress (#207 regression lock)") }) + +function compressToolPartWithBounds(callID: string, bounds: any[], output: string) { + return { + id: `${callID}-part`, messageID: "msg", sessionID: SID, + type: "tool" as const, tool: "compress", callID, + state: { status: "completed" as const, input: { content: bounds }, output }, + } +} + +test("issue #364 P1: T1 capture compress does NOT reset tier cadence baselines (multi-turn, §5.7)", () => { + const state = createSessionState() + state.sessionId = "test-364-capture" + state.modelContextLimit = 1_000_000 + + const config = buildConfig() + config.compress.preserveRecentMessages = 5 + config.compress.preserveRecentTokens = 5_000 + config.compress.preserveLastUserMessage = true + // Production preserve-recent knobs restored — buildConfig zeroes them. + + // Sentinel far above this test's context volume: any reset to + // currentTokens shows up as a value change. + state.nudges.lastTier2NudgeTokens = 500_000 + state.nudges.lastTier3NudgeTokens = 500_000 + + // Turn 1: T1 capture compress (m-prefix boundaries). + const turn1: WithParts[] = [ + userMsg("u1", "task one"), + assistantMsg("c1", "captured", [ + compressToolPartWithBounds("cap-1", [{ startId: "m00001", endId: "m00005", summary: "x" }], "done"), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal( + state.nudges.lastTier2NudgeTokens, + 500_000, + "turn 1: T1 capture must not move the tier-2 baseline (old code reset it to currentTokens)", + ) + assert.equal( + state.nudges.lastTier3NudgeTokens, + 500_000, + "turn 1: tier-3 baseline untouched by a T1 capture", + ) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: compress turn — no nudge") + + // Turn 2: a second capture — the starvation pattern where every capture + // used to re-arm the growthFloor wait so T2 could never catch up. + const turn2: WithParts[] = [ + ...turn1, + userMsg("u2", "task two"), + assistantMsg("c2", "captured", [ + compressToolPartWithBounds("cap-2", [{ startId: "m00006", endId: "m00012", summary: "y" }], "done"), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal( + state.nudges.lastTier2NudgeTokens, + 500_000, + "turn 2: baseline still intact across consecutive captures", + ) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 2: compress turn — no nudge") +}) + +test("issue #364 guard: block-ref distill compress DOES reset tier cadence baselines (loop-prevention preserved)", () => { + const state = createSessionState() + state.sessionId = "test-364-distill" + state.modelContextLimit = 1_000_000 + + const config = buildConfig() + config.compress.preserveRecentMessages = 5 + config.compress.preserveRecentTokens = 5_000 + + state.nudges.lastTier2NudgeTokens = 500_000 + state.nudges.lastTier3NudgeTokens = 500_000 + + const turn1: WithParts[] = [ + userMsg("u1", "distill the old summaries"), + assistantMsg("d1", "distilled", [ + compressToolPartWithBounds("dis-1", [{ startId: "b2", endId: "b7", summary: "z" }], "done"), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.notEqual( + state.nudges.lastTier2NudgeTokens, + 500_000, + "distill (b-prefix): baseline moves to currentTokens — #235 loop-prevention preserved", + ) + assert.notEqual( + state.nudges.lastTier2NudgeTokens, + undefined, + "and stays defined (never undefined — the #235 bug)", + ) + assert.equal(state.nudges.shouldInjectThisTurn, false, "compress turn — no nudge") +}) + +test("issue #364 cycle: baseline held through capture → T2 fires on first growth past floor", () => { + const state = createSessionState() + state.sessionId = "test-364-cycle" + state.modelContextLimit = 1_000_000 + + const config = buildConfig() + config.compress.maxContextLimit = 500_000 + config.compress.minContextLimit = 200_000 + config.compress.nudgeGrowthTokens = 10_000 + config.compress.minNudgeGrowthFloor = 5_000 + config.compress.minNudgeGrowthRatio = 0.01 + + // Seed tier-1 blocks: tier1Tokens = 25K >= nudgeGrowthTokens 10K. + for (let i = 0; i < 5; i++) { + const blockId = i + 1 + state.prune.messages.blocksById.set(blockId, { + blockId, + runId: i + 1, + active: true, + tier: 1, + generation: "young", + survivedCount: 1, + directMessageIds: [], + effectiveMessageIds: [], + consumedBlockIds: [], + parentBlockIds: [], + summary: "T1 summary ".repeat(200), + summaryTokens: 5_000, + topic: `T1 block ${i}`, + createdAt: Date.now(), + }) + state.prune.messages.activeBlockIds.add(blockId) + } + + state.messageIds.byRawId.set("u1", "m00001") + state.nudges.lastPerMessageNudgeTokens = 500_000 + state.nudges.lastTier2NudgeTokens = 100_000 + + // Turn A: T1 capture at ~135K context. The fix keeps the 100K baseline; + // the old code re-armed it to ~135K. + const turnA: WithParts[] = [ + userMsg("u1", "task"), + assistantMsgWithTokens("a1", "cap", { input: 130_000, output: 5_000 }, [ + compressToolPartWithBounds("cap-1", [{ startId: "m00001", endId: "m00009", summary: "x" }], "done"), + ]), + ] + injectCompressNudges(state, config, logger, turnA, {} as any) + assert.equal( + state.nudges.lastTier2NudgeTokens, + 100_000, + "turn A: capture keeps the baseline (old: reset to ~135K)", + ) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn A: compress turn — no nudge") + + // Turn B: no compress, ~136K context — only ~1K growth since turn A, + // below the 5K floor. Old code stays blocked (baseline was re-armed to + // ~135K); with the baseline intact the cadence measures from 100K and + // T2 fires. + const turnB: WithParts[] = [ + ...turnA, + userMsg("u2", "more"), + assistantMsgWithTokens("a2", "work", { input: 131_000, output: 5_000 }, [ + toolPart("t2", "x".repeat(10_000)), + ]), + ] + injectCompressNudges(state, config, logger, turnB, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn B: T2 fires — cadence measured from the intact baseline") + assert.notEqual( + state.nudges.lastTier2NudgeTokens, + 100_000, + "turn B: T2 fired and set a fresh baseline", + ) + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 136_000, + "T1 baseline corrected downward to currentTokens (pre-existing correction path, inject.ts:294-302)" + ) +}) diff --git a/tests/query-pure.test.ts b/tests/query-pure.test.ts index 8acade44..cebaebd2 100644 --- a/tests/query-pure.test.ts +++ b/tests/query-pure.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { messageHasCompress, isIgnoredUserMessage } from "../lib/messages/query" +import { messageHasCompress, isIgnoredUserMessage, isCaptureOnlyCompress } from "../lib/messages/query" function makeAssistant(overrides: Record = {}) { return { @@ -96,3 +96,111 @@ test("isIgnoredUserMessage returns false for message with undefined parts field" ;(msg as any).parts = undefined assert.equal(isIgnoredUserMessage(msg as any), false) }) + +test("isCaptureOnlyCompress returns true for m-prefix boundaries (raw-message T1 capture)", () => { + const msg = makeAssistant() + msg.parts = [ + { + type: "tool", + tool: "compress", + state: { input: { content: [{ startId: "m00001", endId: "m00010", summary: "x" }] } }, + }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), true) +}) + +test("isCaptureOnlyCompress returns true for multiple m-prefix entries", () => { + const msg = makeAssistant() + msg.parts = [ + { + type: "tool", + tool: "compress", + state: { input: { content: [{ startId: "m00001" }, { startId: "m00020", endId: "m00030" }] } }, + }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), true) +}) + +test("isCaptureOnlyCompress returns false when any boundary is a block ref (T2/T3 distillation)", () => { + const msg = makeAssistant() + msg.parts = [ + { + type: "tool", + tool: "compress", + state: { input: { content: [{ startId: "b3", endId: "b15", summary: "x" }] } }, + }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), false) +}) + +test("isCaptureOnlyCompress returns false for mixed m-prefix and block-ref boundaries", () => { + const msg = makeAssistant() + msg.parts = [ + { + type: "tool", + tool: "compress", + state: { + input: { + content: [{ startId: "m00001", endId: "m00005" }, { startId: "B7", endId: "b9" }], + } }, + }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), false, "uppercase B-prefix also matches /^b\\d+$/i") +}) + +test("isCaptureOnlyCompress returns false for unparsable input (conservative: keep reset)", () => { + const msg = makeAssistant() + msg.parts = [ + { type: "tool", tool: "compress", state: { input: {} } }, + { type: "tool", tool: "compress", state: {} }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), false, "no parsable boundaries → conservative false") +}) + +test("isCaptureOnlyCompress returns true for JSON-string input with m-prefix boundary", () => { + const msg = makeAssistant() + msg.parts = [ + { + type: "tool", + tool: "compress", + state: { input: '{"content":[{"startId":"m00001","endId":"m00012"}]}' }, + }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), true, "string input is parsed") +}) + +test("isCaptureOnlyCompress returns false for malformed JSON-string input", () => { + const msg = makeAssistant() + msg.parts = [ + { type: "tool", tool: "compress", state: { input: "{not json" } }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), false, "JSON.parse failure → empty ids → conservative false") +}) + +test("isCaptureOnlyCompress returns false when boundaries are empty/whitespace strings", () => { + const msg = makeAssistant() + msg.parts = [ + { + type: "tool", + tool: "compress", + state: { input: { content: [{ startId: " ", endId: "" }] } }, + }, + ] + assert.equal(isCaptureOnlyCompress(msg as any), false, "no usable boundary → conservative false") +}) + +test("isCaptureOnlyCompress returns false for a user message", () => { + const msg = makeUser([{ type: "text", text: "hi" }]) + ;(msg as any).parts.push({ type: "tool", tool: "compress", state: { input: { content: [{ startId: "m00001" }] } } }) + assert.equal(isCaptureOnlyCompress(msg as any), false) +}) + +test("isCaptureOnlyCompress returns false for non-compress tool parts", () => { + const msg = makeAssistant() + msg.parts = [{ type: "tool", tool: "read", state: { input: { content: [{ startId: "m00001" }] } } }] + assert.equal(isCaptureOnlyCompress(msg as any), false) +}) + +test("isCaptureOnlyCompress returns false for undefined message", () => { + assert.equal(isCaptureOnlyCompress(undefined), false) +}) From 93d11b61bc3573a5373a23842ae1cdb91223a401 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Fri, 4 Sep 2026 19:54:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(e2e):=20re-scope=20scenario=2011=20?= =?UTF-8?q?=E2=80=94=20T1=20captures=20leave=20tier=20baseline=20untouched?= =?UTF-8?q?=20(post-#364=20semantics)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md | 10 ++++++++++ scripts/e2e/README.md | 2 +- ...on => 11-tier2-baseline-untouched-by-captures.json} | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) rename scripts/e2e/scenarios/{11-tier2-baseline-preserved-after-compress.json => 11-tier2-baseline-untouched-by-captures.json} (74%) diff --git a/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md b/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md index 97a5f77e..5ba8cae2 100644 --- a/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md +++ b/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md @@ -18,6 +18,16 @@ legacy tests stay green; fix then re-applied. - Existing #235 regression test unaffected: its compress fixture uses `input: {}` → classifier returns conservative false → reset still happens. +- CI Docker E2E caught a semantics change in scenario 11 (first PR push failed + `e2e: tier2BaselineSet === true — got null`). The fake LLM can only emit m-refs + (scripts/e2e/README Known Limitation 1), so scenario 11's `tier2BaselineSet: true` + was locking the OLD unconditional-reset behavior — a T1 capture no longer sets the + baseline (that IS the fix). Renamed to + `11-tier2-baseline-untouched-by-captures.json`, asserts `tier2BaselineSet: false` + (unset stays unset through captures); the #235 never-undefined invariant remains + locked by the unit tests (phase 1-3 + b-prefix contrast). README scenario table + updated. E2E coverage of the distill reset path needs fake-LLM b-ref support — + tracked as known limitation, not introduced here. ## Notes / decisions diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 06ec0099..ad45bc56 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -71,7 +71,7 @@ the turn counter for real conversation turns. | `08-nudge-with-protection.json` | Nudge→compress WITH protection enabled → verify compress succeeds despite protected zone, nudge baseline set, protected messages survived | | `09-nudge-refire-after-compress.json` | Multi-turn nudge→compress→growth→re-nudge→re-compress. Verifies minBlockCount ≥ 1 (full re-nudge cycle with baseline reset is in scenario 10 + unit tests), maxBlockCount ≤ 8 | | `10-autonomous-nudge-refire.json` | Issue #176: Autonomous session (bash tool calls grow context) → first nudge→compress → continued growth → second nudge→second compress → verify minBlockCount ≥ 2, maxCompressCallsVisible ≤ 2 | -| `11-tier2-baseline-preserved-after-compress.json` | Bug #235 regression: verify lastTier2NudgeTokens preserved (not reset to undefined) after compress. Tests compress handler baseline preservation, not T2 cadence (T2 never fires — consumption chain leaves only 1 active T1 block) | +| `11-tier2-baseline-untouched-by-captures.json` | Issue #364: verify raw-message T1 captures (m-refs) do NOT touch lastTier2NudgeTokens — stays unset when T2 never fired. The #235 never-undefined invariant is locked by unit tests on the distill/conservative reset path | | `12-consumed-call-hiding.json` | Bug #236 regression: T1 compresses auto-consume previous blocks → verify lastRequestCompressCalls=1 (consumed calls hidden from LLM) | ### Scenario Format diff --git a/scripts/e2e/scenarios/11-tier2-baseline-preserved-after-compress.json b/scripts/e2e/scenarios/11-tier2-baseline-untouched-by-captures.json similarity index 74% rename from scripts/e2e/scenarios/11-tier2-baseline-preserved-after-compress.json rename to scripts/e2e/scenarios/11-tier2-baseline-untouched-by-captures.json index fdbe5da3..6bef5bdf 100644 --- a/scripts/e2e/scenarios/11-tier2-baseline-preserved-after-compress.json +++ b/scripts/e2e/scenarios/11-tier2-baseline-untouched-by-captures.json @@ -1,6 +1,6 @@ { "name": "tier2-baseline-preserved-after-compress", - "description": "Bug #235 regression: lastTier2NudgeTokens must be preserved (not reset to undefined) after compress. The compress handler at inject.ts:121-128 sets lastTier2NudgeTokens=currentTokens on every compress attempt. Before fix (#235): handler reset to undefined, causing T2 to re-fire every turn without growth. Note: T2 never actually fires in this scenario because each T1 compress auto-consumes the previous block (search.ts auto-detection), leaving only 1 active T1 block. The assertion tests the compress handler's baseline preservation, not T2 cadence. T2 distillation testing requires fake LLM support for bNN block-ID refs (not yet implemented).", + "description": "Issue #364 semantics: raw-message T1 captures (mNNNNN refs — all the fake LLM can emit) must NOT touch the tier cadence baselines. lastTier2NudgeTokens stays unset here because T2 never fires (each T1 compress auto-consumes the previous block, search.ts auto-detection, leaving 1 active T1 block) and captures no longer set it. The #235 invariant (baseline never reset to undefined once T2 has fired) is locked by the distill/conservative reset path in unit tests (tests/inject.test.ts phase 1-3 + the b-prefix contrast test); E2E coverage of that path needs fake LLM support for bNN block-ID refs (not yet implemented).", "acpConfig": { "compress": { "minCompressRange": 0, @@ -28,7 +28,7 @@ "minBlockCount": 2, "maxBlockCount": 8, "activeBlockCount": 1, - "tier2BaselineSet": true, + "tier2BaselineSet": false, "nudgeBaselineSet": true, "maxCompressCallsVisible": 3 } From 1ec6211eeda52d57a4b74e415ebe145b0be5b697 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Fri, 4 Sep 2026 20:40:56 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(e2e):=20revert=20scenario=2011=20renam?= =?UTF-8?q?e=20=E2=80=94=20keep=20original=20filename=20(ci.yml=20hardcode?= =?UTF-8?q?s=20scenario=20paths;=20bot=20PAT=20lacks=20workflow=20scope=20?= =?UTF-8?q?to=20edit=20it);=20re-scoped=20content=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WORKLOG.md | 17 +++++++++++------ scripts/e2e/README.md | 2 +- ...ier2-baseline-preserved-after-compress.json} | 0 3 files changed, 12 insertions(+), 7 deletions(-) rename scripts/e2e/scenarios/{11-tier2-baseline-untouched-by-captures.json => 11-tier2-baseline-preserved-after-compress.json} (100%) diff --git a/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md b/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md index 5ba8cae2..6b79dc20 100644 --- a/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md +++ b/devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md @@ -22,12 +22,17 @@ `e2e: tier2BaselineSet === true — got null`). The fake LLM can only emit m-refs (scripts/e2e/README Known Limitation 1), so scenario 11's `tier2BaselineSet: true` was locking the OLD unconditional-reset behavior — a T1 capture no longer sets the - baseline (that IS the fix). Renamed to - `11-tier2-baseline-untouched-by-captures.json`, asserts `tier2BaselineSet: false` - (unset stays unset through captures); the #235 never-undefined invariant remains - locked by the unit tests (phase 1-3 + b-prefix contrast). README scenario table - updated. E2E coverage of the distill reset path needs fake-LLM b-ref support — - tracked as known limitation, not introduced here. + baseline (that IS the fix). Content re-scoped to assert `tier2BaselineSet: false` + (unset stays unset through captures); the #235 never-undefined invariant remains + locked by the unit tests (phase 1-3 + b-prefix contrast). The FILENAME is kept as + `11-tier2-baseline-preserved-after-compress.json` (now a slight misnomer): the e2e + job in `.github/workflows/ci.yml` hardcodes the explicit scenario path list, and + the bot PAT lacks `workflow` scope — pushes touching `.github/workflows/` are + rejected by the remote ("refusing to allow a Personal Access Token to create or + update workflow ... without `workflow` scope"), so ci.yml cannot be updated from + this environment. A human may rename file + `ci.yml:59` together in a cosmetic + follow-up. README scenario table updated. E2E coverage of the distill reset path + needs fake-LLM b-ref support — tracked as known limitation, not introduced here. ## Notes / decisions diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index ad45bc56..50bacd6e 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -71,7 +71,7 @@ the turn counter for real conversation turns. | `08-nudge-with-protection.json` | Nudge→compress WITH protection enabled → verify compress succeeds despite protected zone, nudge baseline set, protected messages survived | | `09-nudge-refire-after-compress.json` | Multi-turn nudge→compress→growth→re-nudge→re-compress. Verifies minBlockCount ≥ 1 (full re-nudge cycle with baseline reset is in scenario 10 + unit tests), maxBlockCount ≤ 8 | | `10-autonomous-nudge-refire.json` | Issue #176: Autonomous session (bash tool calls grow context) → first nudge→compress → continued growth → second nudge→second compress → verify minBlockCount ≥ 2, maxCompressCallsVisible ≤ 2 | -| `11-tier2-baseline-untouched-by-captures.json` | Issue #364: verify raw-message T1 captures (m-refs) do NOT touch lastTier2NudgeTokens — stays unset when T2 never fired. The #235 never-undefined invariant is locked by unit tests on the distill/conservative reset path | +| `11-tier2-baseline-preserved-after-compress.json` | Issue #364: verify raw-message T1 captures (m-refs) do NOT touch lastTier2NudgeTokens — stays unset when T2 never fired. The #235 never-undefined invariant is locked by unit tests on the distill/conservative reset path (filename kept from the pre-#364 revision because the CI e2e job hardcodes scenario paths) | | `12-consumed-call-hiding.json` | Bug #236 regression: T1 compresses auto-consume previous blocks → verify lastRequestCompressCalls=1 (consumed calls hidden from LLM) | ### Scenario Format diff --git a/scripts/e2e/scenarios/11-tier2-baseline-untouched-by-captures.json b/scripts/e2e/scenarios/11-tier2-baseline-preserved-after-compress.json similarity index 100% rename from scripts/e2e/scenarios/11-tier2-baseline-untouched-by-captures.json rename to scripts/e2e/scenarios/11-tier2-baseline-preserved-after-compress.json