diff --git a/.github/FORK_OWNED_FILES b/.github/FORK_OWNED_FILES index c23be2e056..2b3f84338d 100644 --- a/.github/FORK_OWNED_FILES +++ b/.github/FORK_OWNED_FILES @@ -58,4 +58,9 @@ scripts/check-no-comments.mjs :: check # --- Fork-owned deps (root package.json has http-proxy-agent@^9 which # upstream removed; this is what the pnpm-lockfile guard is for) --- -package.json :: http-proxy-agent \ No newline at end of file +package.json :: http-proxy-agent + +# --- squeeze-model fork modules (refactor: isolated from upstream hotspots) --- +packages/agent-core-v2/src/agent/fullCompaction/squeezeForkOps.ts :: SqueezeModelDecided +packages/agent-core-v2/src/agent/fullCompaction/squeezeCascade.ts :: resolveSqueezeModelAliasWithCascade +packages/agent-core-v2/src/app/telemetry/forkEvents.ts :: forkTelemetryEventDefinitions \ No newline at end of file diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 9175cd26c4..366f32d71d 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -65,6 +65,7 @@ auto_install = false disablePasteBurst: false, cacheExpiryHint: true, disableFeedbackSurvey: false, + editorCommand: 'code --wait', favoriteModels: [], notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index 0cce49b393..8bdb35d662 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -85,6 +85,7 @@ import type { import type { PermissionSetMode } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionModeOps'; import type { PermissionRecordApprovalResult } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRulesOps'; import type { RuntimeSetBinding } from '@moonshot-ai/agent-core-v2/agent/runtimeBinding/runtimeBindingOps'; +import type { SqueezeModelDecided } from '@moonshot-ai/agent-core-v2/agent/fullCompaction/squeezeForkOps'; import type { SwarmModeEnter, SwarmModeExit } from '@moonshot-ai/agent-core-v2/features/swarm/swarmOps'; import type { TowerModeEnter, TowerModeExit } from '@moonshot-ai/agent-core-v2/features/tower/towerOps'; import type { ToolsUpdateStore } from '@moonshot-ai/agent-core-v2/features/todo/todoOps'; @@ -186,6 +187,7 @@ export type AgentRecord = | WireRecordOf<'prompt.completed', PromptCompleted> | WireRecordOf<'prompt.steered', PromptSteered> | WireRecordOf<'runtime.set_binding', RuntimeSetBinding> + | WireRecordOf<'squeeze_model.decided', SqueezeModelDecided> | WireRecordOf<'swarm_mode.enter', SwarmModeEnter> | WireRecordOf<'swarm_mode.exit', SwarmModeExit> | WireRecordOf<'task.started', TaskStarted> diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 8ba926295d..fe28b974a8 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -560,6 +560,7 @@ export function projectContext( case 'plan.revision': case 'plugin.session_start': case 'runtime.set_binding': + case 'squeeze_model.decided': case 'staleGuard.recorded': case 'staleGuard.cleared': case 'interruptionReminder.recorded': diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 8ea9a0c4b3..2c9ca9be57 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -1421,6 +1421,19 @@ export const WIRE_RENDERERS: RendererMap = { }), }, + 'squeeze_model.decided': { + tone: 'compaction', + label: 'squeeze', + headline: (r) => ({ + main: ( + + {r.model} + {r.modelDisplay ? {r.modelDisplay} : null} + + ), + }), + }, + 'staleGuard.recorded': { tone: 'meta', label: 'stale', diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 8846f7bc5f..758ccd0c0f 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 86 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -59,6 +59,7 @@ // contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // externalHooks.stopHookContinuationUsed src/features/externalHooks/agent/agentExternalHooksService.ts +// fallbackModel.active src/session/fallback/state.ts // fileHistory src/features/fileHistory/fileHistoryOps.ts // fullCompaction src/agent/fullCompaction/compactionOps.ts // fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts @@ -102,8 +103,10 @@ // runtime.binding src/agent/runtimeBinding/runtimeBindingService.ts // runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts +// squeezeModel src/agent/fullCompaction/squeezeForkOps.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts +// substituteModel.active src/session/substitute/state.ts // swarm src/features/swarm/swarmOps.ts // task src/agent/task/taskOps.ts // task.activeTaskReminderPending src/agent/task/taskService.ts @@ -1188,6 +1191,12 @@ export interface AgentStateSnapshot { 'fullCompaction.consecutiveOverflowCompactions': number; 'fullCompaction.lastCompactedTokenCount': number | null; 'fullCompaction.observedMaxContextTokensByModel': Map; + // src/agent/fullCompaction/squeezeForkOps.ts + // replayable · durable — folds: SqueezeModelDecided + 'squeezeModel': /* SqueezeModelState — packages/agent-core-v2/src/agent/fullCompaction/squeezeForkOps.ts */ { + readonly model: string; + readonly modelDisplay?: string; + }; // src/agent/interruptionReminder/interruptionReminderOps.ts // replayable · durable — folds: InterruptionReminderRecorded 'interruptionReminder': null; @@ -1221,6 +1230,7 @@ export interface AgentStateSnapshot { readonly thinkingLevel: /* ThinkingEffort — packages/agent-core-v2/src/kosong/contract/provider.ts */ 'off' | 'on' | (string & {}); readonly reservedContextSize: number | undefined; readonly compactionTriggerRatio: number | undefined; + readonly compactionTokenBudget: number | undefined; }; readonly params: /* ModelRequestParams — packages/agent-core-v2/src/kosong/model/modelRequester.ts */ { readonly cacheKey?: string; @@ -1534,6 +1544,17 @@ export interface AgentStateSnapshot { 'tower.base': string | null; // replayable · durable — folds: TowerModeEnter, TowerModeExit 'tower.owner': string | undefined; + // src/session/fallback/state.ts + 'fallbackModel.active': /* ActiveFallbackModel — packages/agent-core-v2/src/session/fallback/state.ts */ { + readonly alias: string; + readonly tier: 'primary' | 'secondary'; + } | undefined; + // src/session/substitute/state.ts + 'substituteModel.active': /* ActiveSubstituteModel — packages/agent-core-v2/src/session/substitute/state.ts */ { + readonly alias: string; + readonly primaryAlias: string; + readonly until: number; + } | undefined; } export type AgentStateKey = keyof AgentStateSnapshot; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 2dae0c93ed..040bee64a8 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -24,7 +24,7 @@ // cross-reducers), blobs (the folding states whose blob codec offloads inline // media to blob storage), owner (the source file declaring the class). -// Index (60 record types) +// Index (61 record types) // config.update profile src/agent/profile/profileOps.ts // context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts // context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts @@ -62,6 +62,7 @@ // prompt.completed promptResolution src/agent/prompt/promptService.ts // prompt.steered promptResolution src/agent/prompt/promptService.ts // runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// squeeze_model.decided (none) src/agent/fullCompaction/squeezeForkOps.ts // swarm_mode.enter swarm src/features/swarm/swarmOps.ts // swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts // task.started task src/agent/task/taskOps.ts @@ -268,6 +269,8 @@ interface FullCompactionBeginPayload { instruction?: string; /** CompactionSource */ source: 'manual' | 'auto'; + model?: string; + modelDisplay?: string; } /** @@ -596,6 +599,17 @@ interface RuntimeSetBindingPayload { runtimeId: string; } +/** + * states: (none) + * owner: src/agent/fullCompaction/squeezeForkOps.ts + */ +interface SqueezeModelDecidedPayload { + _name: 'squeeze_model.decided'; + agentId: string; + model: string; + modelDisplay?: string; +} + /** * states: swarm * owner: src/features/swarm/swarmOps.ts @@ -953,6 +967,7 @@ interface WirePayloadMap { "prompt.completed": PromptCompletedPayload; "prompt.steered": PromptSteeredPayload; "runtime.set_binding": RuntimeSetBindingPayload; + "squeeze_model.decided": SqueezeModelDecidedPayload; "swarm_mode.enter": SwarmModeEnterPayload; "swarm_mode.exit": SwarmModeExitPayload; "task.started": TaskStartedPayload; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 9d7b4e9861..8e5eb62a14 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -80,6 +80,8 @@ import { FullCompactionCancel, FullCompactionComplete, } from './compactionOps'; +import { resolveSqueezeModelAliasWithCascade } from './squeezeCascade'; +import { SqueezeModelDecided, squeezeModelKey } from './squeezeForkOps'; import { type CompactionBeginData, type CompactionResult, @@ -182,6 +184,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom this.states.contributeState(fullCompactionLastCompactedTokenCountKey); this.states.contributeState(fullCompactionConsecutiveOverflowCompactionsKey); this.states.contributeState(fullCompactionActiveTurnIdKey); + this.states.contributeState(squeezeModelKey); this.strategy = new RuntimeCompactionStrategy( () => this.resolveModelContextWithEffectiveMax(), (message) => this.tokenCounting.estimateMessage(message), @@ -351,47 +354,11 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom this.observedMaxContextTokensByModel.set(modelAlias, observed); } - private resolveSqueezeModelAliasWithCascade(currentModelAlias: string): string { - const overrides = this.profile.getAllSessionModelOverrides(); - const binding = compactionModelBindingFor(this.configService, this.flags, { - modelAlias: currentModelAlias, - thinkingLevel: this.profile.data().thinkingLevel, - }, { compactionAlias: overrides.compaction }); - const primaryAlias = binding.model; - if (primaryAlias !== currentModelAlias) { - try { - this.profile.resolveModelContextFor(primaryAlias); - return primaryAlias; - } catch { - } - } - const secondaryAlias = resolveCompactionSecondaryModel(this.configService, this.flags, { - compactionSecondaryAlias: overrides.compactionSecondary, - }); - if ( - secondaryAlias !== undefined && - secondaryAlias !== currentModelAlias && - secondaryAlias !== primaryAlias - ) { - try { - this.profile.resolveModelContextFor(secondaryAlias); - return secondaryAlias; - } catch { - } - } - return currentModelAlias; - } - begin(input: FullCompactionInput): boolean { if (this._compacting) return false; const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; const profileData = this.profile.data(); const currentModelAlias = profileData.modelAlias; - if (currentModelAlias !== undefined) { - const squeezeAlias = this.resolveSqueezeModelAliasWithCascade(currentModelAlias); - data.model = squeezeAlias; - data.modelDisplay = compactionDisplayModel(this.configService, squeezeAlias); - } if (!this.reserveCompactionSlot(data.source)) return false; const tokenCount = this.validateCompactionStart(data.source); @@ -409,6 +376,22 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom new FullCompactionBegin({ ...data, agentId: this.agent.agentId }), ); + if (currentModelAlias !== undefined) { + const squeezeResult = resolveSqueezeModelAliasWithCascade({ + currentModelAlias, + profile: this.profile, + configService: this.configService, + flags: this.flags, + }); + void this.dispatcher.dispatch( + new SqueezeModelDecided({ + agentId: this.agent.agentId, + model: squeezeResult.alias, + modelDisplay: compactionDisplayModel(this.configService, squeezeResult.alias), + }), + ); + } + const active = this.createActiveCompaction( data.source, tokenCount, diff --git a/packages/agent-core-v2/src/agent/fullCompaction/squeezeCascade.ts b/packages/agent-core-v2/src/agent/fullCompaction/squeezeCascade.ts new file mode 100644 index 0000000000..43f737cdf4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/squeezeCascade.ts @@ -0,0 +1,53 @@ +import type { IConfigService } from '#/app/config/config'; +import type { IFlagService } from '#/app/flag/flag'; +import { + compactionModelBindingFor, + resolveCompactionSecondaryModel, +} from '#/session/compaction/configSection'; +import type { IAgentProfileService } from '#/agent/profile/profile'; + +export interface SqueezeCascadeInput { + readonly currentModelAlias: string; + readonly profile: IAgentProfileService; + readonly configService: IConfigService; + readonly flags: IFlagService; +} + +export interface SqueezeCascadeResult { + readonly alias: string; + readonly isSqueezed: boolean; +} + +export function resolveSqueezeModelAliasWithCascade( + input: SqueezeCascadeInput, +): SqueezeCascadeResult { + const { currentModelAlias, profile, configService, flags } = input; + const overrides = profile.getAllSessionModelOverrides(); + const binding = compactionModelBindingFor(configService, flags, { + modelAlias: currentModelAlias, + thinkingLevel: profile.data().thinkingLevel, + }, { compactionAlias: overrides.compaction }); + const primaryAlias = binding.model; + if (primaryAlias !== currentModelAlias) { + try { + profile.resolveModelContextFor(primaryAlias); + return { alias: primaryAlias, isSqueezed: true }; + } catch { + } + } + const secondaryAlias = resolveCompactionSecondaryModel(configService, flags, { + compactionSecondaryAlias: overrides.compactionSecondary, + }); + if ( + secondaryAlias !== undefined && + secondaryAlias !== currentModelAlias && + secondaryAlias !== primaryAlias + ) { + try { + profile.resolveModelContextFor(secondaryAlias); + return { alias: secondaryAlias, isSqueezed: true }; + } catch { + } + } + return { alias: currentModelAlias, isSqueezed: false }; +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/squeezeForkOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/squeezeForkOps.ts new file mode 100644 index 0000000000..271e5bd2bb --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/squeezeForkOps.ts @@ -0,0 +1,44 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +export interface SqueezeModelDecision { + readonly agentId: string; + readonly model: string; + readonly modelDisplay?: string; +} + +const squeezeModelDecidedSchema = z.object({ + agentId: z.string(), + model: z.string(), + modelDisplay: z.string().optional(), +}); + +export class SqueezeModelDecided extends AgentEvent2< + z.infer +> { + static override readonly type = 'squeeze_model.decided'; + static override readonly durable = true; + static override readonly schema = squeezeModelDecidedSchema; +} +export interface SqueezeModelDecided { + readonly agentId: string; + readonly model: string; + readonly modelDisplay?: string; +} + +export interface SqueezeModelState { + readonly model: string; + readonly modelDisplay?: string; +} + +export const squeezeModelKey = defineState( + 'squeezeModel', + () => ({ model: '' }), +).replayable({ schema: z.custom() }) + .on(SqueezeModelDecided, (s, e) => { + s.model = e.model; + s.modelDisplay = e.modelDisplay; + }); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 09034d4ee6..98c931b496 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -42,6 +42,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; +import { forkTrack2 } from '#/app/telemetry/forkEvents'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { @@ -383,7 +384,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ setCompactionTriggerRatio(ratio: number | undefined): void { if (ratio === undefined) { this.compactionTriggerRatioOverride = undefined; - this.telemetry.track2('compaction_threshold_override', { action: 'clear' }); + forkTrack2(this.telemetry, 'compaction_threshold_override', { action: 'clear' }); return; } if ( @@ -397,7 +398,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } this.compactionTriggerRatioOverride = ratio; - this.telemetry.track2('compaction_threshold_override', { ratio, action: 'set' }); + forkTrack2(this.telemetry, 'compaction_threshold_override', { ratio, action: 'set' }); } getCompactionTriggerRatioOverride(): number | undefined { @@ -412,7 +413,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ setCompactionTokenBudget(tokens: number | undefined): void { if (tokens === undefined) { this.compactionTokenBudgetOverride = undefined; - this.telemetry.track2('compaction_token_budget_override', { action: 'clear' }); + forkTrack2(this.telemetry, 'compaction_token_budget_override', { action: 'clear' }); return; } if (!Number.isInteger(tokens) || tokens < 1) { @@ -422,7 +423,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } this.compactionTokenBudgetOverride = tokens * 1_000; - this.telemetry.track2('compaction_token_budget_override', { + forkTrack2(this.telemetry, 'compaction_token_budget_override', { tokens: this.compactionTokenBudgetOverride, action: 'set', }); diff --git a/packages/agent-core-v2/src/app/telemetry/forkEvents.ts b/packages/agent-core-v2/src/app/telemetry/forkEvents.ts new file mode 100644 index 0000000000..c32cb91f82 --- /dev/null +++ b/packages/agent-core-v2/src/app/telemetry/forkEvents.ts @@ -0,0 +1,88 @@ +import type { ITelemetryService } from './telemetry'; +import { + type TelemetryEventDefinition, + defineAgentTelemetryEvent, + telemetryEventDefinitions, +} from './events'; + +export interface CompactionThresholdOverrideEvent { + action: 'set' | 'clear'; + ratio?: number; +} + +export interface CompactionTokenBudgetOverrideEvent { + action: 'set' | 'clear'; + tokens?: number; +} + +export interface SubstituteModelActivatedEvent { + original_model: string; + substitute_model: string; +} + +export interface SubstituteModelDeactivatedEvent { + original_model: string; +} + +export const forkTelemetryEventDefinitions = { + ...telemetryEventDefinitions, + compaction_threshold_override: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'The compaction threshold ratio is set or cleared by the user.', + properties: { + action: 'Whether the threshold was set or cleared', + ratio: 'The threshold ratio when set (0-1)', + }, + }), + compaction_token_budget_override: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'The compaction absolute token budget is set or cleared by the user.', + properties: { + action: 'Whether the budget was set or cleared', + tokens: 'The token budget when set', + }, + }), + substitute_model_activated: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'A substitute model is activated due to a primary model rate limit.', + properties: { + original_model: 'The primary model that was rate-limited', + substitute_model: 'The substitute model that was activated', + }, + }), + substitute_model_deactivated: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'The substitute model is deactivated and the primary model resumes.', + properties: { + original_model: 'The primary model that resumes', + }, + }), +} as const; + +export type ForkTelemetryEventName = keyof typeof forkTelemetryEventDefinitions; + +export type ForkTelemetryEventPayload = + typeof forkTelemetryEventDefinitions[K] extends TelemetryEventDefinition + ? P + : never; + +const FORK_EVENT_NAMES = new Set([ + 'compaction_threshold_override', + 'compaction_token_budget_override', + 'substitute_model_activated', + 'substitute_model_deactivated', +]); + +export function forkTrack2( + telemetry: ITelemetryService, + event: string, + properties?: Record, +): void { + if (!FORK_EVENT_NAMES.has(event)) { + throw new Error(`Unknown fork telemetry event: ${event}`); + } + (telemetry as { track2(event: string, properties?: Record): void }).track2( + event, + properties, + ); +} diff --git a/packages/agent-core-v2/test/agent/fullCompaction/compactionStartedModel.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/compactionStartedModel.test.ts index 1e598f8054..95ce6d430f 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/compactionStartedModel.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/compactionStartedModel.test.ts @@ -34,19 +34,19 @@ const SECONDARY_MODEL = { capabilities: ['thinking', 'tool_use'], } as const; -interface StartedModelArgs { +interface SqueezeModelArgs { readonly model?: string; - readonly model_display?: string; + readonly modelDisplay?: string; } -function findStartedModel(ctx: ReturnType): StartedModelArgs { +function findSqueezeModelDecided(ctx: ReturnType): SqueezeModelArgs { const events = ctx.newEvents() as unknown as Array<{ readonly type?: string; readonly event?: string; readonly args?: unknown; }>; - const event = events.find((e) => e.type === '[rpc]' && e.event === 'compaction.started'); - return (event?.args as StartedModelArgs | undefined) ?? {}; + const event = events.find((e) => e.type === '[wire]' && e.event === 'squeeze_model.decided'); + return (event?.args as SqueezeModelArgs | undefined) ?? {}; } function makeAgent(initialConfig: Record = {}) { @@ -72,7 +72,7 @@ async function runCompaction(ctx: ReturnType): Promise { await completed; } -describe('FullCompaction started model', () => { +describe('SqueezeModelDecided', () => { afterEach(() => { vi.unstubAllEnvs(); }); @@ -82,9 +82,9 @@ describe('FullCompaction started model', () => { const expectedAlias = ctx.get(IAgentProfileService).resolveModelContext().modelAlias; await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe(expectedAlias); - expect(model_display).toBe(expectedAlias); + expect(modelDisplay).toBe(expectedAlias); }); it('carries the dedicated compaction model when [compaction_model] is configured', async () => { @@ -92,9 +92,9 @@ describe('FullCompaction started model', () => { const ctx = makeAgent({ compactionModel: { model: 'kimi/compaction' } }); await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe('kimi/compaction'); - expect(model_display).toBe('kimi/compaction'); + expect(modelDisplay).toBe('kimi/compaction'); }); it('honors the legacy default_model pointer written by an older TUI', async () => { @@ -102,9 +102,9 @@ describe('FullCompaction started model', () => { const ctx = makeAgent({ compactionModel: { defaultModel: 'kimi/compaction' } }); await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe('kimi/compaction'); - expect(model_display).toBe('kimi/compaction'); + expect(modelDisplay).toBe('kimi/compaction'); }); it('cascades to the secondary squeeze model when the primary is unresolvable', async () => { @@ -112,9 +112,9 @@ describe('FullCompaction started model', () => { const ctx = makeAgent({ compactionModel: { model: 'kimi/ghost', secondaryModel: 'kimi/backup' } }); await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe('kimi/backup'); - expect(model_display).toBe('kimi/backup'); + expect(modelDisplay).toBe('kimi/backup'); }); it('uses the secondary squeeze model when no primary is configured', async () => { @@ -122,9 +122,9 @@ describe('FullCompaction started model', () => { const ctx = makeAgent({ compactionModel: { secondaryModel: 'kimi/backup' } }); await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe('kimi/backup'); - expect(model_display).toBe('kimi/backup'); + expect(modelDisplay).toBe('kimi/backup'); }); it('falls back to the current model when both squeeze tiers are unresolvable', async () => { @@ -135,9 +135,9 @@ describe('FullCompaction started model', () => { const expectedAlias = ctx.get(IAgentProfileService).resolveModelContext().modelAlias; await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe(expectedAlias); - expect(model_display).toBe(expectedAlias); + expect(modelDisplay).toBe(expectedAlias); }); it('prefers the primary squeeze model when both tiers resolve', async () => { @@ -147,8 +147,8 @@ describe('FullCompaction started model', () => { }); await runCompaction(ctx); - const { model, model_display } = findStartedModel(ctx); + const { model, modelDisplay } = findSqueezeModelDecided(ctx); expect(model).toBe('kimi/compaction'); - expect(model_display).toBe('kimi/compaction'); + expect(modelDisplay).toBe('kimi/compaction'); }); }); diff --git a/packages/agent-core-v2/test/app/config/bash-task-timeout-smoke.test.ts b/packages/agent-core-v2/test/app/config/bash-task-timeout-smoke.test.ts index e03a294b5c..66a934607f 100644 --- a/packages/agent-core-v2/test/app/config/bash-task-timeout-smoke.test.ts +++ b/packages/agent-core-v2/test/app/config/bash-task-timeout-smoke.test.ts @@ -1,19 +1,3 @@ -/** - * Production smoke test for bash-task-timeout-ignored fix (PR #44). - * - * Drives the real production ConfigService end-to-end against a TOML - * config that sets [task] bash_task_timeout_s = 1800, then constructs - * the real BashTool with that config and asserts that - * resolveAgentTaskConfig(config)?.bashTaskTimeoutS === 1800 — i.e. - * the configured value reaches the BashTool the same way it does in - * the running CLI. - * - * This is the production wiring: the same DI services, the same TOML - * loader, the same `registerConfigSection` contributions. The BashTool - * is constructed with stand-ins for the runtime-only dependencies that - * are not exercised by the timeout-resolution path. - */ - import { DisposableStore } from '#/_base/di/lifecycle'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { TestInstantiationService } from '#/_base/di/test'; @@ -28,8 +12,6 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { resolveAgentTaskConfig } from '#/agent/task/configSection'; -// Side-effect import that registers the [task] and [background] -// sections via registerConfigSection. import '#/agent/task/configSection'; import { describe, expect, it } from 'vitest'; @@ -52,8 +34,6 @@ describe('bash_task_timeout_s smoke (PR #44)', () => { const config = ix.get(IConfigService); await config.ready; - // The configured value reaches resolveAgentTaskConfig — the exact - // helper BashTool.detachTimeoutMs() uses. expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(1800); } finally { disposables.dispose(); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index facd29c481..3a851f2920 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -80,6 +80,7 @@ const V2_ONLY_RECORD_TYPES: ReadonlySet = new Set([ const V2_RECORD_TYPES: ReadonlySet = new Set([ 'tower_mode.enter', 'tower_mode.exit', + 'squeeze_model.decided', 'task.started', 'task.terminated', 'task.waitDelivered', diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index cb3a5ab555..f7c11f1fa2 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -816,10 +816,6 @@ describe('BashTool', () => { const properties = (tool.parameters as { properties: Record }) .properties; - // The schema intentionally omits a JSON-schema `default` so the tool can - // distinguish "model omitted `timeout`" (use configured background default) - // from "model passed an explicit value" (use that value). The default value - // is still surfaced via the human-readable description. expect(properties['timeout']?.default).toBeUndefined(); expect(properties['timeout']?.description).toMatch(/Foreground default 60s/); }); diff --git a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts index 91bacee5fb..475f9e9ace 100644 --- a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts +++ b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts @@ -2,6 +2,7 @@ import type { ReplayableStateKey } from '#/state/state'; import { contextMemoryKey } from '#/agent/contextMemory/contextOps'; import { fullCompactionKey, fullCompactionWireRangesKey } from '#/agent/fullCompaction/compactionOps'; +import { squeezeModelKey } from '#/agent/fullCompaction/squeezeForkOps'; import { interruptionReminderKey } from '#/agent/interruptionReminder/interruptionReminderOps'; import { llmRequestTraceKey } from '#/agent/llmRequester/llmRequestOps'; import { turnKey } from '#/agent/loop/turnOps'; @@ -28,6 +29,7 @@ export const BUILTIN_REPLAYABLE_STATE_KEYS: readonly ReplayableStateKey[] = contextMemoryKey, fullCompactionKey, fullCompactionWireRangesKey, + squeezeModelKey, interruptionReminderKey, llmRequestTraceKey, turnKey, diff --git a/tdd/verification.md b/tdd/verification.md new file mode 100644 index 0000000000..b58b116c25 --- /dev/null +++ b/tdd/verification.md @@ -0,0 +1,186 @@ +# SPEC 059 — TDD Verification (REAL evidence) + +Spec: strategy — rebase fork onto upstream/main and reapply fork features on top (issue #59). +Branch: `059-rebase-fork-onto-upstream` (based linearly on `upstream/main` @ `f9ca33376`). +Every number below is copied from an actual command run in this session; nothing is projected. + +## Step 2 — RED: reproduce the problem + +**Command A — simulate the next upstream sync using the merge-based strategy** (the pre-sync fork +state `2185de301` — first parent of the 2026-09-04 sync merge — merged with `upstream/main`): + +``` +$ git merge-tree --write-tree --name-only 2185de3014918feedf63a66d44049996d7457381 upstream/main +exit code: 1 (conflict) +CONFLICT (content): apps/kimi-code/src/tui/commands/config.ts +CONFLICT (content): apps/kimi-code/src/tui/config.ts +CONFLICT (content): apps/kimi-code/test/tui/commands/update-preferences.test.ts +CONFLICT (content): apps/kimi-code/test/tui/config.test.ts +CONFLICT (content): packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +CONFLICT (content): packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +CONFLICT (content): packages/agent-core-v2/src/app/telemetry/events.ts +``` + +All 5 recurring conflict files from the issue reproduce, plus the 2 additional files the +2026-09-05 sync (`935798c9`) also reported resolving. Historical corroboration: the merge +commits `0d335e1c` / `935798c9` each document manually resolved conflicts on exactly these files. + +**Command B — baseline test suite on fork master** (per-directory chunks of `vitest run`, +same semantics as CI's `pnpm run test --shard=n/5`; pi-tui runs its CI-mandated node:test suite): + +| chunk | result (baseline master) | +|---|---| +| packages/agent-core-v2 | 64 failed / 6352 passed / 1 skipped (359 files: 11 failed) | +| packages/agent-core | 19 failed / 4205 passed (3 expected-fail, 30 skipped) | +| packages/kap-server | 2 failed / 1322 passed | +| packages/node-sdk oauth klient kimi-telemetry migration-legacy acp-server kaos transcript tree-sitter-bash protocol | 5 failed / 3169 passed (56 skipped) | +| packages/kosong | 0 failed / 1365 passed | +| apps/kimi-code | 9 failed / 3610 passed (5 skipped) | +| apps/vis server+web | 0 failed / 221 passed | +| apps/vscode | 1 failed / 357 passed | +| pi-tui (node:test) | 0 failed / 953 passed | + +Baseline totals: **100 failed / 21,202 passed**. The issue's "4 pre-existing failures" was +stale — master genuinely carried 100. These include real fork/upstream integration drift, +e.g. `test/tui/config.test.ts "parses valid TOML"` (the sync kept upstream's `[editor]` +TOML input but dropped upstream's `editorCommand: 'code --wait'` assertion) and the two +stale-manifest tests (`docs/state-manifest.d.ts`, `docs/wire-manifest.d.ts` — the committed +manifests no longer matched the current generator). + +Note: environment parity was established first — the repo pins `packageManager: +pnpm@10.33.0` and overrides live in `pnpm-workspace.yaml`; running pnpm 9 silently rewrote +the lockfile and stripped overrides. All measurements use pnpm 10.33.0 + `pnpm install +--frozen-lockfile` (exit 0), matching CI. A re-run of the agent-core-v2 chunk after the +reinstall produced an identical failure set. + +## Step 3 — GREEN: rebase + reapply + +Phases executed (branch commits, newest last): + +1. `3687d3b27` feat: reapply fork features on top of upstream/main — full fork delta + (444 files, +45160/−297, incl. binary spec-kit tooling) applied onto `upstream/main`. + **Proof of faithful reapplication: `git diff master HEAD` is empty (byte-identical + tree to fork master).** +2. `c968bbfa3` refactor: extract squeeze cascade + telemetry into isolated modules — + cherry-pick of `eb3324e83` (PR #58 branch `refactor/squeeze-fork-modules`, previously + NOT merged). Applied cleanly. Creates `squeezeForkOps.ts`, `squeezeCascade.ts`, + `forkEvents.ts`. +3. `a3645bb86` test(tui): restore upstream editorCommand assertion in config fixture — + reconciliation of the fork fixture with the upstream fixture that the 09-05 sync lost. +4. `fix(agent-core-v2): integrate squeeze fork modules with upstream state/wire + registries` — the refactor predates upstream's strict registries, so integration: + - `fullCompactionService.ts`: `contributeState(squeezeModelKey)` (generator requires + every replayable key to be contributed by its owner service) + - `test/index.test.ts`: `'squeeze_model.decided'` registered in `V2_RECORD_TYPES` + - `test/state/builtinReplayableKeys.ts`: `squeezeModelKey` registered (harness drift guard) + - regenerated `docs/state-manifest.d.ts` + `docs/wire-manifest.d.ts` +5. `feat(vis): render squeeze_model.decided wire record` — vis server `AgentRecord` + union + vis-web `WIRE_RENDERERS` entry so the "covers every durable record" guard + stays green. +6. `merge: reconcile legacy master (merge-based history) into rebased line` — because + master's history is merge-based, GitHub's merge of this (rebased) branch into master + conflicts on files where the rebased content improves on master (add/add + FORK_OWNED_FILES; refactor-vs-inline hunks). Reconciled by merging master with + `-X ours` and restoring the refactored `fullCompactionService.ts` (the 3-way merge + had resurrected the inline cascade method from a theirs-only hunk). Result: master + is an ancestor of the PR head, the tree is byte-identical to the rebased content + (`git diff HEAD` empty), GitHub reports `mergeable: true`, and the + PR "Files changed" is exactly the intended 15-file delta. +7. `chore(agent-core-v2): strip comments from synced test files` — the 09-05 sync had + merged upstream test files containing 9 comment lines that violate the fork-owned + no-comment rule, leaving master's CI lint red (3 consecutive master CI failures). + Removed the lines; `node scripts/check-no-comments.mjs` now reports + `OK (1623 files)` and both affected test files pass (62/62). + +**Fork features verified present after rebase** (grep over the rebased tree): +`resolveSqueezeModelAliasWithCascade` (now in `squeezeCascade.ts`, dispatched via +`SqueezeModelDecided`), secondary-model fallback in `compactionRound`, fork telemetry +events `compaction_threshold_override` / `compaction_token_budget_override` / +`substitute_model_activated` / `substitute_model_deactivated` (+ `forkTrack2` wrapper), +`model`/`modelDisplay` on `CompactionBeginData`/`FullCompactionBegin`, +`favoriteModels` TUI config, `fallbackModel` schema (agent-core + TUI), TUI slash +commands (`/squeeze-model`, `/fallback-model`, `/fork-session`, `/fork-and-switch`), +substitute-model on rate limit, session-list subcommand, no-comment rule, +`http-proxy-agent` dep. + +**Upstream features verified present (previously entangled/missing):** +`fullCompactionWireRangesKey`, `captureWireLines()`, `renderRecoveryFooter()`, +`IWireService`, `budget()` + `CompactionBudget`, `isContextBudgetReminder`, +`aheadReminderTelemetry()`, `ContextBudgetReminderEvent`, +`CompactionAheadReminderEvent`, `renderCompactionInstruction()`, +`historyForModel.length === 0` guard. + +**Survival markers:** all 17 `.github/FORK_OWNED_FILES` entries resolve on the rebased +branch (file exists + marker string present). + +## Step 5 — VERIFY (actual command results on the rebased branch) + +**pnpm test** (per-directory chunks, identical commands as baseline): + +| chunk | baseline | rebased | delta | +|---|---|---|---| +| agent-core-v2 | 64F / 6352P | **62F / 6354P** | −2 (stale-manifest tests fixed) | +| agent-core | 19F / 4205P | **19F / 4205P** | identical | +| kap-server | 2F / 1322P | **2F / 1322P** | identical | +| other packages | 5F / 3169P | **5F / 3169P** | identical | +| kosong | 0F / 1365P | **0F / 1365P** | identical | +| apps/kimi-code | 9F / 3610P | **8F / 3611P** | −1 (config fixture fixed) | +| vis server+web | 0F / 221P | **0F / 221P** | identical (transient −1 during integration, fixed by renderer) | +| vscode | 1F / 357P | **1F / 357P** | identical | +| pi-tui node:test | 0F / 953P | **0F / 953P** | identical | + +Totals: baseline **100 failed / 21,202 passed** → rebased **97 failed / 21,205 passed**. +**3 pre-existing failures fixed; 0 new failures introduced.** The remaining 97 failures +are byte-identical (by test name) to the pre-existing master failures in unrelated areas +(agent behavioral suites, harness snapshots, sdk/oauth config tests, one vscode +integration test) and are out of scope for this rebase spec. + +`pnpm build` — exit 0 (all workspace packages, apps, webview bundle). +`git status --short` / `git diff --stat` — empty (zero remaining formatting diffs). + +**Phase 4 dry-run of the next upstream sync** (the actual acceptance test of this spec): + +``` +$ git merge-tree --write-tree --name-only upstream/main HEAD +exit code: 0 (clean — no conflicts, no output) +``` + +Compare with RED (exit 1, 7 conflicting files) — future syncs merge cleanly because the +fork-owned logic now lives in dedicated modules and the tree is linear on upstream/main. + +## CI comparison (GitHub Actions, real runs) + +Master CI was already red before this change (runs on 032a4494 / 40b9b99e / 2185de30 all +conclude `failure`). Normalized comparison of unique `##[error]` signatures across the five +test shards: + +- master CI run `33959456283` (032a4494): 53 unique error signatures +- PR CI run `33971295071` (3e3098c2f): 50 unique error signatures +- signatures only in master (fixed by this PR): the two stale-manifest AssertionErrors + (state-manifest, wire-manifest) + the config.test.ts fixture AssertionError — exactly + the 3 fixes above +- **signatures only in the PR (new): none** + +Remaining PR CI redness is inherited master debt: the 97 pre-existing test failures +(vitest exits non-zero on any failure, so all 5 shards conclude `failure` exactly as +master's do) and 20 pre-existing `oxlint --type-aware` diagnostics in 10 files this +branch does not modify (unmasked by fixing the 9 comment-rule violations that used to +short-circuit lint earlier; check-no-comments now reports `OK (1623 files)`). +Green on this PR's CI: build, typecheck, test-pi-tui (953/953), test-vscode-legacy, +Nix Build, flake.nix sync check. + +## Success criteria — PROVED vs NOT + +- PROVED: 5 recurring conflict files reproduced conflicting under the old strategy (RED). +- PROVED: fork rebased linearly onto current upstream/main; tree byte-identical to fork + master before the isolated-module commits (no feature lost in transit). +- PROVED: fork features (squeeze cascade modules, secondary fallback, telemetry events, + favorites, slash commands, fallbackModel, substitute model) all present + markers resolve. +- PROVED: upstream features (wire journal, budget, ahead reminders, instruction helper) + present and the strict state/wire registries pass. +- PROVED: full test suite re-run — 0 new failures, 3 pre-existing failures fixed. +- PROVED: pnpm build exit 0; working tree clean. +- PROVED: dry-run next upstream sync is conflict-free. +- NOT PROVED / out of scope: the 97 remaining pre-existing failures on unrelated suites + (present identically on fork master before this change; fixing them belongs to + separate specs). The 4 "expected fail" / skipped / todo entries in agent-core mirror CI.