From f3543966fe41fd7b990e6d1c8966993d57548196 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 26 Jul 2026 15:17:10 -0400 Subject: [PATCH 1/8] test: add native compaction behavior regression suite Consolidated Kimi + Codex auto-compaction contract pinning the four recurred defect classes (Forge episode 851158bf): - N1 native settings: buildProviderSettings never disables auto-compact for kimi/codex (codex native; kimi armed with real window incl. K3 1M) - N2 thresholds: reserveBasedThreshold per-provider (kimi 45k vs codex 33k) and per-model CLAUDE_CODE_AUTO_COMPACT_WINDOW source values - N3 NeoKai fallback: PROVIDER_NO_SDK_AUTO_COMPACT empty invariant; shouldUseHyperNeoCompactFallback false for kimi/codex (dormant extension point) - N4 no /compact injection: handler-level gate for codex + kimi-k3 (previously only kimi-k2.7 covered) and the internal-flag transcript- exclusion mechanism Fills the gaps left by the scattered unit tests (query-options-builder, kimi-provider, anthropic-to-codex-bridge, sdk-message-handler) with a single self-contained contract suite. --- .../agent/native-compaction-behavior.test.ts | 621 ++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts diff --git a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts new file mode 100644 index 000000000..371027799 --- /dev/null +++ b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts @@ -0,0 +1,621 @@ +/** + * Native Compaction Behavior — regression suite (Task #754). + * + * Pins the Kimi + Codex auto-compaction contract that recurred as four defect + * classes (Forge evidence episode 851158bf-aeb3-4f63-b313-8838d3341bac): + * + * C1 premature disablement — SDK auto-compact turned off, causing overflow + * C2 wrong threshold — compaction firing at the wrong window + * C3 SDK/runtime mismatch — HyperNeo fallback racing/preempting native + * C4 text injection — literal `/compact` inserted into the + * conversation transcript or provider request + * + * Four contract groups, one per defect class. The deep per-value unit coverage + * lives in `query-options-builder.test.ts`, `kimi-provider.test.ts`, + * `anthropic-to-codex-bridge-provider.test.ts`, and `sdk-message-handler.test.ts`; + * this suite CONSOLIDATES the Kimi/Codex contract into one place and fills the + * gaps those files leave (Codex + Kimi-K3 handler-level gating, the empty + * fallback-set invariant, the per-provider reserve threshold, and the + * transcript-exclusion mechanism for `/compact`). + * + * N1 native SDK auto-compaction is used — never disabled for kimi/codex (C1) + * N2 provider-specific thresholds are respected — kimi 45k vs codex 33k + * reserve; per-model context windows (C2) + * N3 NeoKai (HyperNeo) fallback applied only where intended — the proactive + * `/compact` fallback is a dormant extension point; kimi/codex never opt + * in (C3) + * N4 literal `/compact` never enters the transcript or provider request — + * the handler-level gate for kimi/codex, plus the internal-flag + * transcript-exclusion mechanism (C4) + */ + +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import type { MessageContent, MessageHub, Session } from '@hyperneo/shared'; +import type { SDKMessage, SDKUserMessage } from '@hyperneo/shared/sdk'; +import type { ContextTracker } from '../../../../src/lib/agent/context-tracker'; +import { reserveBasedThreshold } from '../../../../src/lib/agent/context-tracker'; +import { MessageQueue } from '../../../../src/lib/agent/message-queue'; +import type { ProcessingStateManager } from '../../../../src/lib/agent/processing-state-manager'; +import type { QueryLifecycleManager } from '../../../../src/lib/agent/query-lifecycle-manager'; +import { + buildProviderSettings, + PROVIDER_NO_SDK_AUTO_COMPACT, + shouldUseHyperNeoCompactFallback, +} from '../../../../src/lib/agent/query-options-builder'; +import { + SDKMessageHandler, + type SDKMessageHandlerContext, +} from '../../../../src/lib/agent/sdk-message-handler'; +import type { ErrorManager } from '../../../../src/lib/error-manager'; +import type { + DaemonInternalEventMap, + InternalEventBus, +} from '../../../../src/lib/internal-event-bus'; +import { setModelsCache } from '../../../../src/lib/model-service'; +import { + getModelContextWindow, + MODEL_CONTEXT_WINDOWS, +} from '../../../../src/lib/providers/codex-models'; +import { KimiProvider } from '../../../../src/lib/providers/kimi-provider'; +import type { Database } from '../../../../src/storage/database'; + +// --------------------------------------------------------------- N1: native --- + +describe('N1: native SDK auto-compaction is used (never disabled for kimi/codex)', () => { + // The C1 regression: a provider's SDK auto-compact being turned off + // (`{ autoCompactEnabled: false }`) created a dead zone — no SDK compact, and + // HyperNeo's async fallback fires after turns so it cannot prevent + // within-turn/resume overflow. Kimi overflowed ~7.7% of sessions that way. + // The contract: buildProviderSettings must NEVER disable auto-compact for a + // Kimi or Codex model — it either trusts the SDK natively (codex → undefined) + // or arms it with the real window (kimi). + + const CASES: Array<{ + label: string; + provider: string; + contextWindow: number; + model: string; + expected: 'native' | { autoCompactEnabled: true; autoCompactWindow: number }; + }> = [ + { + label: 'kimi K2.7 (262144) — armed with real window', + provider: 'kimi', + contextWindow: 262_144, + model: 'kimi-k2.7-code', + expected: { autoCompactEnabled: true, autoCompactWindow: 262_144 }, + }, + { + label: 'kimi K3 (1M) — armed with 1M window', + provider: 'kimi', + contextWindow: 1_048_576, + model: 'kimi-k3', + expected: { autoCompactEnabled: true, autoCompactWindow: 1_048_576 }, + }, + { + label: 'codex gpt-5.5 (272000) — native, no override', + provider: 'anthropic-codex', + contextWindow: 272_000, + model: 'gpt-5.5', + expected: 'native', + }, + { + label: 'codex-mini (128000) — native, no override', + provider: 'anthropic-codex', + contextWindow: 128_000, + model: 'gpt-5.4-mini', + expected: 'native', + }, + ]; + + it.each(CASES.map((c) => [c.label, c] as const))('%s', (_label, c) => { + const settings = buildProviderSettings(c.provider, c.contextWindow, c.model); + if (c.expected === 'native') { + // Native: SDK auto-compact is trusted as-is (no settings override). + expect(settings).toBeUndefined(); + } else { + expect(settings).toEqual(c.expected); + } + }); + + it('never returns autoCompactEnabled:false for any Kimi or Codex model (C1 invariant)', () => { + // Iterate the real model catalogue rather than hand-picked values so a new + // Kimi/Codex model cannot silently land in the disabled-fallback branch. + const kimiModels = KimiProvider.MODELS.map((m) => ({ + provider: 'kimi', + model: m.id, + window: m.contextWindow, + })); + const codexModels = ( + Object.keys(MODEL_CONTEXT_WINDOWS) as Array + ).map((id) => ({ provider: 'anthropic-codex', model: id, window: MODEL_CONTEXT_WINDOWS[id] })); + for (const { provider, model, window } of [...kimiModels, ...codexModels]) { + const settings = buildProviderSettings(provider, window, model); + expect(settings?.autoCompactEnabled, `${provider}/${model}`).not.toBe(false); + } + }); + + it('keeps Kimi K3 native even when no context window is reported', () => { + // K3 is special-cased to surface its real 1M window regardless of the + // metadata-supplied value, so a missing window must NOT collapse into the + // generic "unknown window → undefined" branch and lose the K3 intent. + expect(buildProviderSettings('kimi', undefined, 'kimi-k3')).toEqual({ + autoCompactEnabled: true, + autoCompactWindow: 1_048_576, + }); + }); +}); + +// ---------------------------------------------------------- N2: thresholds --- + +describe('N2: provider-specific reserve thresholds are respected', () => { + // The C2 regression class: compaction firing at the wrong window. HyperNeo's + // fallback reserve mirrors the SDK's own buffer so it would fire at the same + // point the SDK does — but Kimi's ~32k max output + mandatory reasoning + // demands a larger 45k reserve, while every other provider (incl. Codex) + // uses the SDK-standard 33k. These values are the compaction contract. + + it('uses a 45k reserve for Kimi (K2.7 + K3) and a 33k reserve for Codex', () => { + expect(reserveBasedThreshold(262_144, 'kimi')).toBe(262_144 - 45_000); // 217144 + expect(reserveBasedThreshold(1_048_576, 'kimi')).toBe(1_048_576 - 45_000); // 1003576 + expect(reserveBasedThreshold(272_000, 'anthropic-codex')).toBe(272_000 - 33_000); // 239000 + expect(reserveBasedThreshold(128_000, 'anthropic-codex')).toBe(128_000 - 33_000); // 95000 + }); + + it('uses the SDK-standard 33k reserve when no provider is given', () => { + expect(reserveBasedThreshold(200_000)).toBe(200_000 - 33_000); // 167000 + }); + + it('floors the threshold at 0 for non-positive / non-finite windows', () => { + expect(reserveBasedThreshold(0, 'kimi')).toBe(0); + expect(reserveBasedThreshold(-5, 'anthropic-codex')).toBe(0); + expect(reserveBasedThreshold(Number.POSITIVE_INFINITY, 'kimi')).toBe(0); + expect(reserveBasedThreshold(Number.NaN)).toBe(0); + }); + + it('Kimi reserve stays strictly larger than the Codex/default reserve at every window', () => { + // The provider-specific delta is the whole point: Kimi must compact earlier. + for (const window of [128_000, 200_000, 262_144, 272_000, 1_048_576]) { + const kimi = reserveBasedThreshold(window, 'kimi'); + const codex = reserveBasedThreshold(window, 'anthropic-codex'); + expect(kimi, `window=${window}`).toBeLessThan(codex); + expect(codex - kimi, `window=${window}`).toBe(45_000 - 33_000); // 12000 + } + }); + + it('the per-model Codex windows that feed CLAUDE_CODE_AUTO_COMPACT_WINDOW are the expected values', () => { + // The Codex bridge sets `CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(entry.contextWindow)`; + // these are the threshold-source values (env-var wiring is covered by + // anthropic-to-codex-bridge-provider.test.ts). + expect(getModelContextWindow('gpt-5.5')).toBe(272_000); + expect(getModelContextWindow('gpt-5.3-codex')).toBe(272_000); + expect(getModelContextWindow('gpt-5.4-mini')).toBe(128_000); + expect(getModelContextWindow('codex-mini')).toBe(128_000); // alias resolves + }); + + it('Kimi buildSdkConfig arms CLAUDE_CODE_AUTO_COMPACT_WINDOW per model (K2.7=262144, K3=1M)', () => { + // Kimi buildSdkConfig is pure (no bridge server) so it is safe to drive here. + const provider = new KimiProvider({ KIMI_API_KEY: 'key' }); + expect(provider.buildSdkConfig('kimi-k2.7-code').envVars.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe( + '262144' + ); + expect(provider.buildSdkConfig('kimi-k3').envVars.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe( + '1048576' + ); + }); +}); + +// ------------------------------------- N3: NeoKai fallback (dormant for kimi/codex) + +describe('N3: NeoKai (HyperNeo) fallback applied only where intended', () => { + // The C3 regression class: HyperNeo's proactive async `/compact` fallback + // racing with or preempting the SDK's native auto-compact. The design contract + // is that NO provider uses this fallback — `PROVIDER_NO_SDK_AUTO_COMPACT` is + // intentionally empty. Kimi was previously special-cased into it and that + // caused the ~7.7% overflow (the async fallback cannot prevent within-turn or + // resume overflow). This group pins the empty-set invariant so no provider — + // especially Kimi/Codex — can accidentally opt back into text-injection + // compaction. It also keeps the context-fetcher capacity-mismatch warning + // active for native providers (codex/glm), which is how a C2/C3 regression is + // surfaced in production. + + it('PROVIDER_NO_SDK_AUTO_COMPACT is empty (no provider uses the async /compact fallback)', () => { + expect(PROVIDER_NO_SDK_AUTO_COMPACT.size).toBe(0); + }); + + it.each([ + ['kimi', 'kimi'], + ['anthropic-codex', 'anthropic-codex'], + ['anthropic', 'anthropic'], + ['anthropic-copilot', 'anthropic-copilot'], + ['glm', 'glm'], + ['openrouter', 'openrouter'], + ['ollama', 'ollama'], + ['minimax', 'minimax'], + ['gemini', 'gemini'], + ])('shouldUseHyperNeoCompactFallback(%s) is false', (_label, providerId) => { + expect(shouldUseHyperNeoCompactFallback(providerId)).toBe(false); + }); + + it('every Kimi and Codex model id resolves to no HyperNeo fallback', () => { + const ids = [ + ...KimiProvider.MODELS.map((m) => m.id), + ...(Object.keys(MODEL_CONTEXT_WINDOWS) as Array), + ]; + for (const id of ids) { + // The provider id is what the gate keys on, not the model; assert both + // Kimi and Codex provider ids are inert regardless of selected model. + expect(shouldUseHyperNeoCompactFallback('kimi'), `kimi/${id}`).toBe(false); + expect(shouldUseHyperNeoCompactFallback('anthropic-codex'), `codex/${id}`).toBe(false); + } + }); +}); + +// ----------------------------- N4: literal /compact never in transcript/request + +/** + * Lean harness for the SDKMessageHandler compaction path. Constructs the handler + * exactly as in production; only the I/O collaborators are mocked so the + * assertion runs on whether `/compact` is enqueued (the only path by which + * literal `/compact` text reaches the SDK user-message stream). + */ +interface CompactionRefreshHarness { + handler: SDKMessageHandler; + enqueueSpy: ReturnType; + shouldCompactAtSpy: ReturnType; + markCompactionTriggeredSpy: ReturnType; + getContextUsageSpy: ReturnType; +} + +function driveCompactionRefresh(opts: { + provider: string; + model: string; + contextWindow: number; + totalUsed: number; + sdkMaxTokens?: number; +}): CompactionRefreshHarness { + const session: Session = { + id: 'compact-session', + title: 'Compact Session', + workspacePath: '/test/path', + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + status: 'active', + config: { model: opts.model, maxTokens: 8192, temperature: 1.0, provider: opts.provider }, + metadata: { + messageCount: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0, + toolCallCount: 0, + }, + }; + + const enqueueSpy = mock(async () => 'context-id'); + const shouldCompactAtSpy = mock(() => true); // adversarial: pretend the threshold is crossed + const markCompactionTriggeredSpy = mock(() => {}); + + const db = { + saveSDKMessage: mock(() => true), + updateSession: mock(() => {}), + getMessagesByStatus: mock(() => []), + getMessageByStatusAndUuid: mock(() => null), + updateMessageStatus: mock(() => {}), + updateMessageTimestamp: mock(() => {}), + beginTransaction: mock(() => {}), + commitTransaction: mock(() => {}), + abortTransaction: mock(() => {}), + } as unknown as Database; + + const publish = mock(async () => {}); + const messageHub = { + event: mock(() => {}), + onRequest: mock(() => () => {}), + query: mock(async () => ({})), + command: mock(async () => {}), + } as unknown as MessageHub; + const internalEventBus = { + publish, + publishAsync: publish, + subscribe: mock(() => () => {}), + } as unknown as InternalEventBus; + + const stateManager = { + detectPhaseFromMessage: mock(async () => {}), + setIdle: mock(async () => {}), + setCompacting: mock(async () => {}), + getState: mock(() => ({ phase: 'idle' })), + } as unknown as ProcessingStateManager; + + const contextTracker = { + getContextInfo: mock(() => ({ totalTokens: 1000, maxTokens: 128000 })), + updateWithDetailedBreakdown: mock(() => {}), + shouldCompact: mock(() => false), + shouldCompactAt: shouldCompactAtSpy, + markCompactionTriggered: markCompactionTriggeredSpy, + } as unknown as ContextTracker; + + const messageQueue = { + enqueue: enqueueSpy, + enqueueWithId: mock(async () => {}), + clear: mock(() => {}), + } as unknown as MessageQueue; + + const errorManager = { handleError: mock(async () => {}) } as unknown as ErrorManager; + const lifecycleManager = { stop: mock(async () => {}) } as unknown as QueryLifecycleManager; + + const getContextUsageSpy = mock(async () => ({ + categories: [{ name: 'Messages', tokens: opts.totalUsed }], + totalTokens: opts.totalUsed, + maxTokens: opts.sdkMaxTokens ?? opts.contextWindow, + rawMaxTokens: opts.sdkMaxTokens ?? opts.contextWindow, + percentage: Math.round((opts.totalUsed / (opts.contextWindow || 1)) * 100), + gridRows: [], + model: opts.model, + memoryFiles: [], + mcpTools: [], + agents: [], + isAutoCompactEnabled: true, + apiUsage: null, + })); + + const ctx: SDKMessageHandlerContext = { + session, + db, + messageHub, + internalEventBus, + stateManager, + contextTracker, + messageQueue, + errorManager, + lifecycleManager, + queryObject: { getContextUsage: getContextUsageSpy } as never, + queryPromise: null, + onInitSlashCommands: mock(async () => {}), + onCommandsChanged: mock(async () => {}), + }; + + return { + handler: new SDKMessageHandler(ctx), + enqueueSpy, + shouldCompactAtSpy, + markCompactionTriggeredSpy, + getContextUsageSpy, + }; +} + +describe('N4: literal /compact never enters the transcript or provider request', () => { + // The C4 regression class: `/compact` injected as a user message that becomes + // part of the conversation transcript or the provider request. The proactive + // fallback is the ONLY path that enqueues `/compact` from the context-refresh + // flow; it is gated by shouldUseHyperNeoCompactFallback. For Kimi and Codex + // that gate is closed (N3), so the enqueue is structurally unreachable — + // proven here at the handler level (not just the function level) so a wiring + // regression that drops the gate is caught. + + afterEach(() => { + setModelsCache(new Map()); + }); + + function resultMessage(): SDKMessage { + return { + type: 'result', + subtype: 'success', + uuid: 'result-uuid', + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + total_cost_usd: 0.001, + modelUsage: {}, + } as unknown as SDKMessage; + } + + it('Codex session near capacity does NOT enqueue /compact (SDK native handles it)', async () => { + // Codex (anthropic-codex) is in NATIVE_CONTEXT_WINDOW_PROVIDER_IDS and its + // bridge routes real Codex IDs with preferContextWindowMetadata, so the SDK + // reads the correct 272k/128k windows and its own auto-compact fires at the + // right threshold. HyperNeo must not inject `/compact`. + setModelsCache( + new Map([ + [ + 'global', + [ + { + id: 'gpt-5.5', + name: 'GPT-5.5', + provider: 'anthropic-codex', + contextWindow: 272_000, + available: true, + }, + ], + ], + ]) + ); + + const harness = driveCompactionRefresh({ + provider: 'anthropic-codex', + model: 'gpt-5.5', + contextWindow: 272_000, + totalUsed: 260_000, // ~96% of capacity — well past any reserve threshold + sdkMaxTokens: 272_000, + }); + + await harness.handler.handleMessage(resultMessage()); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.getContextUsageSpy).toHaveBeenCalledTimes(1); + // The gate short-circuits before the threshold check. + expect(harness.shouldCompactAtSpy).not.toHaveBeenCalled(); + expect(harness.markCompactionTriggeredSpy).not.toHaveBeenCalled(); + expect(harness.enqueueSpy).not.toHaveBeenCalledWith('/compact', true); + }); + + it('Codex-mini (128k) session near capacity does NOT enqueue /compact', async () => { + setModelsCache( + new Map([ + [ + 'global', + [ + { + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + provider: 'anthropic-codex', + contextWindow: 128_000, + available: true, + }, + ], + ], + ]) + ); + + const harness = driveCompactionRefresh({ + provider: 'anthropic-codex', + model: 'gpt-5.4-mini', + contextWindow: 128_000, + totalUsed: 120_000, // ~94% of capacity + sdkMaxTokens: 128_000, + }); + + await harness.handler.handleMessage(resultMessage()); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.shouldCompactAtSpy).not.toHaveBeenCalled(); + expect(harness.enqueueSpy).not.toHaveBeenCalledWith('/compact', true); + }); + + it('Kimi K3 (1M) session near capacity does NOT enqueue /compact', async () => { + // K2.7 is already covered in sdk-message-handler.test.ts; K3 (the 1M model) + // is the gap. Even at ~99% of the 1M window the gate must hold. + setModelsCache( + new Map([ + [ + 'global', + [ + { + id: 'kimi-k3', + name: 'Kimi K3', + provider: 'kimi', + contextWindow: 1_048_576, + preferContextWindowMetadata: true, + available: true, + }, + ], + ], + ]) + ); + + const harness = driveCompactionRefresh({ + provider: 'kimi', + model: 'kimi-k3', + contextWindow: 1_048_576, + totalUsed: 1_040_000, // ~99% of capacity + sdkMaxTokens: 1_048_576, + }); + + await harness.handler.handleMessage(resultMessage()); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.getContextUsageSpy).toHaveBeenCalledTimes(1); + expect(harness.shouldCompactAtSpy).not.toHaveBeenCalled(); + expect(harness.markCompactionTriggeredSpy).not.toHaveBeenCalled(); + expect(harness.enqueueSpy).not.toHaveBeenCalledWith('/compact', true); + }); + + it('holds across the Kimi/Codex model matrix (every model near capacity, no /compact)', async () => { + const matrix = [ + { provider: 'kimi', model: 'kimi-k2.7-code', contextWindow: 262_144, sdkMaxTokens: 200_000 }, + { provider: 'kimi', model: 'kimi-for-coding', contextWindow: 262_144, sdkMaxTokens: 200_000 }, + { provider: 'kimi', model: 'kimi-k3', contextWindow: 1_048_576, sdkMaxTokens: 1_048_576 }, + { + provider: 'anthropic-codex', + model: 'gpt-5.5', + contextWindow: 272_000, + sdkMaxTokens: 272_000, + }, + { + provider: 'anthropic-codex', + model: 'gpt-5.3-codex', + contextWindow: 272_000, + sdkMaxTokens: 272_000, + }, + { + provider: 'anthropic-codex', + model: 'gpt-5.4-mini', + contextWindow: 128_000, + sdkMaxTokens: 128_000, + }, + ]; + + for (const c of matrix) { + setModelsCache( + new Map([ + [ + 'global', + [ + { + id: c.model, + name: c.model, + provider: c.provider, + contextWindow: c.contextWindow, + available: true, + }, + ], + ], + ]) + ); + // Drive at totalUsed past the largest reserve threshold for this window. + const harness = driveCompactionRefresh({ + provider: c.provider, + model: c.model, + contextWindow: c.contextWindow, + totalUsed: c.contextWindow - 1_000, // 1k shy of the full window + sdkMaxTokens: c.sdkMaxTokens, + }); + await harness.handler.handleMessage(resultMessage()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(harness.enqueueSpy, `${c.provider}/${c.model}`).not.toHaveBeenCalledWith( + '/compact', + true + ); + expect(harness.markCompactionTriggeredSpy, `${c.provider}/${c.model}`).not.toHaveBeenCalled(); + } + }); + + it('the only /compact enqueue path is internal (excluded from the transcript)', async () => { + // Even if the dormant fallback DID fire, the `/compact` it enqueues is + // marked internal:true. This test pins the transcript-exclusion mechanism: + // an internal `/compact` is yielded to the SDK as a user message (so the + // SDK runs its built-in /compact slash command — a structured control, not + // prompt text), but it never reaches the yield-time DB/UI broadcast hook + // that persists conversation turns. Combined with the handler gate above, + // this is why literal `/compact` never lands in the Kimi/Codex transcript. + const queue = new MessageQueue(); + const yieldedSpy = mock(() => {}); + queue.onMessageYielded = yieldedSpy; + queue.start(); + + let yielded: (SDKUserMessage & { internal?: boolean }) | undefined; + const consumer = (async () => { + for await (const entry of queue.messageGenerator('sess')) { + yielded = entry.message as SDKUserMessage & { internal?: boolean }; + entry.onSent(); + break; + } + })(); + + await queue.enqueue('/compact', true); + await consumer; + queue.stop(); + + expect(yielded).toBeDefined(); + expect(yielded?.internal).toBe(true); + expect(yielded?.message.content).toEqual([ + { type: 'text', text: '/compact' }, + ] as MessageContent[]); + // internal messages never hit the yield-time persistence/broadcast hook. + expect(yieldedSpy).not.toHaveBeenCalled(); + }); +}); From 65c1c91e23ad64322d88604ffb8fcc84ed32dc57 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 26 Jul 2026 16:37:32 -0400 Subject: [PATCH 2/8] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20pin?= =?UTF-8?q?=20production=20internal-flag=20call=20site=20+=20clarify=20N2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N4: add a test that opens the fallback gate for a test-only provider (PROVIDER_NO_SDK_AUTO_COMPACT, restored in finally) and drives the handler to verify the production enqueue call site passes internal=true. The MessageQueue test now documents it pins the mechanism only. - N2: reframe to distinguish the active SDK-native window (buildProviderSettings + CLAUDE_CODE_AUTO_COMPACT_WINDOW, the effective kimi/codex threshold) from the dormant fallback reserve (reserveBasedThreshold), with a bridging test tying them. reserveBasedThreshold is unreachable for kimi/codex while the fallback set stays empty. --- .../agent/native-compaction-behavior.test.ts | 109 +++++++++++++++--- 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts index 371027799..640a6d015 100644 --- a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts +++ b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts @@ -147,14 +147,24 @@ describe('N1: native SDK auto-compaction is used (never disabled for kimi/codex) // ---------------------------------------------------------- N2: thresholds --- -describe('N2: provider-specific reserve thresholds are respected', () => { - // The C2 regression class: compaction firing at the wrong window. HyperNeo's - // fallback reserve mirrors the SDK's own buffer so it would fire at the same - // point the SDK does — but Kimi's ~32k max output + mandatory reasoning - // demands a larger 45k reserve, while every other provider (incl. Codex) - // uses the SDK-standard 33k. These values are the compaction contract. +describe('N2: thresholds — active SDK window (kimi/codex) + dormant fallback reserve', () => { + // The C2 regression class: compaction firing at the wrong window. There are + // TWO threshold surfaces and this group pins both so a regression in either + // is caught: + // + // • ACTIVE path (kimi/codex): the SDK's NATIVE auto-compact, armed by the + // window in buildProviderSettings (N1) + CLAUDE_CODE_AUTO_COMPACT_WINDOW. + // This is what actually governs kimi/codex compaction today. + // • DORMANT fallback reserve: reserveBasedThreshold() is used ONLY by + // HyperNeo's async /compact fallback, which is dormant today + // (PROVIDER_NO_SDK_AUTO_COMPACT is empty — see N3) because the SDK native + // path is strictly safer. It would be re-armed only if a provider were + // opted back into the set; the 45k kimi / 33k codex reserve is the + // contract for that case. reserveBasedThreshold is NOT the effective + // kimi/codex threshold while the set stays empty. it('uses a 45k reserve for Kimi (K2.7 + K3) and a 33k reserve for Codex', () => { + // Dormant-fallback reserve (see header). Not the active kimi/codex threshold. expect(reserveBasedThreshold(262_144, 'kimi')).toBe(262_144 - 45_000); // 217144 expect(reserveBasedThreshold(1_048_576, 'kimi')).toBe(1_048_576 - 45_000); // 1003576 expect(reserveBasedThreshold(272_000, 'anthropic-codex')).toBe(272_000 - 33_000); // 239000 @@ -202,6 +212,26 @@ describe('N2: provider-specific reserve thresholds are respected', () => { '1048576' ); }); + + it('the ACTIVE kimi/codex threshold is the SDK window (buildProviderSettings), not the fallback reserve', () => { + // Because PROVIDER_NO_SDK_AUTO_COMPACT is empty (N3), SDKMessageHandler + // never reaches reserveBasedThreshold for kimi/codex — the SDK native + // auto-compact governs. The active window is the one armed by + // buildProviderSettings; the dormant reserve would be computed FROM that + // same window only if the fallback were re-armed. Pin the relationship so a + // regression to the active window (not the reserve) is what fails. + const k2Window = buildProviderSettings('kimi', 262_144, 'kimi-k2.7-code')?.autoCompactWindow; + const k3Window = buildProviderSettings('kimi', 1_048_576, 'kimi-k3')?.autoCompactWindow; + expect(k2Window).toBe(262_144); + expect(k3Window).toBe(1_048_576); + // And the dormant reserve that would apply to those same active windows: + expect(reserveBasedThreshold(262_144, 'kimi')).toBe(262_144 - 45_000); + expect(reserveBasedThreshold(1_048_576, 'kimi')).toBe(1_048_576 - 45_000); + // Codex stays native (no armed window) — its active threshold is the SDK's + // own, fed by CLAUDE_CODE_AUTO_COMPACT_WINDOW above; reserveBasedThreshold + // only documents what the dormant fallback would use. + expect(buildProviderSettings('anthropic-codex', 272_000, 'gpt-5.5')).toBeUndefined(); + }); }); // ------------------------------------- N3: NeoKai fallback (dormant for kimi/codex) @@ -584,14 +614,13 @@ describe('N4: literal /compact never enters the transcript or provider request', } }); - it('the only /compact enqueue path is internal (excluded from the transcript)', async () => { - // Even if the dormant fallback DID fire, the `/compact` it enqueues is - // marked internal:true. This test pins the transcript-exclusion mechanism: - // an internal `/compact` is yielded to the SDK as a user message (so the - // SDK runs its built-in /compact slash command — a structured control, not - // prompt text), but it never reaches the yield-time DB/UI broadcast hook - // that persists conversation turns. Combined with the handler gate above, - // this is why literal `/compact` never lands in the Kimi/Codex transcript. + it('MessageQueue preserves the internal flag so an internal /compact stays out of the transcript', async () => { + // MECHANISM only: this drives MessageQueue directly (with internal=true + // supplied by the caller) to prove an internal `/compact` is yielded to the + // SDK as a user message (so the SDK runs its built-in /compact slash command + // — a structured control, not prompt text) but never reaches the yield-time + // DB/UI broadcast hook that persists conversation turns. The PRODUCTION call + // site that supplies internal=true is pinned by the next test. const queue = new MessageQueue(); const yieldedSpy = mock(() => {}); queue.onMessageYielded = yieldedSpy; @@ -618,4 +647,56 @@ describe('N4: literal /compact never enters the transcript or provider request', // internal messages never hit the yield-time persistence/broadcast hook. expect(yieldedSpy).not.toHaveBeenCalled(); }); + + it('when the dormant fallback fires, the handler enqueues /compact as internal (production call site)', async () => { + // The kimi/codex cases prove the gate stays CLOSED (no enqueue). To pin the + // production CALL SITE — that SDKMessageHandler enqueues `/compact` with + // internal=true (not false, not omitted) — this test opens the gate for a + // test-only provider by adding it to PROVIDER_NO_SDK_AUTO_COMPACT (the + // production-empty set; restored in `finally` so the N3 invariant holds), + // then drives the handler past the reserve threshold. Combined with the + // MessageQueue mechanism test above, this proves the full production path + // handler → enqueue('/compact', true) → internal (transcript-excluded) + // message. Without this, a regression flipping the call site's `true` to + // `false` would leave the suite green. + const TEST_PROVIDER = 'test-fallback-provider'; + const fallbackSet = PROVIDER_NO_SDK_AUTO_COMPACT as Set; + fallbackSet.add(TEST_PROVIDER); + try { + setModelsCache( + new Map([ + [ + 'global', + [ + { + id: 'fb-model', + name: 'Fallback Model', + provider: TEST_PROVIDER, + contextWindow: 200_000, + available: true, + }, + ], + ], + ]) + ); + const harness = driveCompactionRefresh({ + provider: TEST_PROVIDER, + model: 'fb-model', + contextWindow: 200_000, + totalUsed: 195_000, // past the 167k (200k − 33k) reserve threshold + sdkMaxTokens: 200_000, + }); + await harness.handler.handleMessage(resultMessage()); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.getContextUsageSpy).toHaveBeenCalledTimes(1); + expect(harness.shouldCompactAtSpy).toHaveBeenCalled(); + expect(harness.markCompactionTriggeredSpy).toHaveBeenCalled(); + // The production call site enqueues /compact WITH internal=true. + expect(harness.enqueueSpy).toHaveBeenCalledWith('/compact', true); + } finally { + fallbackSet.delete(TEST_PROVIDER); + setModelsCache(new Map()); + } + }); }); From 5509a517fae61dc6608a2b45757b4ba2aa366a29 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 23:20:01 -0400 Subject: [PATCH 3/8] fix(test): stop provider-service mock.module leak flaking getProviderService test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session-lifecycle-sdk-title.test.ts registered a top-level mock.module('../../../../src/lib/provider-service', ...) whose factory returned an INCOMPLETE stub (mockProviderService lacked getDefaultProvider/getProviderApiKey). Bun's mock.module is process-global, so this mock leaked into provider-service.test.ts: when it won the module cache, getProviderService() returned the stub and the 'getProviderService > should return ProviderService instance' test failed with getDefaultProvider undefined — but only in the full 1-core shard on Linux CI (passes in isolation and on macOS), so it presented as an intermittent red-CI flake on every dev PR since 2026-07-26. The mock was fully redundant: the test already injects its stub via the SessionLifecycleConfig.titleGenerationProviderServiceForTesting DI hook (mockTitleProviderService). Removed the leaking mock.module and the now-unused mockProviderService object. The file's own design note (top-of-file + line ~85) already prescribes exactly this — only external packages should be mock.module'd. --- .../session-lifecycle-sdk-title.test.ts | 64 ++----------------- 1 file changed, 7 insertions(+), 57 deletions(-) diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts index b412b5e4c..5525e72e5 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts @@ -14,7 +14,7 @@ * other test files sharing the same bun test process. */ -import { describe, expect, it, beforeEach, afterEach, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; // Track query call options to verify what is passed to the SDK. // Only updated on calls that carry a `thinking` option (title generation), @@ -25,50 +25,6 @@ let lastTitleProcessEnv: Record | undefined; // Mutable state controlling which messages the SDK query mock yields for // title generation. Set in beforeEach so each test starts from a known state. let mockSdkMessages: unknown[] = []; -const mockProviderService = { - // Complete the API surface: this mock is installed at the top level via - // mock.module on provider-service, which (under CI's coverage-instrumented - // module resolution) can leak into sibling files that import the real - // module — notably provider-service.test.ts, which asserts - // getProviderService() exposes getDefaultProvider/getProviderApiKey/restoreEnvVars. - // Omitting them makes that test see `undefined` and fail. The sibling - // session-lifecycle.test.ts mock is complete for the same reason. - getDefaultProvider: mock(async () => 'anthropic'), - getProviderApiKey: mock((_provider: string) => process.env.ANTHROPIC_API_KEY || undefined), - isProviderAvailable: mock(async () => true), - getTitleGenerationModels: mock(async (provider: string, modelId: string) => ({ - sdkModelId: provider === 'glm' ? 'default' : modelId, - providerModelId: provider === 'glm' ? 'glm-5-turbo' : modelId, - })), - applyEnvVarsToProcessForProvider: mock(async (provider: string, providerModelId: string) => { - const original = { - ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, - ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL, - ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL, - }; - if (provider === 'glm') { - process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = providerModelId; - process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = providerModelId; - process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = providerModelId; - } - return original; - }), - getEnvVarsForModel: mock(async (modelId: string, provider: string) => - provider === 'glm' - ? { - ANTHROPIC_DEFAULT_HAIKU_MODEL: modelId, - ANTHROPIC_DEFAULT_SONNET_MODEL: modelId, - ANTHROPIC_DEFAULT_OPUS_MODEL: modelId, - } - : {} - ), - restoreEnvVars: mock((original: Record) => { - for (const [key, value] of Object.entries(original)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }), -}; async function* makeAsyncGen(messages: unknown[]) { for (const msg of messages) { @@ -142,12 +98,6 @@ mock.module('@anthropic-ai/claude-agent-sdk', () => ({ }, })); -mock.module('../../../../src/lib/provider-service', () => ({ - getProviderService: () => mockProviderService, - resetProviderServiceInstance: mock(() => {}), - mergeProviderEnvVars: (env: Record) => ({ ...process.env, ...env }), -})); - mock.module('@hyperneo/shared/sdk/type-guards', () => ({ isSDKAssistantMessage: (msg: { type: string }) => msg.type === 'assistant', isSDKUserMessage: (msg: { type: string; isReplay?: boolean }) => @@ -181,17 +131,17 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type !== 'stream_event' && msg.type !== 'api_retry', })); +import type { MessageHub } from '@hyperneo/shared'; +import { DEFAULT_GLOBAL_SETTINGS } from '@hyperneo/shared'; +import type { InternalEventBus } from '../../../../src/lib/internal-event-bus'; +import type { AgentSessionFactory, SessionCache } from '../../../../src/lib/session/session-cache'; import type { SessionLifecycle, SessionLifecycleConfig, } from '../../../../src/lib/session/session-lifecycle'; -import type { Database } from '../../../../src/storage/database'; -import type { InternalEventBus } from '../../../../src/lib/internal-event-bus'; -import type { WorktreeManager } from '../../../../src/lib/worktree-manager'; -import type { SessionCache, AgentSessionFactory } from '../../../../src/lib/session/session-cache'; import type { ToolsConfigManager } from '../../../../src/lib/session/tools-config'; -import type { MessageHub } from '@hyperneo/shared'; -import { DEFAULT_GLOBAL_SETTINGS } from '@hyperneo/shared'; +import type { WorktreeManager } from '../../../../src/lib/worktree-manager'; +import type { Database } from '../../../../src/storage/database'; type TitleSdkInvoker = { generateTitleWithSdk(provider: string, modelId: string, messageText: string): Promise; From 164f8581e14c4d5ffad5b374cfd07300cfe633fd Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 23:28:24 -0400 Subject: [PATCH 4/8] chore: re-trigger CI to verify provider-service flake fix The pull_request event was skipped for 00fe25727 after rapid force-pushes. This empty commit re-triggers CI to confirm the getProviderService flake is gone. From 0bad0a8e304612de6b3dbcf54cc4ef6eaf488842 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Fri, 31 Jul 2026 22:16:59 -0400 Subject: [PATCH 5/8] =?UTF-8?q?test:=20address=20round-3=20review=20?= =?UTF-8?q?=E2=80=94=20catalog=20coverage=20+=20type-guards=20mock=20parit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native-compaction-behavior.test.ts: - N2: assert EVERY canonical MODEL_CONTEXT_WINDOWS entry with a key-set equality guard (fails loudly on catalog drift; updated for GPT-5.6 #2278 — codex-mini/codex-latest now resolve to 1,050,000) - N2: reframe the Kimi bridging test — daemon passes the real window; the SDK clamps K2.7 to its 200k fallback and compacts at 167k; assert the safety invariant (SDK-effective threshold strictly below the real window) - N3: provider matrix now matches the factory-registered built-ins exactly (drops gemini — not registered; adds ollama-cloud and acp) - N4: model matrix derived from the canonical catalogs (KimiProvider.MODELS + MODEL_CONTEXT_WINDOWS) so kimi-k3[1m] and kimi-k2.7-code-highspeed are exercised, with a canonical-ID guard session test files (sandbox-default, session-lifecycle, session-lifecycle-sdk-title): - add isSDKModelRefusalFallbackMessage to the leaked type-guards mocks (P1: sdk-message-handler now imports it post-#2282; the leaked mock lacking it breaks any shard-mate importing the handler on Linux CI) --- .../agent/native-compaction-behavior.test.ts | 143 ++++++++++++------ .../1-core/session/sandbox-default.test.ts | 14 +- .../session-lifecycle-sdk-title.test.ts | 2 + .../1-core/session/session-lifecycle.test.ts | 21 +-- 4 files changed, 119 insertions(+), 61 deletions(-) diff --git a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts index 640a6d015..087be508d 100644 --- a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts +++ b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts @@ -193,13 +193,31 @@ describe('N2: thresholds — active SDK window (kimi/codex) + dormant fallback r }); it('the per-model Codex windows that feed CLAUDE_CODE_AUTO_COMPACT_WINDOW are the expected values', () => { - // The Codex bridge sets `CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(entry.contextWindow)`; - // these are the threshold-source values (env-var wiring is covered by - // anthropic-to-codex-bridge-provider.test.ts). - expect(getModelContextWindow('gpt-5.5')).toBe(272_000); - expect(getModelContextWindow('gpt-5.3-codex')).toBe(272_000); - expect(getModelContextWindow('gpt-5.4-mini')).toBe(128_000); - expect(getModelContextWindow('codex-mini')).toBe(128_000); // alias resolves + // The Codex bridge writes each canonical entry directly to + // `CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(entry.contextWindow)`, so a wrong + // window changes production compaction capacity. Assert the expected value + // for EVERY canonical MODEL_CONTEXT_WINDOWS entry — the key-set equality + // check fails loudly when a model is added/removed without updating this + // expectation (env-var wiring itself is covered by + // anthropic-to-codex-bridge-provider.test.ts). Catalog as of #2278 (GPT-5.6). + const EXPECTED_WINDOWS: Record = { + 'gpt-5.6-sol': 1_050_000, + 'gpt-5.6-terra': 1_050_000, + 'gpt-5.6-luna': 1_050_000, + 'gpt-5.5': 272_000, + 'gpt-5.3-codex': 272_000, + 'gpt-5.4': 272_000, + 'gpt-5.4-mini': 128_000, + }; + expect(Object.keys(MODEL_CONTEXT_WINDOWS).sort()).toEqual(Object.keys(EXPECTED_WINDOWS).sort()); + for (const [id, window] of Object.entries(EXPECTED_WINDOWS)) { + expect(getModelContextWindow(id), id).toBe(window); + } + // Alias resolution: codex-mini moved from gpt-5.4-mini (128k) to gpt-5.6-luna (1.05M) + // in #2278; codex-latest now resolves to gpt-5.6-sol. + expect(getModelContextWindow('codex-mini')).toBe(1_050_000); + expect(getModelContextWindow('codex-latest')).toBe(1_050_000); + expect(getModelContextWindow('codex-5.4-mini')).toBe(128_000); }); it('Kimi buildSdkConfig arms CLAUDE_CODE_AUTO_COMPACT_WINDOW per model (K2.7=262144, K3=1M)', () => { @@ -213,24 +231,47 @@ describe('N2: thresholds — active SDK window (kimi/codex) + dormant fallback r ); }); - it('the ACTIVE kimi/codex threshold is the SDK window (buildProviderSettings), not the fallback reserve', () => { + it("the daemon passes Kimi's real window; the SDK clamps it to a threshold safely below it", () => { // Because PROVIDER_NO_SDK_AUTO_COMPACT is empty (N3), SDKMessageHandler // never reaches reserveBasedThreshold for kimi/codex — the SDK native - // auto-compact governs. The active window is the one armed by - // buildProviderSettings; the dormant reserve would be computed FROM that - // same window only if the fallback were re-armed. Pin the relationship so a - // regression to the active window (not the reserve) is what fails. + // auto-compact governs. This test pins the daemon side of that contract + // with precise semantics (the SDK's internal clamping is external behavior, + // verified empirically against SDK 0.3.x and documented at + // query-options-builder.ts:265-290 — a unit test cannot drive the real SDK): + // + // 1. The daemon passes the REAL window via settings + env. For Kimi K2.7 + // (262,144) the SDK CLAMPS that to its 200k fallback and compacts at + // 200,000 − 33,000 = 167,000. The safety invariant the whole design + // rests on: the clamped SDK threshold is strictly BELOW Kimi's real + // window, so Kimi always accepts. Pin that relationship explicitly. + // 2. Codex stays native (no settings override) — its window is fed by + // CLAUDE_CODE_AUTO_COMPACT_WINDOW + /v1/models metadata. const k2Window = buildProviderSettings('kimi', 262_144, 'kimi-k2.7-code')?.autoCompactWindow; const k3Window = buildProviderSettings('kimi', 1_048_576, 'kimi-k3')?.autoCompactWindow; expect(k2Window).toBe(262_144); expect(k3Window).toBe(1_048_576); - // And the dormant reserve that would apply to those same active windows: + + // Documented SDK-clamping constants (query-options-builder.ts:265-290): + // unknown model IDs resolve to the SDK's 200k fallback window and its + // auto-compact fires 33k below that. The safety invariant for Kimi K2.7: + // the SDK-effective threshold (167k) stays strictly below the real 262k + // window — i.e. native auto-compact can never compact past what Kimi + // accepts. If the daemon's K2.7 window ever dropped BELOW this threshold + // (e.g. a catalog regression to a sub-200k value), Kimi would overflow + // before the SDK compacts — this assertion catches it. + const SDK_FALLBACK_WINDOW = 200_000; + const SDK_RESERVE = 33_000; + const sdkEffectiveK27Threshold = SDK_FALLBACK_WINDOW - SDK_RESERVE; // 167000 + expect(sdkEffectiveK27Threshold).toBeLessThan(k2Window!); + expect(k2Window!).toBeGreaterThan(SDK_FALLBACK_WINDOW); + + // Codex: no settings override (native); reserveBasedThreshold only + // documents what the dormant fallback would use. + expect(buildProviderSettings('anthropic-codex', 272_000, 'gpt-5.5')).toBeUndefined(); + // And the dormant reserve that would apply to the same windows if the + // fallback were ever re-armed (still NOT the active threshold): expect(reserveBasedThreshold(262_144, 'kimi')).toBe(262_144 - 45_000); expect(reserveBasedThreshold(1_048_576, 'kimi')).toBe(1_048_576 - 45_000); - // Codex stays native (no armed window) — its active threshold is the SDK's - // own, fed by CLAUDE_CODE_AUTO_COMPACT_WINDOW above; reserveBasedThreshold - // only documents what the dormant fallback would use. - expect(buildProviderSettings('anthropic-codex', 272_000, 'gpt-5.5')).toBeUndefined(); }); }); @@ -252,16 +293,23 @@ describe('N3: NeoKai (HyperNeo) fallback applied only where intended', () => { expect(PROVIDER_NO_SDK_AUTO_COMPACT.size).toBe(0); }); + // Exactly the built-in provider IDs registered by providers/factory.ts + // (initializeProviders / registerBuiltInProvider): anthropic, glm, kimi, + // minimax, openrouter, ollama (local), ollama-cloud, anthropic-codex, acp, + // anthropic-copilot. Keep this in lockstep with factory.ts so a + // provider-specific branch added to shouldUseHyperNeoCompactFallback for any + // registered built-in fails here. it.each([ - ['kimi', 'kimi'], - ['anthropic-codex', 'anthropic-codex'], ['anthropic', 'anthropic'], ['anthropic-copilot', 'anthropic-copilot'], + ['anthropic-codex', 'anthropic-codex'], ['glm', 'glm'], + ['kimi', 'kimi'], + ['minimax', 'minimax'], ['openrouter', 'openrouter'], ['ollama', 'ollama'], - ['minimax', 'minimax'], - ['gemini', 'gemini'], + ['ollama-cloud', 'ollama-cloud'], + ['acp', 'acp'], ])('shouldUseHyperNeoCompactFallback(%s) is false', (_label, providerId) => { expect(shouldUseHyperNeoCompactFallback(providerId)).toBe(false); }); @@ -554,30 +602,35 @@ describe('N4: literal /compact never enters the transcript or provider request', expect(harness.enqueueSpy).not.toHaveBeenCalledWith('/compact', true); }); - it('holds across the Kimi/Codex model matrix (every model near capacity, no /compact)', async () => { - const matrix = [ - { provider: 'kimi', model: 'kimi-k2.7-code', contextWindow: 262_144, sdkMaxTokens: 200_000 }, - { provider: 'kimi', model: 'kimi-for-coding', contextWindow: 262_144, sdkMaxTokens: 200_000 }, - { provider: 'kimi', model: 'kimi-k3', contextWindow: 1_048_576, sdkMaxTokens: 1_048_576 }, - { - provider: 'anthropic-codex', - model: 'gpt-5.5', - contextWindow: 272_000, - sdkMaxTokens: 272_000, - }, - { - provider: 'anthropic-codex', - model: 'gpt-5.3-codex', - contextWindow: 272_000, - sdkMaxTokens: 272_000, - }, - { - provider: 'anthropic-codex', - model: 'gpt-5.4-mini', - contextWindow: 128_000, - sdkMaxTokens: 128_000, - }, - ]; + it('holds across the Kimi/Codex model matrix (every canonical model near capacity, no /compact)', async () => { + // Derive the matrix from the canonical production catalogs so every real + // route is exercised — a regression limited to one canonical ID (e.g. the + // kimi-k3[1m] suffix route or kimi-k2.7-code-highspeed) cannot slip through, + // and a model added to either catalog joins automatically. sdkMaxTokens + // mirrors the SDK-effective window each route reports: kimi-k3[1m]'s [1m] + // suffix makes the SDK believe 1M; every other Kimi ID falls back to the + // SDK's 200k; Codex reports its real metadata window. + const kimiCases = KimiProvider.MODELS.map((m) => ({ + provider: 'kimi', + model: m.id, + contextWindow: m.contextWindow, + sdkMaxTokens: /\[1m\]$/i.test(m.id) ? m.contextWindow : 200_000, + })); + const codexCases = ( + Object.keys(MODEL_CONTEXT_WINDOWS) as Array + ).map((id) => ({ + provider: 'anthropic-codex', + model: id, + contextWindow: MODEL_CONTEXT_WINDOWS[id], + sdkMaxTokens: MODEL_CONTEXT_WINDOWS[id], + })); + const matrix = [...kimiCases, ...codexCases]; + + // Guard: the derivation really covers the current canonical production IDs + // (forces a deliberate update here when either catalog changes). + expect(kimiCases.map((c) => c.model).sort()).toEqual( + ['kimi-for-coding', 'kimi-k2.7-code-highspeed', 'kimi-k3[1m]'].sort() + ); for (const c of matrix) { setModelsCache( diff --git a/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts b/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts index 60b583aa0..2ccce099c 100644 --- a/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts +++ b/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts @@ -5,7 +5,7 @@ * and properly configured for new sessions. */ -import { describe, expect, it, beforeEach, mock } from 'bun:test'; +import { beforeEach, describe, expect, it, mock } from 'bun:test'; // Mock SDK type-guards at the top level mock.module('@hyperneo/shared/sdk/type-guards', () => ({ @@ -26,6 +26,8 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'compact_boundary', isSDKStatusMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'status', + isSDKModelRefusalFallbackMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'model_refusal_fallback', isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => @@ -41,16 +43,16 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type !== 'stream_event' && msg.type !== 'api_retry', })); +import type { MessageHub } from '@hyperneo/shared'; +import type { InternalEventBus } from '../../../../src/lib/internal-event-bus'; +import type { AgentSessionFactory, SessionCache } from '../../../../src/lib/session/session-cache'; import { SessionLifecycle, type SessionLifecycleConfig, } from '../../../../src/lib/session/session-lifecycle'; -import type { Database } from '../../../../src/storage/database'; -import type { InternalEventBus } from '../../../../src/lib/internal-event-bus'; -import type { WorktreeManager } from '../../../../src/lib/worktree-manager'; -import type { SessionCache, AgentSessionFactory } from '../../../../src/lib/session/session-cache'; import type { ToolsConfigManager } from '../../../../src/lib/session/tools-config'; -import type { MessageHub } from '@hyperneo/shared'; +import type { WorktreeManager } from '../../../../src/lib/worktree-manager'; +import type { Database } from '../../../../src/storage/database'; describe('Sandbox Default Configuration', () => { let lifecycle: SessionLifecycle; diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts index 5525e72e5..e8d8f3c09 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts @@ -116,6 +116,8 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'compact_boundary', isSDKStatusMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'status', + isSDKModelRefusalFallbackMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'model_refusal_fallback', isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts index 8f946007d..e21e61ee4 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts @@ -5,7 +5,7 @@ * updates, deletion, and title generation. */ -import { describe, expect, it, beforeEach, afterEach, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; // Mock SDK type-guards at the top level mock.module('@hyperneo/shared/sdk/type-guards', () => ({ @@ -26,6 +26,8 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'compact_boundary', isSDKStatusMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'status', + isSDKModelRefusalFallbackMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'model_refusal_fallback', isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => @@ -163,20 +165,19 @@ const mockKimiModels: ModelInfo[] = [ }, ]; -import { setModelsCache, clearModelsCache } from '../../../../src/lib/model-service'; -import type { ModelInfo } from '@hyperneo/shared'; +import type { MessageHub, ModelInfo, Session } from '@hyperneo/shared'; +import { DEFAULT_GLOBAL_SETTINGS } from '@hyperneo/shared'; +import type { InternalEventBus } from '../../../../src/lib/internal-event-bus'; +import { clearModelsCache, setModelsCache } from '../../../../src/lib/model-service'; +import type { AgentSessionFactory, SessionCache } from '../../../../src/lib/session/session-cache'; import { + generateBranchName, SessionLifecycle, type SessionLifecycleConfig, - generateBranchName, } from '../../../../src/lib/session/session-lifecycle'; -import type { Database } from '../../../../src/storage/database'; -import type { InternalEventBus } from '../../../../src/lib/internal-event-bus'; -import type { WorktreeManager } from '../../../../src/lib/worktree-manager'; -import type { SessionCache, AgentSessionFactory } from '../../../../src/lib/session/session-cache'; import type { ToolsConfigManager } from '../../../../src/lib/session/tools-config'; -import type { MessageHub, Session } from '@hyperneo/shared'; -import { DEFAULT_GLOBAL_SETTINGS } from '@hyperneo/shared'; +import type { WorktreeManager } from '../../../../src/lib/worktree-manager'; +import type { Database } from '../../../../src/storage/database'; describe('SessionLifecycle', () => { let lifecycle: SessionLifecycle; From 4597587af20d816c38fe94225169ebfbc5ced738 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Fri, 31 Jul 2026 22:30:06 -0400 Subject: [PATCH 6/8] test: cover k3-256k catalog addition + full type-guards mock parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N4 canonical-ID guard updated for #2286 (k3-256k, 256K window) — the guard caught this drift on CI exactly as designed - N1: add k3-256k case pinning the per-variant window resolution (256K, not the 1M flagship) - N2: buildSdkConfig assertion for k3-256k (CLAUDE_CODE_AUTO_COMPACT_WINDOW=262144) - session mocks: add isSDKSessionStateChangedMessage, isSDKCommandsChangedMessage, isSDKThinkingTokensMessage alongside isSDKModelRefusalFallbackMessage so the leaked type-guards mock surface fully covers sdk-message-handler's imports (post-#2282/#2287) under any shard load order --- .../agent/native-compaction-behavior.test.ts | 18 ++++++++++++++++-- .../1-core/session/sandbox-default.test.ts | 6 ++++++ .../session-lifecycle-sdk-title.test.ts | 6 ++++++ .../1-core/session/session-lifecycle.test.ts | 6 ++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts index 087be508d..6105dd08c 100644 --- a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts +++ b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts @@ -91,6 +91,15 @@ describe('N1: native SDK auto-compaction is used (never disabled for kimi/codex) model: 'kimi-k3', expected: { autoCompactEnabled: true, autoCompactWindow: 1_048_576 }, }, + { + // k3-256k (#2286) is a K3 variant whose window is resolved per-variant — + // 256K, NOT the 1M flagship window. + label: 'kimi K3 256K variant — armed with its 256K window (not 1M)', + provider: 'kimi', + contextWindow: 262_144, + model: 'k3-256k', + expected: { autoCompactEnabled: true, autoCompactWindow: 262_144 }, + }, { label: 'codex gpt-5.5 (272000) — native, no override', provider: 'anthropic-codex', @@ -229,6 +238,10 @@ describe('N2: thresholds — active SDK window (kimi/codex) + dormant fallback r expect(provider.buildSdkConfig('kimi-k3').envVars.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe( '1048576' ); + // k3-256k (#2286): a K3 variant capped at 256K — must NOT inherit the 1M window. + expect(provider.buildSdkConfig('k3-256k').envVars.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe( + '262144' + ); }); it("the daemon passes Kimi's real window; the SDK clamps it to a threshold safely below it", () => { @@ -627,9 +640,10 @@ describe('N4: literal /compact never enters the transcript or provider request', const matrix = [...kimiCases, ...codexCases]; // Guard: the derivation really covers the current canonical production IDs - // (forces a deliberate update here when either catalog changes). + // (forces a deliberate update here when either catalog changes). Catalog as + // of #2286 (k3-256k added). expect(kimiCases.map((c) => c.model).sort()).toEqual( - ['kimi-for-coding', 'kimi-k2.7-code-highspeed', 'kimi-k3[1m]'].sort() + ['k3-256k', 'kimi-for-coding', 'kimi-k2.7-code-highspeed', 'kimi-k3[1m]'].sort() ); for (const c of matrix) { diff --git a/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts b/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts index 2ccce099c..ad5c80977 100644 --- a/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts +++ b/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts @@ -28,6 +28,12 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'status', isSDKModelRefusalFallbackMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'model_refusal_fallback', + isSDKSessionStateChangedMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'session_state_changed', + isSDKCommandsChangedMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'commands_changed', + isSDKThinkingTokensMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'thinking_tokens', isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts index e8d8f3c09..b604835a7 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts @@ -118,6 +118,12 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'status', isSDKModelRefusalFallbackMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'model_refusal_fallback', + isSDKSessionStateChangedMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'session_state_changed', + isSDKCommandsChangedMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'commands_changed', + isSDKThinkingTokensMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'thinking_tokens', isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts index e21e61ee4..c7f64b73c 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts @@ -28,6 +28,12 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'status', isSDKModelRefusalFallbackMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'model_refusal_fallback', + isSDKSessionStateChangedMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'session_state_changed', + isSDKCommandsChangedMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'commands_changed', + isSDKThinkingTokensMessage: (msg: { type: string; subtype?: string }) => + msg.type === 'system' && msg.subtype === 'thinking_tokens', isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => From 2f759b258cecfb2ceb43b7c1ffe114353b6a2b26 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Fri, 31 Jul 2026 22:41:13 -0400 Subject: [PATCH 7/8] fix(test): complete leaked type-guards mock surface (flattenSDKSlashCommands) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session files' process-wide type-guards mock still omitted flattenSDKSlashCommands, which sdk-message-handler imports — so when the mock leaks ahead of a handler-importing suite on Linux CI, the load fails with 'Export named flattenSDKSlashCommands not found'. Added a faithful mirror of the real implementation to all three session mocks and verified exhaustive parity: all 15 of the handler's type-guards imports are now present in every mock block. --- .../unit/1-core/session/sandbox-default.test.ts | 17 +++++++++++++++++ .../session/session-lifecycle-sdk-title.test.ts | 17 +++++++++++++++++ .../1-core/session/session-lifecycle.test.ts | 17 +++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts b/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts index ad5c80977..d8535076e 100644 --- a/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts +++ b/packages/daemon/tests/unit/1-core/session/sandbox-default.test.ts @@ -34,6 +34,23 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'commands_changed', isSDKThinkingTokensMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'thinking_tokens', + // Mirrors packages/shared/src/sdk/type-guards.ts flattenSDKSlashCommands so a + // leaked mock keeps sdk-message-handler's commands_changed path working. + flattenSDKSlashCommands: (commands: Array<{ name?: string; aliases?: string[] }>) => { + const names = new Set(); + const normalize = (n: string) => (n.startsWith('/') ? n.slice(1) : n); + for (const command of commands) { + if (typeof command.name === 'string' && command.name.length > 0) { + names.add(normalize(command.name)); + } + for (const alias of command.aliases ?? []) { + if (typeof alias === 'string' && alias.length > 0) { + names.add(normalize(alias)); + } + } + } + return [...names].filter((name) => name.length > 0); + }, isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts index b604835a7..9f152dc1e 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle-sdk-title.test.ts @@ -124,6 +124,23 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'commands_changed', isSDKThinkingTokensMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'thinking_tokens', + // Mirrors packages/shared/src/sdk/type-guards.ts flattenSDKSlashCommands so a + // leaked mock keeps sdk-message-handler's commands_changed path working. + flattenSDKSlashCommands: (commands: Array<{ name?: string; aliases?: string[] }>) => { + const names = new Set(); + const normalize = (n: string) => (n.startsWith('/') ? n.slice(1) : n); + for (const command of commands) { + if (typeof command.name === 'string' && command.name.length > 0) { + names.add(normalize(command.name)); + } + for (const alias of command.aliases ?? []) { + if (typeof alias === 'string' && alias.length > 0) { + names.add(normalize(alias)); + } + } + } + return [...names].filter((name) => name.length > 0); + }, isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => diff --git a/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts b/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts index c7f64b73c..028462472 100644 --- a/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts +++ b/packages/daemon/tests/unit/1-core/session/session-lifecycle.test.ts @@ -34,6 +34,23 @@ mock.module('@hyperneo/shared/sdk/type-guards', () => ({ msg.type === 'system' && msg.subtype === 'commands_changed', isSDKThinkingTokensMessage: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'thinking_tokens', + // Mirrors packages/shared/src/sdk/type-guards.ts flattenSDKSlashCommands so a + // leaked mock keeps sdk-message-handler's commands_changed path working. + flattenSDKSlashCommands: (commands: Array<{ name?: string; aliases?: string[] }>) => { + const names = new Set(); + const normalize = (n: string) => (n.startsWith('/') ? n.slice(1) : n); + for (const command of commands) { + if (typeof command.name === 'string' && command.name.length > 0) { + names.add(normalize(command.name)); + } + for (const alias of command.aliases ?? []) { + if (typeof alias === 'string' && alias.length > 0) { + names.add(normalize(alias)); + } + } + } + return [...names].filter((name) => name.length > 0); + }, isSDKHookResponse: (msg: { type: string; subtype?: string }) => msg.type === 'system' && msg.subtype === 'hook_response', isSDKAPIRetryMessage: (msg: { type: string; subtype?: string }) => From 83e3ccbbde6b39afda500faeedfa315dbae87feb Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Fri, 31 Jul 2026 22:58:52 -0400 Subject: [PATCH 8/8] test: pin query-runner retry-replay exclusion for internal /compact (N4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the N4 boundary comment: the MessageQueue mechanism test only proves HyperNeo-side persistence exclusion. This adds a QueryRunner createMessageGeneratorWrapper test proving an internal /compact is delivered to the SDK stream but (a) invisible to processing state and (b) never captured in _lastConsumedUserMessage — the retry-replay buffer a transient-error retry uses to re-enqueue the last consumed message. Without this, a regression recording internal messages there would re-inject /compact into the provider request as visible prompt text on retry, and the suite would stay green. Also sharpens the mechanism-test comment to scope its claim (SDK-side slash-command interception is external behavior, like the N2 window clamp). --- .../agent/native-compaction-behavior.test.ts | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts index 6105dd08c..e95241edf 100644 --- a/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts +++ b/packages/daemon/tests/unit/1-core/agent/native-compaction-behavior.test.ts @@ -42,6 +42,7 @@ import { PROVIDER_NO_SDK_AUTO_COMPACT, shouldUseHyperNeoCompactFallback, } from '../../../../src/lib/agent/query-options-builder'; +import { QueryRunner, type QueryRunnerContext } from '../../../../src/lib/agent/query-runner'; import { SDKMessageHandler, type SDKMessageHandlerContext, @@ -683,11 +684,14 @@ describe('N4: literal /compact never enters the transcript or provider request', it('MessageQueue preserves the internal flag so an internal /compact stays out of the transcript', async () => { // MECHANISM only: this drives MessageQueue directly (with internal=true - // supplied by the caller) to prove an internal `/compact` is yielded to the - // SDK as a user message (so the SDK runs its built-in /compact slash command - // — a structured control, not prompt text) but never reaches the yield-time - // DB/UI broadcast hook that persists conversation turns. The PRODUCTION call - // site that supplies internal=true is pinned by the next test. + // supplied by the caller) to prove the internal flag survives queueing and + // suppresses HyperNeo's OWN transcript surfaces — the yield-time DB/UI + // broadcast hook that persists conversation turns. The literal text IS + // yielded to the SDK (the SDK's slash-command interception of it is external + // behavior, like the N2 window clamp — documented, not unit-testable here). + // The PRODUCTION call site that supplies internal=true is pinned by the + // fallback-fires test below; the query-runner retry boundary is pinned by + // the test after that. const queue = new MessageQueue(); const yieldedSpy = mock(() => {}); queue.onMessageYielded = yieldedSpy; @@ -715,6 +719,73 @@ describe('N4: literal /compact never enters the transcript or provider request', expect(yieldedSpy).not.toHaveBeenCalled(); }); + it('internal /compact is excluded from the query-runner retry-replay buffer (daemon query boundary)', async () => { + // The remaining daemon-side channel by which `/compact` could reach the + // provider request: QueryRunner.createMessageGeneratorWrapper records the + // last consumed NON-internal user message (`_lastConsumedUserMessage`) so a + // transient-error retry can re-enqueue it. An internal `/compact` must never + // be captured there — otherwise a retry would re-inject it into the SDK + // stream as a visible user turn (plain prompt text to the provider). This + // exercises the daemon→SDK query boundary directly: the internal message is + // still DELIVERED to the SDK stream, but is invisible to processing state + // and to the replay buffer. + const queue = new MessageQueue(); + queue.start(); + + const setProcessingSpy = mock(async () => {}); + const session: Session = { + id: 'query-boundary-session', + title: 'Query Boundary Session', + workspacePath: '/test/path', + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + status: 'active', + config: { model: 'default', maxTokens: 8192, temperature: 1.0 }, + metadata: { + messageCount: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0, + toolCallCount: 0, + }, + }; + + const runner = new QueryRunner({ + session, + messageQueue: queue, + stateManager: { setProcessing: setProcessingSpy } as unknown as ProcessingStateManager, + } as unknown as QueryRunnerContext); + + const yielded: Array = []; + const consumer = (async () => { + for await (const message of runner.createMessageGeneratorWrapper()) { + yielded.push(message as SDKUserMessage & { internal?: boolean }); + if (yielded.length === 2) { + // Stop so the generator ends after delivering message 2 (its onSent + // fires when the wrapper advances, clearing its queue timer). + queue.stop(); + } + } + })(); + + await queue.enqueue('/compact', true); + await queue.enqueue('fix the bug', false); + await consumer; + + // The internal /compact IS delivered to the SDK stream (as a command), but: + expect(yielded).toHaveLength(2); + expect(yielded[0].internal).toBe(true); + expect(yielded[1].internal).toBe(false); + // …only the non-internal message is tracked for processing state… + expect(setProcessingSpy).toHaveBeenCalledTimes(1); + // …and the retry-replay buffer holds the user message, NOT /compact — so a + // transient-error retry can never re-inject /compact as visible prompt text. + const replay = (runner as unknown as { _lastConsumedUserMessage: { content: unknown } | null }) + ._lastConsumedUserMessage; + expect(replay?.content).toEqual([{ type: 'text', text: 'fix the bug' }]); + }); + it('when the dormant fallback fires, the handler enqueues /compact as internal (production call site)', async () => { // The kimi/codex cases prove the gate stays CLOSED (no enqueue). To pin the // production CALL SITE — that SDKMessageHandler enqueues `/compact` with