Skip to content

feat(context): bounded, policy-driven delivery — budget, priority order, always-on by delivery_policy (ADR 0108 D6) - #3247

Merged
mabry1985 merged 5 commits into
mainfrom
feat/3187-delivery-budget
Aug 28, 2026
Merged

feat(context): bounded, policy-driven delivery — budget, priority order, always-on by delivery_policy (ADR 0108 D6)#3247
mabry1985 merged 5 commits into
mainfrom
feat/3187-delivery-budget

Conversation

@mabry1985

@mabry1985 mabry1985 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Implements ADR 0108 D6 — delivery is bounded and policy-driven. Refs #3187, #3184. Builds on D4 (#3242, delivery_policy) and D8 (#3243, graph/projection.py).

What

Budget. New config context.budget_pct (context_budget_pct, default 8, 0 = unbounded; blank/non-numeric → default with a warning, negative → 0): the projected context — everything injected per turn on top of the stable prompt — may use at most that share of the model's context window (chars//4). ProjectionOptions.from_config derives budget_chars = max(window × pct/100 × 4, 16 000) — the 16k-char floor (≈ always-on cap 6k + digest cap ~8k + headroom) means always-on memory and the digest are never fought over, whatever the window. No window reported by the gateway → unbounded, logged once (the knob is inert). ProjectionOptions(budget_chars<=0) reads as None so direct construction agrees with from_config. Surfaced in Settings → Knowledge → Recall, the example YAML, and the config reference. No delivery_order knob — the order is fixed by the ADR (documented).

Priority + shed order (_fit_to_budget in graph/projection.py). Fill priority: working state → always-on memory → skill index → prior-session digest → RAG hits. Over budget, shed lowest-priority first, re-measuring after each step so separators and the envelope are accounted for and nothing is cut mid-line:

  1. RAG hits — one whole hit at a time from the lowest-ranked end, then the section.
  2. The prior-session digest — as one unit (the loader hands in a rendered block under its own ~2k-token cap; D9 restructures it into attributed entries and per-entry shedding lands with that).
  3. The skill index — walked down by rows: the summaries are read once per compose and re-rendered with skills_top_k = full_rows-1, -2, … (the last description row becomes a name-only row each step) until the block fits, then the identity floor (name-only rows, skills index: fresh instances hide most skills — MRU-ordered top-5 of 18 made the Cowork archetype's headline skill invisible #2867). Monotone in the budget, at most skills_top_k renders, never below the floor. (The first cut walked a char cap by overshoot, which could stall for 16 iterations on a 1–20-char overshoot and drop to the floor — the adversarial probe caught it; a sweep test now pins monotonicity.)
  4. Working state and always-on memory are never shed: if they alone exceed the budget they are delivered anyway and a WARNING names the sizes — once per distinct (working-state, always-on) size per process, DEBUG after that.

Deterministic. One INFO line when anything was shed (never per-item spam).

Always-on = delivery_policy="always", not domain="hot". get_hot_memory_entries() / get_hot_memory() (names kept for custom backends and the console) select by policy; _publish_hot_write fires on the STORED policy (a superset — D4 forces always on every hot write, so legacy rows still inject and fire); operator_api/memory_routes.py lists the hot inspector by policy — deliberately without the reader's deliverable filter, so a rejected/expired pin still lists (with injecting: false) for the operator to see and un-reject — and the hot EDIT route is lifecycle-preserving: it keeps the row's own domain, pins delivery_policy="always", carries memory_kind/subject/expires_at/namespace/epoch into the revision, and keeps a rejected pin rejected (any other edit is the operator's own write → confirmed); a backend predating the kwargs falls back to the old domain pin (its own reader keys on the domain, so that is the same pin, not a demotion). graph/snapshot_op.collect_knowledge_seed drops always rows on any domain (memory never travels, ADR 0091) alongside #3245's commons-tier guard and keeps MEMORY_DOMAINS.

deliverable=True on list_chunks and search — plain (FTS + LIKE), hybrid (both rankings, so a rejected row can't surface as a vector-only hit), layered (both tiers): excludes review_state='rejected' and rows past expires_at. The always-on reader and the projection's RAG search pass it (search_scoped retries without the kwarg for a backend that predates it, then post-filters with deliverable_hit — the rule holds either way); memory_recall / memory_list do not, so excluded rows stay reachable on demand.

One wiring. KnowledgeMiddleware(..., options=ProjectionOptions.from_config(config)) in graph/agent.py replaces the hand-wired kwargs (the individual kwargs stay for tests and derive from options when given). The D8 drift test is replaced by test_middleware_is_wired_from_one_options_object, which also asserts agent.py has no hand-wired skills_index_chars=int( left.

Shape. ProjectedContext gains budget_chars, used_chars, overflow ({"label", "dropped_items", "dropped_chars"} in shed order — kept even when everything was shed and the text is empty); shed sections carry "truncated": true; as_legacy_dict() adds "budget": {"chars", "used", "overflow"} only when a budget is in force — unbounded delivery keeps the two-key shape byte-for-byte. The prompt preview API (GET /api/prompts/preview) carries the budget summary (call.budget, null when unbounded) and the per-section truncated flags through _sections(); the console inspector renders them in a follow-up (listed below). The injection log records the ids that were delivered, after shedding. deliverable_hit parses expires_at as ISO-8601 (Z and naive → UTC) with a string-compare fallback; the SQL predicate's +00:00 ISO-shape assumption is documented on _deliverable_clauses (D7 normalizes at the write funnel).

Defaults and what changes where

8% of a 128k window = int(128_000 × 0.08 × 4) = 40 960 chars ≈ 10k tokens. The worst-realistic 128k turn is ~40.4k chars — 6 000 chars of always-on memory + a ~2 000-token digest (~8 000 chars) + ten 1 000-char hits + a 2%-of-window skill index (10 240) + envelope/working state — i.e. ~564 chars of headroom: nothing is shed at the default on 128k unless every part is at its cap at once, and then only the last hit. The D8 pre-refactor golden (tests/fixtures/projection_native_golden.json) passes unchanged.

On ≤32k-window models the default budget is the 16k-char floor (8% of 32k = 10 240 < floor; 8% of 8k would not even hold the always-on envelope), so RAG hits and skill descriptions beyond the floor are shed where they were unbounded before — always-on memory and the digest are never touched. LiteLLM reports 8k/32k for many local/legacy models, so this is the one behavior change an operator may notice; the settings copy, config comment, example YAML and docs all say so.

Rejected/expired rows were never written before D4/D7, so deliverable changes nothing for existing stores. Lower context.budget_pct to make the budget bite.

Consequences of the always-on switch

  • A row on any domain with delivery_policy="always" now injects every turn and shows in the console's Hot memory list — that is the intended D4→D6 payoff.
  • hot + non-always rows cannot exist (D4 forces the policy), so no row is stranded by the switch; NULL already reads as retrieved.
  • MEMORY_DOMAINS still drops the hot domain from snapshots; the policy filter extends that to pinned rows elsewhere.

Gates

Run in the worktree at 46eab6ef, rebased onto bd5108f8 (origin/main = D7 #3246; before that #3244/#3245). Rebase conflicts resolved: graph/snapshot_op.py seed loop keeps BOTH guards (commons-tier from #3245, always-on from this PR); ADR 0108 Phase 5 keeps BOTH "Shipped" bullets (D7's + D6's). Post-rebase, the B4 route test was added: an always row on a non-hot domain edited via PUT /api/knowledge/chunks/{id} keeps its policy and stays in GET /api/memory/hot (D7's typed-field carry-over + this PR's policy-keyed hot list, tests/test_knowledge_routes.py::test_chunk_update_keeps_an_always_on_row_always_on).

  • uv run ruff check . → All checks passed!
  • uv run lint-imports → Contracts: 3 kept, 0 broken.
  • focused post-rebase (test_delivery_budget, test_projection incl. the D8 golden, test_knowledge_routes, test_memory_routes, test_hot_memory, test_knowledge_typed_memory, test_plugin_api_reference, test_config_roundtrip, test_context_characterization, test_prompt_routes) → 252 passed
  • FULL suite r4 uv run python -m pytest tests/ -q -p no:cacheprovider6987 passed, 16 skipped, 156 warnings in 326.02s — EXIT=0 (scratchpad/d6-pytest-r4.log; r3 pre-rebase was 6960 passed EXIT=0; the r2 run's 2 failures were the plugin-events catalog reading the dedupe callable named emit( as a bus topic — renamed, tests/test_plugin_api_reference.py green since)

Remaining on #3187

Not in this PR: per-kind retrieval quotas; the session-frozen operator-profile snapshot; a separate confirmation/write gate for standing constraints; inspector "why was this selected" explanations beyond the budget summary; a mid-session "your memory update becomes active next turn" notice; console rendering of the budget summary + truncated flags the preview API now carries; an injection-log shed column; per-chat model overrides don't re-derive the budget (pre-existing — same as the skill-index cap, both derive from the configured model's window).

Pending in this PR (after #3246 / D7 merges): a route test asserting an always row edited from the Knowledge view (PUT /api/knowledge/chunks/{id}) stays always-on and in the hot list — D7 carries the typed-field carry-over fix for that route; this PR does not touch it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 11 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 067fa1ef-14e1-476c-ac70-c771730fb15b

📥 Commits

Reviewing files that changed from the base of the PR and between bd5108f and 46eab6e.

📒 Files selected for processing (23)
  • changelog.d/3247.added.md
  • config/langgraph-config.example.yaml
  • docs/adr/0108-context-architecture-v2.md
  • docs/explanation/memory-and-knowledge.md
  • docs/guides/knowledge.md
  • docs/reference/configuration.md
  • graph/agent.py
  • graph/config.py
  • graph/middleware/knowledge.py
  • graph/projection.py
  • graph/settings_schema.py
  • graph/snapshot_op.py
  • knowledge/hybrid_store.py
  • knowledge/layered.py
  • knowledge/store.py
  • operator_api/memory_routes.py
  • operator_api/prompt_routes.py
  • tests/test_config_roundtrip.py
  • tests/test_delivery_budget.py
  • tests/test_knowledge_routes.py
  • tests/test_memory_routes.py
  • tests/test_projection.py
  • tests/test_prompt_routes.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

mabry1985 and others added 5 commits August 28, 2026 12:13
…er, always-on by delivery_policy (ADR 0108 D6)

The projected context has a char budget (`context.budget_pct` of the model
window, chars//4; default 8%, 0 = unbounded, unbounded when no window is
known). Fill priority: working state → always-on memory → skill index →
prior-session digest → RAG hits. Over budget the lowest-priority parts shed
first — RAG hits one whole hit at a time from the lowest-ranked end, then
the digest as a unit, then skill descriptions down to the identity floor
(names never drop); working state and always-on memory are never shed (a
warning names the sizes when they alone exceed the budget). Deterministic;
unbounded delivery is byte-identical to the pre-D6 composer (the D8 golden
still holds).

Always-on is now selected by `delivery_policy="always"`, not `domain="hot"`
(every hot write is stamped always since D4, so legacy rows still inject,
and a row on any domain can be pinned): the store's hot readers, the
`memory.hot_written` event, the console's hot list/edit (edit keeps the
row's own domain, pins the policy), and the snapshot seed (always-on never
travels) all key on the policy. `deliverable=True` on list_chunks/search
(plain FTS + LIKE, hybrid both rankings, layered both tiers) excludes
rejected and expired rows from delivery; the projection's RAG search passes
it (with a post-filter for backends that predate the kwarg) and
`memory_recall` does not.

One wiring: graph/agent.py builds KnowledgeMiddleware with
`options=ProjectionOptions.from_config(config)` — the same reader the
external runtime uses. `ProjectedContext` carries `budget_chars`,
`used_chars` and an `overflow` list; `as_legacy_dict()` adds a `budget`
summary when a budget is in force and keeps the two-key shape otherwise;
shed sections are marked `truncated`; the injection log records what was
delivered.

Refs #3187, #3184.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
…, preview carries the budget, lifecycle-preserving hot edit

- Skill shed walks by ROWS from the summaries read once per compose
  (skills_top_k = full_rows-1, -2, …, then the identity floor): monotone in
  the budget, ≤ skills_top_k renders, no extra index reads — the char-cap walk
  could overshoot by a few chars for 16 iterations and fall to the floor.
- The derived budget is floored at 16k chars (≈ always-on cap + digest cap) so
  ≤32k-window models keep their standing context whole and shed only RAG hits
  / skill descriptions beyond it; the never-shed warning fires once per
  distinct (working_state, always_on) size; from_config logs once when
  budget_pct > 0 but no window is known (the knob is inert).
- GET /api/prompts/preview carries `budget` (null when unbounded) and
  per-section `truncated` flags through _sections().
- The hot EDIT route carries memory_kind/subject/expires_at/namespace/epoch
  into the revision; a rejected pin stays rejected, else confirmed; the hot
  list intentionally shows rejected/expired pins with injecting=false.
- ProjectionOptions(budget_chars<=0) reads as None; _finish keeps overflow
  when everything was shed; deliverable_hit parses ISO (Z, naive→UTC) with a
  string fallback; context.budget_pct coercion warns on blank/non-numeric and
  clamps negatives to 0; _deliverable_clauses documents its +00:00 shape
  assumption; snapshot seed keeps both the commons-tier and always-on guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
… catalog scanner reads emit("…") as a bus topic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
…s always-on (ADR 0108 D6 + D7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby
@mabry1985
mabry1985 marked this pull request as ready for review August 28, 2026 19:18
@mabry1985
mabry1985 force-pushed the feat/3187-delivery-budget branch from f6745a5 to 46eab6e Compare August 28, 2026 19:20

@protoreview protoreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA panel review — WARN

code-review-structural · head f6745a5ad2a2 · formal

⚠️ PR advanced 8 commit(s) during this round (f6745a5ad2a246eab6efbd2e); 1 finding(s) in the delta were demoted to possibly addressed.

Overall risk is elevated but unconfirmed: the panel's single blocker (a syntactically truncated return in graph/snapshot_op.py) and one minor (a missing budget floor in graph/projection.py) both sit in files that 404 at the PR head SHA, so neither can be confirmed or refuted. The verifier refuted the third finding (fabricated evidence in memory_routes.py), which is a good sign the panel's cross-file lane was overreaching. Fix-first: re-pin the PR head or resolve the force-push so the two uncertain findings can be re-verified; the blocker is the one that would break import-time if real. The panel did not disagree on severity — the disagreement was on whether the evidence was real (Finding 3) versus unreadable (Findings 1–2). Verification changed the outcome by eliminating Finding 3 entirely and downgrading the panel's confidence on the remaining two from "asserted" to "unverifiable." Gap: the structural pass was skipped because both target files are unreadable at the pinned SHA; no structural invariants could be checked.

Findings

Severity Location Finding Verified
🔴 blocker graph/snapshot_op.py:539 The build_snapshot function ends with a truncated return statement ("return Snapsho") that is syntactically incomplete and will raise a SyntaxError at import t… ⚠️ uncertain
🟡 minor graph/projection.py:132 The PR description promises a 16k-char floor on budget_chars (max(window × pct/100 × 4, 16000)) so that always-on memory and the prior-session digest are 'neve… ⏳ possibly addressed
findings JSON (machine-readable)
[
  {
    "file": "graph/snapshot_op.py",
    "line": 539,
    "severity": "blocker",
    "category": "correctness",
    "claim": "The build_snapshot function ends with a truncated return statement (\"return Snapsho\") that is syntactically incomplete and will raise a SyntaxError at import time, breaking the entire snapshot feature.",
    "evidence": "return Snapsho",
    "source": "protopatch",
    "verdict": "uncertain",
    "note": "gap: unverified \u2014 file 404s at PR head (46eab6efbd2e); default-branch read shows the file exists pre-PR but the PR-head version is unreadable, so the claimed truncated return cannot be confirmed or refuted."
  },
  {
    "file": "graph/projection.py",
    "line": 132,
    "severity": "minor",
    "category": "correctness",
    "claim": "The PR description promises a 16k-char floor on budget_chars (max(window \u00d7 pct/100 \u00d7 4, 16000)) so that always-on memory and the prior-session digest are 'never fought over, whatever the window', but the code has no such floor; on a small-window model the derived budget can fall below the always-on + digest size and _fit_to_budget sheds the digest (its step 2), contradicting the documented guarantee.",
    "evidence": "budget_chars=int(window * budget_pct / 100 * 4) if (window and budget_pct > 0) else None,",
    "verdict": "possibly addressed",
    "note": "gap: unverified \u2014 file 404s at PR head (46eab6efbd2e); default-branch read shows the pre-PR ProjectionOptions (no budget_chars field, as expected), but the PR-head version is unreadable, so the claimed missing floor cannot be confirmed or refuted. \u2014 the PR head advanced while this round ran and the new commits touch this finding's region \u2014 verified against the superseded head, so it may already be addressed"
  }
]

@protoreview protoreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA panel review — WARN

code-review-structural · head 46eab6efbd2e · formal

The PR head SHA (46eab6e) is unreachable (404), so neither prior-round issue could be independently verified at the actual head. The synthesizer's "confirmed addressed" claim rests on inference from the default branch, which was itself truncated before reaching the relevant functions. The one thing to fix before merge: confirm the truncated return in snapshot_op.py is actually resolved — if it persists, it is a hard SyntaxError at import time. The panel disagreed on whether the two prior issues were closed: the synthesizer said yes, the verifier could not confirm. Gaps: the structural pass was skipped entirely (verifier noted the 404), and the diff was truncated at 12k chars, hiding the code changes to projection.py.

Prior requests

Prior finding Disposition Why
🔴 graph/snapshot_op.py:539 open PR head SHA 46eab6e returned 404; verifier fell back to default branch where build_snapshot was not visible (file truncated at 20k chars). Cannot confirm the …

Findings

Severity Location Finding Verified
🔴 blocker graph/snapshot_op.py:539 build_snapshot ends with a truncated return statement ("return Snapsho") that is syntactically incomplete and will raise a SyntaxError at import time, breaking… ⚠️ uncertain
🟡 minor graph/projection.py:132 The PR description promises a 16k-char floor on budget_chars (max(window × pct/100 × 4, 16000)) so that always-on memory and the prior-session digest are 'neve… ⚠️ uncertain
findings JSON (machine-readable)
[
  {
    "file": "graph/snapshot_op.py",
    "line": 539,
    "severity": "blocker",
    "category": "",
    "claim": "build_snapshot ends with a truncated return statement (\"return Snapsho\") that is syntactically incomplete and will raise a SyntaxError at import time, breaking the entire snapshot feature. Flagged in round 1; the synthesizer claimed it was addressed at this head, but the PR head SHA 404'd and the verifier could not independently confirm the fix.",
    "evidence": "return Snapsho",
    "verdict": "uncertain",
    "note": "PR head SHA 46eab6efbd2e0ff5c1a4f8dfaee10ebbc7c25b76 returned 404 on both file reads. Verifier fell back to default branch; build_snapshot was not visible in the truncated output (cut at 20 000 chars). Cannot confirm resolution."
  },
  {
    "file": "graph/projection.py",
    "line": 132,
    "severity": "minor",
    "category": "",
    "claim": "The PR description promises a 16k-char floor on budget_chars (max(window \u00d7 pct/100 \u00d7 4, 16000)) so that always-on memory and the prior-session digest are 'never fought over, whatever the window', but no such floor is visible in the code. Flagged in round 1; the synthesizer claimed it was addressed, but the verifier could not confirm (PR head 404'd, diff truncated before reaching projection.py code changes).",
    "evidence": "budget_chars",
    "verdict": "uncertain",
    "note": "PR head SHA 404'd. On the default branch, ProjectionOptions and compose_projected_context are present but no 16k floor is visible. The PR diff was truncated at 12 000 chars, hiding the actual code changes to projection.py."
  }
]

1 panel step(s) hit their time budget and were skipped this round: find_removed_behavior. The verdict stands on the remaining angles; a finding only that step would have caught could be missed — the next push re-runs the full panel.


Unaccounted prior finding(s). An earlier round of this panel confirmed the following, and this round neither reports them, nor says they were fixed, nor refutes them:

  • graph/snapshot_op.py:539 (blocker) — The build_snapshot function ends with a truncated return statement ("return Snapsho") that is syntactically incomplete and will raise a SyntaxError at import time, breaking the entire snapshot feature.

A finding that disappears without a disposition is unproven, not resolved (issue #26). Any standing block stays up until the next round accounts for it — or an operator dismisses this review.

@mabry1985

Copy link
Copy Markdown
Member Author

QA panel WARN — both findings verified at the actual head 46eab6ef and refuted; merging.

The panel's own notes say its reads 404'd / truncated after the force-push, so both findings were judged on cut-off text:

  1. graph/snapshot_op.py:539 "truncated return Snapsho" (blocker)git show 46eab6ef:graph/snapshot_op.py | python3 -m py_compile → compiles clean. The only matching text is the intact return SnapshotResult( at line 746; the panel's 20k-char fetch window cut the file mid-token. (A real SyntaxError could not coexist with the 6,987-test full suite that imports this module — see the PR's Gates.)
  2. graph/projection.py:132 "missing 16k floor" (minor) — round 1 ran against the pre-review-round SHA f6745a5a. At 46eab6ef: _MIN_BUDGET_CHARS = 16_000 (line 74) and budget_chars = max(int(window * budget_pct / 100 * 4), _MIN_BUDGET_CHARS) (line 152), with tests in tests/test_delivery_budget.py.

Dismissal trail for the panel's issue #26 protocol: both prior findings are hereby accounted for as refuted at head with the evidence above.

🤖 Generated with Claude Code
https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby

@mabry1985
mabry1985 merged commit b3d9c91 into main Aug 28, 2026
17 of 18 checks passed
@mabry1985
mabry1985 deleted the feat/3187-delivery-budget branch August 28, 2026 20:36
mabry1985 added a commit that referenced this pull request Aug 28, 2026
…pt inspector (ADR 0108 D6) (#3257)

* feat(console): show the delivery budget and shed sections in the prompt inspector

The backend has returned the ADR 0108 D6 delivery budget since #3247 — the
ceiling, chars used, and an `overflow` list naming what was shed to fit, plus
`truncated` on every section the budget cut. Nothing consumed any of it:
`api.promptPreview()` had zero callers, so the console never called the preview
route at all, and the fields were absent from the TypeScript types.

Two latent bugs had to be fixed before the preview could be wired up at all:

- `promptText()` read only `stable + context`, but post-#3234 captures and the
  /preview synthesis carry an EMPTY `system.context` and put the whole
  projection in `projected_context` — so the dialog rendered a bare stable
  prefix for exactly the calls it is now most used to inspect. `splitLine()`
  reported a 0-char tail for the same reason.
- A `"projected"`-scope section fell through to the stable tint, painting the
  per-turn projection as if it were the cacheable prefix — the one split the
  breakdown exists to show.

The delivery budget renders as its own strip (ceiling / used / spare, with the
shed list beneath) and is deliberately NOT merged into the existing section-size
bars: those are a share-of-prompt breakdown, this is a ceiling, and labeling
them alike would imply the sections were measured against it.

The next-call preview is a tab that loads on selection, not with the dialog —
it re-runs the dynamic layer including retrieval, so it is the one tab that
costs something to open — and it is badged speculative because no model call was
made and nothing was written to the injection log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby

* chore(changelog): fragment for #3257

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby

* fix(console): the preview tab self-cancelled its own fetch

`previewState` was both SET inside the preview effect and listed in its dep
array. setPreviewState("loading") re-ran the effect, the cleanup flipped
`alive = false` so the in-flight response was discarded, and the re-run's own
guard returned early because the state was no longer "idle" — nothing ever set
it back, so the tab sat on "Composing the next call…" forever.

Dropping `previewState` from the deps is enough: React rebuilds the closure
every render, so a run triggered by `active`/`preview`/`sessionId` still reads
the current state and the guard still prevents a re-fetch. `preview` stays a
dep — it is set on the SUCCESS path after the work completes, so its re-run is
benign, which is exactly the distinction that made `previewState` a bug.

Every test on this branch targeted the pure formatters in promptView.ts, so
nothing exercised the component and this shipped green. Adds four component
tests (jsdom + react-dom/client, the NewAgentPanel precedent) driving the real
effect. They use a hand-resolved deferred rather than mockResolvedValue: an
immediately-resolved mock settles before React re-runs the effect, so the
self-cancel never happens and the tests pass against the broken code — with the
deferred, all four fail when the dep array is re-armed.

Also fixes the changelog fragment to the documented shape: changelog.d/README.md
puts the (#NNNN) and its period INSIDE the bold lead-in, which is what the
collation extracts as the release-note title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEMxBi71vjtmmmziFCMcby

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant