mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* feat(context-collapse): implement context collapse for proactive context management
* feat(context-collapse): add turn-boundary helpers for span selection
* feat(context-collapse): deterministic turn-anchored span selection
* feat(context-collapse): code-computed span risk score
* feat(context-collapse): ctx-agent summarization instruction
* feat(context-collapse): implement ctx-agent span summarization spawn
* fix(context-collapse): make runtime activation opt-in (CLAUDE_CONTEXT_COLLAPSE)
* fix(context-collapse): address review feedback on restore state and test rigor
- restoreContextCollapseState now resets armed/lastSpawnTokens up front so a
snapshot-less restore cannot carry stale spawn state across sessions.
- projectView reuses a stable timestamp from the replaced span instead of
new Date(), keeping the read-side projection deterministic.
- Strengthen the disabled-state and turn-boundary assertions, drop an internal
renderToolUseMessage assertion, and isolate the operations/persist/spawn tests
from shared module and CLAUDE_CONTEXT_COLLAPSE env state.
* test(context-collapse): re-init enablement in persist.test hooks
resetContextCollapse() does not re-read CLAUDE_CONTEXT_COLLAPSE, so the
afterEach env delete left enabled=true in module state, leaking to the
next test file. Call initContextCollapse() in both hooks so module
enablement stays synced to the env var.
* test(context-collapse): stop spawnCtxAgent module stubs leaking across files
spawnCtxAgent.test.ts stubs shared modules (tokens, forkedAgent, messages,
analytics, log, spanSelection) via mock.module in beforeEach. bun's
mock.restore() does not undo mock.module, so the tokens stub (() => 100000)
bled into autoCompact/microCompact/runAgent tests run later in the full serial
suite, making them see every conversation as over-threshold (4 spurious
failures in test:full, all green in isolation).
Restore each stub to its real implementation in afterEach. The reals are
snapshotted into plain objects up front because 'import * as' yields a live
namespace that mock.module mutates in place, so holding the namespace would
restore the stub. autoCompact.js is deliberately not restored here since
autoCompact.test.ts re-imports it fresh via a cache-busting nonce.
Also reset+reinit the collapse module in afterEach so enabled state stays
synced to the now-unset env var.
* test(context-collapse): also restore autoCompact stub from spawnCtxAgent
The getEffectiveContextWindowSize stub on ../compact/autoCompact.js was the one
module the previous commit left unrestored, on the assumption that restoring it
would clash with autoCompact.test.ts's nonce re-import. It doesn't: the nonce
import uses a different specifier, and the snapshot restore is keyed by the
plain specifier. compressToolHistory imports getEffectiveContextWindowSize and
sizes tool-history truncation from it, so the leaked 20000-token window made it
fully omit tool results ('chars omitted') instead of mid-truncating
('[…truncated') for large-context models, failing the openaiShim compression
tests in the full serial suite. Restore all seven mocked modules.
* fix(context-collapse): re-arm after reset and gate ctx_inspect on opt-in
resetContextCollapse() left armed=false while enabled stayed true, so the
first /compact, main-thread compaction cleanup, or rewind permanently
disabled collapse for the rest of an opted-in session. Reset now mirrors
restoreContextCollapseState and sets armed=enabled.
CtxInspectTool.isEnabled() returned true unconditionally, advertising
ctx_inspect to the model in every default session even when the runtime
opt-in was off. It now returns isContextCollapseEnabled(). The opt-in is
also exposed as the contextCollapseEnabled global config key, so it is
reachable through /config instead of only the CLAUDE_CONTEXT_COLLAPSE env
var.
* refactor(context-collapse): drop no-op ternary in drainStaged persist call
The (stagedQueue.length > 0 ? 0 : 0) subtrahend always evaluated to 0, so
this is just persistCommits(processed.length).
* fix(context-collapse): persist commits before advancing the snapshot
drainStaged removed processed spans from the staged queue and then fired
persistCommits and persistSnapshot in parallel. If the snapshot write (which
no longer lists those spans as staged) landed while the commit write failed
or the process died between them, restore would find the spans neither staged
nor committed and the collapse would disappear on resume. Chain the snapshot
write after the commit write so the commit log is durable first.
* fix(context-collapse): project committed collapses on the query path, fix opt-in reach
Three issues from review:
- Committed collapses were never re-applied to the model input. The query path
calls applyCollapsesIfNeeded but only drained staged spans; projectView (which
replays the commit log) ran only in /context. Since messagesForQuery is rebuilt
from full REPL history each turn and the commit log is repopulated on resume,
the archived spans returned to the model on the next turn, undoing the collapse.
applyCollapsesIfNeeded now runs projectView first (idempotent). Adds a
regression that a committed collapse changes the next query input.
- Cache-safe params were saved only for exact repl_main_thread/sdk sources, but
the REPL tags non-default output styles as repl_main_thread:outputStyle:*, so
those sessions left the ctx-agent without params (empty spawns). Matches
repl_main_thread:* now, via a small tested helper.
- contextCollapseEnabled had no settings control. Adds a /config toggle that
refreshes runtime state (re-runs initContextCollapse) so it applies without a
restart.
* fix(context-collapse): clear already-committed staged spans; harden config toggle
After projecting committed collapses before draining, a span present in both the
commit log and the staged snapshot (a restore whose snapshot predates the
matching commit write) could not be drained — projectView had already removed
its messages — so it lingered in stagedQueue and distorted spawn/overflow
checks. drainStaged now drops staged spans that are already committed and syncs
the snapshot. Adds a regression covering the committed+staged overlap restore.
Also wraps the /config context-collapse refresh in try/catch so a failed
require/init can't crash the settings UI, and lists the toggle in the
save-and-close change summary like the neighboring compaction settings.
* fix(context-collapse): re-sync runtime state on config cancel
The context-collapse toggle's onChange refreshes the module-level
enabled/armed cache via initContextCollapse(). The revert path restored
the config key on disk but left that cache untouched, so enabling the
toggle and then pressing Escape kept collapse active for the rest of the
session. Re-init context collapse after the global config snapshot is
restored so cancel fully reverts runtime state.
* fix(context-collapse): keep collapsed summaries visible to the model
projectView and drainStaged replaced an archived span with a system
informational placeholder, but normalizeMessagesForAPI filters out every
system message that is not a local command. So once a collapse committed,
the next model request lost both the archived messages and the
<collapsed> summary meant to stand in for them, defeating the feature.
Mark the placeholder with isCollapseSummary and let it take the same
model-input path as local-command system messages (converted to a user
message), so the summary survives normalization. Added a regression that
runs the projected view through normalizeMessagesForAPI and asserts the
summary is still present.
* fix(context-collapse): avoid competing snapshot write after drain
After an immediate post-spawn drain, drainStaged(messages, true) starts
its own persistCommits().then(persistSnapshot) chain to guarantee commit
durability before the snapshot stops listing the staged spans. The
unconditional await persistSnapshot() that followed could win that race
and persist a snapshot with no staged spans before the commits landed,
reopening the crash window that drops collapses on restore. Only persist
directly when nothing was drained.
* fix(context-collapse): fall back, keep summaries non-snippable, gate /context
Three review findings:
- Suppress autocompact and the blocking preempt only when collapse holds a
real committed/staged reduction, not on mere enablement. Adds
hasActiveReduction(); a first over-threshold turn where spawnCtxAgent cannot
produce a span (getLastCacheSafeParams() still null) now falls back to
autocompact/blocking instead of sending an oversized transcript.
- Preserve isMeta when converting a collapse-summary placeholder to a user
message in normalizeMessagesForAPI, so the HISTORY_SNIP sweep cannot tag the
only replacement for an archived span as snippable.
- Gate the two /context projectView calls on isContextCollapseEnabled(), so a
disabled session does not under-report token usage from a lingering commit
log while the API receives the full transcript.
Adds regressions for hasActiveReduction and for the summary surviving
normalization as a non-snippable meta message.
* fix(context-collapse): scope collapse to the main thread that owns the store
The collapse store (commitLog/stagedQueue) is module-level and shared by
in-process subagents (agent:*) and the ctx-agent (marble_origami), which
run in the same process but do not own the main transcript.
applyCollapsesIfNeeded only skipped marble_origami, so a subagent could
stage or commit a span, flip the global hasActiveReduction(), and make
the next main-thread turn suppress autocompact and the blocking
prompt-too-long preempt while projectView() no-ops against the main
messages, sending an oversized transcript to the API.
Add isMainThreadSource() and gate both application (applyCollapsesIfNeeded,
isWithheldPromptTooLong, recoverFromOverflow) and fallback suppression
(autoCompact shouldAutoCompact, query collapseOwnsIt) to the owning
thread. Subagents now autocompact and preempt their own oversized turns
normally and never mutate the shared store.
Also adds the staged-only hasActiveReduction regression CodeRabbit
requested.
* fix(context-collapse): persist archived count so resumed stats stay accurate
restoreContextCollapseState rebuilt each commit with an empty archived
list, and getStats summed that list, so after a resume /context, the
context visualization, the token warning, and ctx_inspect reported
'N spans summarized (0 messages)' even though projectView was actively
removing the archived spans. The persisted-entry docstring claimed
projectView lazily refills the archive, but it only splices by boundary
uuid and never does.
The archived messages are never read back (only their count fed
getStats), so replace the per-commit Message[] with a persisted
archivedCount. It is written with each commit and restored on resume;
pre-field sessions restore as 0. getStats now reports the same figure
live and after resume.
* fix(context-collapse): keep collapse summary non-snippable across user merge
Preserving isMeta on the system->user conversion was not enough: when the
collapsed span ends right before the next user turn, normalizeMessagesForAPI
merges the summary into that real user message. Under HISTORY_SNIP
mergeUserMessages clears isMeta whenever an operand is real user content and
keeps the real turn's uuid, so the combined block — which carries the only
<collapsed> replacement for the archived span — got a snip id and the model
could queue it for removal.
Carry an isCollapseSummary marker onto the converted user message and through
mergeUserMessages (either operand), strip any snip id already baked into the
real turn when the merge absorbs a summary, and skip such blocks in
appendMessageTagToUserMessage. The merged block stays non-snippable
regardless of merge direction or isMeta being cleared.
* fix(context-collapse): preserve collapse marker on split, drop empty snip blocks
normalizeMessages split path now forwards isCollapseSummary so an array-backed
collapse summary keeps its non-snippable marker across API normalization.
stripSnipTagsFromContent drops a text block whose only content was the snip
marker, so the merge recovery path no longer emits an empty text block.
49 lines
2.0 KiB
TypeScript
49 lines
2.0 KiB
TypeScript
import { existsSync, readFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { expect, test } from 'bun:test'
|
|
|
|
// Regression guard for #856. Several build feature flags require source files
|
|
// that are not mirrored into the open build. When such a flag is set to `true`
|
|
// without the source present, the bundler falls back to a missing-module stub
|
|
// that only exports `default`, which causes runtime errors like
|
|
// `fetchMcpSkillsForClient is not a function` when downstream code reaches
|
|
// through the `require()` to a named export.
|
|
//
|
|
// This test fails fast at test-time if someone re-enables one of these flags
|
|
// without first mirroring the corresponding source file.
|
|
|
|
const BUILD_SCRIPT = join(import.meta.dir, 'build.ts')
|
|
const REPO_ROOT = join(import.meta.dir, '..')
|
|
|
|
type FlagGuard = {
|
|
flag: string
|
|
source: string // path relative to repo root
|
|
}
|
|
|
|
const FLAG_REQUIRES_SOURCE: FlagGuard[] = [
|
|
{ flag: 'MCP_SKILLS', source: 'src/skills/mcpSkills.ts' },
|
|
{ flag: 'CONTEXT_COLLAPSE', source: 'src/services/contextCollapse/index.ts' },
|
|
]
|
|
|
|
test('build feature flags are not enabled without their source files', () => {
|
|
const buildScript = readFileSync(BUILD_SCRIPT, 'utf-8')
|
|
|
|
for (const { flag, source } of FLAG_REQUIRES_SOURCE) {
|
|
const enabledRe = new RegExp(`^\\s*${flag}\\s*:\\s*true\\b`, 'm')
|
|
const isEnabled = enabledRe.test(buildScript)
|
|
const sourceExists = existsSync(join(REPO_ROOT, source))
|
|
|
|
if (isEnabled && !sourceExists) {
|
|
throw new Error(
|
|
`Feature flag ${flag} is enabled in scripts/build.ts, but its required source file "${source}" does not exist. ` +
|
|
`Enabling this flag without the source will cause runtime errors (missing named exports from the missing-module stub). ` +
|
|
`Either mirror the source file or set ${flag}: false.`,
|
|
)
|
|
}
|
|
|
|
// When the source IS present, the flag can be either true or false; either
|
|
// is fine. We only care about the "enabled but missing" combination.
|
|
expect(isEnabled && !sourceExists).toBe(false)
|
|
}
|
|
})
|