Skip to content

fix(opencode): keep the session's recorded model on model-less prompts; brake the compaction spiral - #11

Merged
Shodocan merged 2 commits into
devfrom
compaction-spiral-fix
Sep 17, 2026
Merged

Shodocan merged 2 commits into
devfrom
compaction-spiral-fix

Conversation

@Shodocan

Copy link
Copy Markdown
Owner

Issue for this PR

Issues are disabled on this repo, so these are tracked in the harness repo:
Shodocan/harness-opencode#36 (wakeup drops the session model)
Shodocan/harness-opencode#31 (compaction death spiral) — this is PR 1 of 2

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Two bugs that feed each other.

1. A model-less prompt loses the session's model. createUserMessage resolved
input.model ?? ag.model ?? currentModel(sessionID). The workflow plugin posts parent-wakeup
prompts with no model (its client body type cannot carry one), and it injects a model for
every policy agent, so ag.model was always truthy and the session's own recorded selection
was never reached. setAgentModel then persisted that wrong value, so one wakeup permanently
overwrote the user's choice. A 1M-context session got switched to a 262K model, which also
moved the compaction gate to 262K and compacted at ~210-230K.

I could not just reorder the ?? chain: currentModel ends in provider.defaultModel() with
orDie, so it never returns empty and ag.model would have become dead code, rerouting fresh
sessions to the provider default. So currentModel is split — recordedModel returns only an
actual recorded selection (SessionTable.model, else the newest user message carrying a model)
or undefined — and resolution becomes input.model ?? recordedModel ?? ag.model ?? provider.defaultModel(). setAgentModel is now gated on an explicit input.model.
injectBackgroundResult passes the parent's recorded model, since that path can.

2. A failed compaction made the next one bigger. Compaction scope is everything since the
last completed summary, and a summary row carrying an error is never isCompletedSummary,
so it stayed in scope and got re-serialized. Failed attempts pruned nothing while appending
markers, so each retry serialized strictly more. Momentum session
ses_f858b80aeffeniNr1o0hCcFuGf reached ~968K against a 947,520 budget and then bricked.
The errored row was only dropped when a fallback_model was configured, and the one cycle
guard was armed only in that same case. Nothing checked that a compaction actually shrank
anything — CompactionExecutor has no-reduction and post-compaction-over-budget verdicts
but is never wired into src.

filterCompacted now excludes non-completed summary rows from model scope (the durable rows
stay), awaitingCompactionProgress is armed after every completed compaction, the
resume-admission check runs for every live-path compaction rather than only the fallback, and a
no-reduction verdict compares symmetric toModelMessagesEffect projections before/after.

I did not wire CompactionExecutor itself — that swaps single-shot compaction for its chunked
rolling-summary model, which is anomalyco#18's work, not surgical. Its verdict semantics are reused
directly instead.

