3 Commits
Author SHA1 Message Date
beardthelionandGitHub d5588ea80d feat(context-collapse): opt-in between-turns context collapse (span summarization) (#1619)
* 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.
2026-06-17 11:02:54 +08:00
JATMNandGitHub 9190bd0c50 Harden test isolation and smoke checks (#1440)
* fix(test): isolate provider-related attribution and preconnect tests

Remove process-global provider mocks from apiPreconnect tests and exercise real env-based provider resolution with hermetic first-party setup.

Reset bootstrap/settings state around attribution tests and reload the attribution module per test so provider and client state cannot leak across suites.

Verification: bun test --max-concurrency=1 src/utils/apiPreconnect.test.ts src/utils/attribution.test.ts

* Fix full local check failures

Add a check script that runs smoke plus the full single-concurrency Bun test suite, and wire it into CONTRIBUTING, the PR template, and PR checks.

Fix Windows/full-suite failures by preferring Git Bash over the WSL bash launcher, normalizing settings paths before source matching, making path and warning-glyph tests platform-aware, and restoring persistent Bun module mocks for AgentTool and hook-chain tests.

Verified with bun test src\tools\BashTool\BashTool.errorOutput.test.ts --max-concurrency=1 and bun run check.

* fix(test): eliminate mock.module() leaks and platform-specific test failures

## Problem

The full test suite (bun test --max-concurrency=1) had 10 failing tests on
Windows. Investigation revealed 4 distinct root causes, all stemming from
bun's mock.module() not being fully reversible by mock.restore(). When a
test file replaces a shared module via mock.module(), stale bindings persist
in already-imported modules even after mock.restore() is called. This is a
known bun limitation.

The CI (Ubuntu) only showed 1 consistent failure (the attribution test),
but the Windows-local failures exposed real bugs that could surface in CI
under different test ordering.

## Changes

### src/utils/hookChains.integration.test.ts (root polluter)

This file was the biggest source of test pollution with 9 mock.module()
calls replacing shared modules (analytics, growthbook, policyLimits,
teammateMailbox, teammate, AgentTool, replBridge, etc.) with partial
surfaces. For example, the teammateMailbox mock only exported writeToMailbox
but the real module has 20+ exports including isIdleNotification,
createIdleNotification, readMailbox, etc. When mock.restore() didn't fully
undo these mocks, downstream tests got undefined for missing exports.

Fix: Import real modules via cache-busted dynamic imports before setting up
mocks, then spread the real module surface into each mock.module() call.
This way even if the mock leaks, downstream tests see the full module
surface with only the intended overrides. All 9 mock.module calls now
spread their real module counterparts.

Also fixed: the test was failing in isolation with SyntaxError because
attachments.ts transitively imports isIdleNotification from
teammateMailbox.js, which was missing from the partial mock.

### src/utils/settings/changeDetector.test.ts (Windows path normalization)

4 tests failed because getSourceForPath() normalizes paths using
path.normalize() which converts forward slashes to backslashes on Windows.
The test hardcoded Unix-style paths (/tmp/openclaude/user/settings.json)
but path.normalize produces \tmp\openclaude\user\settings.json on
Windows. The path comparison always failed, so handleChange() returned
early without triggering any callbacks or debounce timers.

Fix: Import normalize from 'path' and apply it to all test path constants
(pathsBySource, getManagedSettingsDropInDir). This matches what the
production code does.

### src/utils/exportFormats.test.ts (Windows path separator)

resolveExportFilepath() uses path.join() which produces backslash-separated
paths on Windows. The test expected forward-slash paths.

Fix: Import join from 'path' and use it in the expected value so the
assertion is platform-agnostic.

### src/utils/file.test.ts (growthbook mock leak)

importFileModuleWithKillswitchEnabled() mocked growthbook.js with only
getFeatureValue_CACHED_MAY_BE_STALE: () => killswitchEnabled. When
killswitchEnabled was false, this poisoned isAgentSwarmsEnabled() for all
downstream tests because agentSwarmsEnabled.ts has a static import of
getFeatureValue_CACHED_MAY_BE_STALE that captured the mock binding.

Fix: Import the real growthbook module and spread it into the mock, so
all exports remain available even if the mock leaks.

### src/utils/plugins/officialMarketplaceStartupCheck.test.ts (same pattern)

Same growthbook mock leak pattern. Top-level mock.module with only
getFeatureValue_CACHED_MAY_BE_STALE: () => true.

Fix: Import real growthbook module and spread into mock.

### src/tools/AgentTool/AgentTool.teammateModel.test.ts (transitive mock binding)

4 tests failed with 'Agent Teams is not yet available on your plan' because
isAgentSwarmsEnabled() returned false. The function checks
getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true) from
growthbook.js, but the static import binding in agentSwarmsEnabled.ts was
captured from a leaked mock that returned false.

Cache-busting the AgentTool.js import doesn't help because
agentSwarmsEnabled.ts is a transitive dependency that keeps its
already-loaded (mocked) growthbook binding.

Fix: Add mock.module for agentSwarmsEnabled.js in importAgentToolWithSpawnMock()
to pin isAgentSwarmsEnabled to true, matching the test's intent (it sets
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1).

## Verification

- bun run smoke: passes
- bun test --max-concurrency=1: 3019 pass, 0 fail (verified twice)
- No skipped tests (test.skip/it.skip/describe.skip), no test.todo,
  no flaky markers, no test exclusions in config

## Known remaining risks

6 test files still have partial mock.module() calls on providers.js
(withRetry, officialRegistry, domainCheck, conversationRecovery, fastMode)
that don't spread the real module. These don't cause failures under current
test ordering but are latent risks if bun changes file execution order.

* Fix remaining provider mock leak risks

Address the known remaining risks from 7583157 by making provider mocks in withRetry, officialRegistry, and fastMode tests spread and restore the real providers module surface.

Verified with the targeted provider-mock test group and bun run check.

* Harden smoke test coverage

Remove the CI-only skip and unrelated error swallowing from the SDK query lifecycle tests so fork/resume behavior is asserted in CI and local runs.

Isolate test-suite global state by disabling built-in SDK agents for the lifecycle test, restoring MACRO presence exactly, clearing the agent cache, restoring axios mocks, and protecting xAI loopback tests from proxy/fetch leakage.

Make the provider API test script run serially to match the shared env/proxy mutation surface.

Validation: bun run check; CI=1 bun test tests\\sdk\\query-lifecycle.test.ts --max-concurrency=1; bun run test:provider; npm run test:provider-recommendation; bun run security:pr-scan -- --base upstream/main; bun run web:typecheck; bun run web:build; python -m pytest -q -p no:cacheprovider python/tests.

* Expose hidden SDK test failures

Tighten SDK test drains so they only suppress expected lifecycle abort errors instead of swallowing arbitrary init and bootstrap failures.

Replace no-op test assertions with real checks and add V2 lifecycle isolation for MACRO, built-in agents, and agent cache state.

Fix SDK V2 sendMessage to fast-exit when the caller-provided AbortController is already aborted, preventing aborted sessions from submitting work and producing result messages.

Validation: bun test scripts\\feature-flags-source-guard.test.ts tests\\sdk\\query-concurrency.test.ts tests\\sdk\\sdk-v2-lifecycle.test.ts --max-concurrency=1; bun test tests\\sdk\\query-concurrency.test.ts tests\\sdk\\query-lifecycle.test.ts tests\\sdk\\sdk-v2-lifecycle.test.ts --max-concurrency=1; bun run check.

* Fix CI smoke test failures

Respect SDK context null session project directories so regenerated SDK sessions do not fall back to global project state.

Isolate attribution tests from CI provider/model environment and replace nondeterministic live query permission checks with direct assertions against the SDK permission machinery.

Validation: bun test src\\utils\\attribution.test.ts tests\\sdk\\query-lifecycle.test.ts tests\\sdk\\permissions.test.ts --max-concurrency=1; bun test tests\\sdk\\sdk-context-isolation.test.ts tests\\sdk\\query-concurrency.test.ts --max-concurrency=1; bun run check.

* Stabilize attribution contract test

Assert that includeCoAuthoredBy emits the default co-author trailer without pinning the active provider's model label, which can legitimately differ in CI provider environments.

Validation: bun test src\\utils\\attribution.test.ts --max-concurrency=1; ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 CLAUDE_CODE_USE_BEDROCK=1 bun test src\\utils\\attribution.test.ts --max-concurrency=1; bun run check.
2026-05-30 14:40:33 +08:00
KRATOSandGitHub dcbe29558a fix(mcp): disable MCP_SKILLS feature flag — source not mirrored (#872)
Closes #856.

MCP servers that expose resources (e.g. RepoPrompt) failed to load
their tools in the open build with:

    Error fetching tools/commands/resources:
    fetchMcpSkillsForClient is not a function

Root cause: scripts/build.ts set MCP_SKILLS: true, which made
feature('MCP_SKILLS') evaluate to true at build time. The guards
around the dynamic skill discovery path therefore stayed live. The
underlying source file src/skills/mcpSkills.ts is not mirrored into
the open tree, so the bundler fell back to its generic missing-module
stub — which only exports `default` for require()-style imports, not
the named `fetchMcpSkillsForClient` binding. At runtime the require
returned an object without that property, and calling it threw.

`openclaude mcp doctor` reported RepoPrompt as healthy because doctor
does not exercise the skills-fetch path.

Fix: flip MCP_SKILLS to false and move it into the "Disabled: missing
source" group. With the flag off, every `if (feature('MCP_SKILLS'))`
guard becomes a no-op at build time, the require() branch is dead
code, and MCP servers with resources load normally via the existing
`Promise.resolve([])` fallbacks already present at each call site.

Also adds scripts/feature-flags-source-guard.test.ts to fail fast if
MCP_SKILLS (or any future flag in the same category) is re-enabled
without the corresponding source file being mirrored first.

Verification:
  - Test fails on main, passes with this fix
  - `bun run build` produces a bundle with no
    `missing-module-stub:../../skills/mcpSkills.js` reference
  - Full `bun test` — 1222 pass / 12 fail (same pre-existing 12 as
    main; new test adds the +1 pass)
2026-04-24 11:35:59 +08:00