History is never deleted (anomalyco#18's guarantee); the brake marks. New terminal errors reuse
SessionV1.ContextOverflowError, the type processor.ts already collapses to, with distinct
messages, so string-matching callers keep working and a bricked session becomes diagnosable.

Deliberately out of scope: bounding/chunking the compaction input, changing serialize()'s
reasoning mass, an input + max_tokens <= window clamp, the reserved: 12000 flooring, and any
overflow.ts arithmetic. overflow.ts is untouched here.

How did you verify your code works?

Tests written first and confirmed failing for the intended reason — e.g. modelID came back
agent-model where session-model was expected, and a non-reducing compaction returned
continue where stop was expected.

New coverage: persisted-vs-agent-default precedence; no spurious persistence on fallback;
explicit input.model still persists (no regression); injectBackgroundResult preserves the
parent model; a failed attempt does not enlarge the next serialized scope; a non-reducing
compaction terminates instead of looping; history intact after both.

Three existing tests encoded the old behaviour and were updated to the new contract rather than
deleted, keeping their history-preservation assertions. One of them gained a stronger assertion
(no assistant row leaks into model scope) alongside the changed count. prompt.test.ts's
agent-variant test asserted that a model-less prompt reverts to the agent default after an
explicit selection — that reversion is the bug — so it was reordered with fresh-session
behaviour still asserted first.

bun test test/session/ test/tool/: baseline 1087 pass / 8 fail (5 environmental + 3 scaffolded
RED), final 1093 pass / 5 fail. The 5 are environmental and reproduce on clean HEAD via
git stash: tool.write permissions expects mode 420 and gets 436 (umask), three
workflow-observer tests, and provider-transport-wire.test.ts missing the ephemeral fixture
/tmp/workflows-v4.3.0/test/fixtures/provider-transport.ts. None are in a file this PR touches.

bun typecheck 30/30 (the pre-existing @opencode-ai/plugin TS2307 was a missing workspace
link locally, resolved by bun install; not a code change). oxlint 0 errors, warning counts
unchanged.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Plugin parent-wakeup prompts are delivered over HTTP with a body that
structurally cannot carry a model, so prompt resolution fell through to
the plugin-injected agent default and then persisted it via
setAgentModel: a session whose user selected a 1M-context model was
silently switched to the 262K agent default, permanently overwriting the
selection after the first wakeup. Both the inference route and the
compaction gate then used the smaller window (harness-opencode#36).

currentModel can never return empty - its provider.defaultModel() tail
always resolves - so a naive reorder would have made the agent default
dead code and changed fresh-session behavior. Instead, split it into
recordedModel (persisted SessionTable.model, then the newest user
message carrying a model; undefined when the session has no recorded
selection) and currentModel (recordedModel plus the provider-default
fallback, unchanged for its command-path callers). The precedence is
now: explicit input.model, then the session's recorded selection, then
the agent default, then the provider default - which keeps the agent
default authoritative for fresh sessions and fixes model-less prompts
on sessions with a recorded selection. Applied in createUserMessage and
shellImpl (same omission at both sites).

setAgentModel now runs only for explicit input.model selections; a
model resolved from the agent default or read back from the session is
not a user choice and is no longer written to the session record.

The task tool's background-result injection now pins the parent
session's recorded model on the wakeup-style prompt it sends; it
previously passed agent and variant but no model.

The agent-variant test encoded the old precedence (a model-less prompt
reverting to the agent default after an explicit selection); it is
reordered to keep full variant coverage under the fixed contract and to
pin the new sticky-selection behavior. Adds wakeup-precedence,
non-persistence, explicit-persistence and background-injection tests.
A failed compaction kept its errored summary row (including partial
streamed output) inside the next serialization scope, so every retry
serialized strictly more than the last; the awaitingCompactionProgress
cycle guard was armed only when compaction.fallback_model was
configured; and a completed compaction that reduced nothing handed the
loop straight back into compaction. Together: sessions whose compaction
request exceeds every configured budget fail forever while growing, and
normal requests gate-reject - a bricked session (harness-opencode#31).

- filterCompacted now excludes summary rows that are not completed
  summaries (errored, unfinished, crash remnants, blank) from model
  scope. This MARKS, never deletes: the durable rows stay for the UI
  and diagnosis, and no user/assistant history is touched (anomalyco#18), but a
  failed attempt can no longer enlarge the next request or the next
  compaction attempt.
- The cycle guard is armed after every completed compaction, with or
  without a fallback model: a rebuilt request that still comes back
  "compact" terminates the run with the existing progress verdict
  instead of re-entering compaction.
- processCompaction applies the CompactionExecutor verdict semantics on
  the live path. The post-compaction admission check (previously
  fallback-only) now runs for every live-path compaction, and an
  overflow-driven single-shot compaction whose projected continuation
  does not shrink the projection of the exact pre-compaction state
  fails terminally with a diagnosable no-reduction error. CompactionExecutor
  itself is NOT wired in: it executes the bounded chunked rolling-summary
  model (issue anomalyco#18 hierarchical summarization, out of scope here), so
  wiring it would replace the execution model rather than brake it; the
  guard reuses its verdict semantics directly on the live path instead.
  The no-reduction check is skipped when a prior turn is replayed
  verbatim: that media-overflow flow strips attachment mass the
  projections cannot see, so its marginal arithmetic would misjudge a
  successful strip; the loop-level guard covers re-entry there.

Terminal errors stay SessionV1.ContextOverflowError, so the public
error surface and the processor's collapsed "Input exceeds context
window of this model" message are unchanged for existing callers, while
the distinct messages keep a bricked session diagnosable.

Updates the filterCompacted unit and boundary tests that pinned the old
errored-summary retention, and adds spiral-brake tests: a failed attempt
does not enlarge the next serialized scope, a non-reducing overflow
compaction fails terminally, the live loop terminates instead of
re-compacting, and seeded user history survives byte-identical.
@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@Shodocan
Shodocan merged commit 96f7902 into dev Sep 17, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant