diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts index 13c8d5a2b..a2e424737 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -15,9 +15,9 @@ * `begin` payload (and is persisted on the record for audit) but is not stored * in the Model; the result numbers are consumed live by the * `compaction.completed` signal and their durable effect (the summary message) - * already lives in `contextMemory`. They are still carried on the `complete` - * payload so the persisted record stays byte-compatible with the legacy wire - * log, but `apply` ignores them and collapses to `idle`. Each `apply` returns + * already lives in `contextMemory`. The live `complete` payload is empty; legacy + * logs may still carry result numbers, and `apply` accepts and ignores them while + * collapsing to `idle`. Each `apply` returns * the same reference on a no-op so the wire's reference-equality gate stays * quiet; it carries no non-determinism. * @@ -84,7 +84,7 @@ export const fullCompactionCancel = defineOp(CompactionModel, 'full_compaction.c apply: (s): CompactionState => (s.phase === 'idle' ? s : { phase: 'idle' }), }); -export type FullCompactionCompletePayload = FullCompactionCompleteData; +export type FullCompactionCompletePayload = Partial; export const fullCompactionComplete = defineOp(CompactionModel, 'full_compaction.complete', { apply: (s, _p: FullCompactionCompletePayload): CompactionState => diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index c2895752b..6476ff3d0 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -42,11 +42,11 @@ import { fullCompactionBegin, fullCompactionCancel, fullCompactionComplete, + type FullCompactionCompletePayload, } from './compactionOps'; import { type CompactionBeginData, type CompactionResult, - type FullCompactionCompleteData, } from './types'; import { OrderedHookSlot } from '#/hooks'; @@ -60,7 +60,7 @@ declare module '#/agent/wireRecord/wireRecord' { interface WireRecordMap { 'full_compaction.begin': CompactionBeginData; 'full_compaction.cancel': {}; - 'full_compaction.complete': FullCompactionCompleteData; + 'full_compaction.complete': FullCompactionCompletePayload; } } @@ -207,9 +207,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull return true; } - private markCompleted(active: ActiveCompaction, result: FullCompactionCompleteData): boolean { + private markCompleted(active: ActiveCompaction): boolean { if (this._compacting !== active) return false; - this.wire.dispatch(fullCompactionComplete(result)); + this.wire.dispatch(fullCompactionComplete({})); this._compacting = null; return true; } @@ -357,7 +357,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull if (this._compacting !== active) throw compactionCancelledReason(active); this.lastCompactedTokenCount = finalResult.tokensAfter; - if (!this.markCompleted(active, completeData(finalResult))) { + if (!this.markCompleted(active)) { throw compactionCancelledReason(active); } const { contextSummary: _contextSummary, ...eventResult } = finalResult; @@ -576,17 +576,6 @@ function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult { return { summary, usage: finish.usage }; } -function completeData(result: CompactionResult): FullCompactionCompleteData { - return { - compactedCount: result.compactedCount, - tokensBefore: result.tokensBefore, - tokensAfter: result.tokensAfter, - keptUserMessageCount: result.keptUserMessageCount, - keptHeadUserMessageCount: result.keptHeadUserMessageCount, - droppedCount: result.droppedCount, - }; -} - function historySafeToCompact( current: readonly ContextMessage[], original: readonly ContextMessage[], diff --git a/packages/agent-core-v2/src/agent/profile/configSection.ts b/packages/agent-core-v2/src/agent/profile/configSection.ts index 94b6c8449..cd7a55880 100644 --- a/packages/agent-core-v2/src/agent/profile/configSection.ts +++ b/packages/agent-core-v2/src/agent/profile/configSection.ts @@ -17,6 +17,7 @@ export const THINKING_SECTION = 'thinking'; export const DEFAULT_THINKING_SECTION = 'defaultThinking'; export const ThinkingConfigSchema = z.object({ + enabled: z.boolean().optional(), mode: z.enum(['auto', 'on', 'off']).optional(), effort: z.string().optional(), keep: z.string().optional(), diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index 604ffe766..2216927e9 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -3,11 +3,13 @@ * Op (`configUpdate`) for the agent's persistent configuration slice. * * Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`, - * the resolved `thinkingLevel`, and `systemPrompt` — as a wire Model (initial + * the resolved thinking effort, and `systemPrompt` — as a wire Model (initial * `defaultProfileModel()`), plus the single Op whose `apply` is a pure merge of - * an already-resolved payload. `thinkingLevel` is resolved to a `ThinkingEffort` - * at the call site (via `resolveThinkingEffort` + the `thinking` config section) - * and carried in the payload, so `apply` stays pure and a resumed agent restores + * an already-resolved payload. Live records carry `thinkingEffort` (matching + * the v1 wire field); legacy replay still accepts `thinkingLevel`. The value is + * resolved to a `ThinkingEffort` at the call site (via `resolveThinkingEffort` + + * the `thinking` config section) and carried in the payload, so `apply` stays + * pure and a resumed agent restores * the persisted resolved value rather than re-resolving against a possibly- * drifted config. `modelCapabilities` is intentionally NOT in the Model — it is * derived live from `IModelResolver` so resume never pins stale capabilities. @@ -27,6 +29,7 @@ */ import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; +import { ErrorCodes, KimiError } from '#/errors'; import { defineModel } from '#/wire/model'; import { defineOp } from '#/wire/op'; @@ -47,6 +50,7 @@ export interface ConfigUpdatePayload { readonly cwd?: string; readonly modelAlias?: string; readonly profileName?: string; + readonly thinkingEffort?: ThinkingEffort; readonly thinkingLevel?: ThinkingEffort; readonly systemPrompt?: string; } @@ -63,8 +67,9 @@ export const configUpdate = defineOp(ProfileModel, 'config.update', { if (p.profileName !== undefined && p.profileName !== s.profileName) { next = { ...(next ?? s), profileName: p.profileName }; } - if (p.thinkingLevel !== undefined && p.thinkingLevel !== s.thinkingLevel) { - next = { ...(next ?? s), thinkingLevel: p.thinkingLevel }; + const thinkingLevel = configUpdateThinkingLevel(p); + if (thinkingLevel !== undefined && thinkingLevel !== s.thinkingLevel) { + next = { ...(next ?? s), thinkingLevel }; } if (p.systemPrompt !== undefined && p.systemPrompt !== s.systemPrompt) { next = { ...(next ?? s), systemPrompt: p.systemPrompt }; @@ -73,6 +78,27 @@ export const configUpdate = defineOp(ProfileModel, 'config.update', { }, }); +function configUpdateThinkingLevel(p: ConfigUpdatePayload): ThinkingEffort | undefined { + if (p.thinkingEffort !== undefined && p.thinkingLevel !== undefined) { + if (p.thinkingEffort !== p.thinkingLevel) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `config.update has conflicting thinkingEffort (${p.thinkingEffort}) and legacy thinkingLevel (${p.thinkingLevel})`, + { + details: { + type: 'config.update', + thinkingEffort: p.thinkingEffort, + thinkingLevel: p.thinkingLevel, + }, + }, + ); + } + return p.thinkingEffort; + } + if (p.thinkingEffort !== undefined) return p.thinkingEffort; + return p.thinkingLevel; +} + /** * The agent's active-tool set. `undefined` means "every tool is active" (the * unrestricted default before any `tools.set_active_tools`); a concrete array diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 42d781f64..33db65258 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -35,7 +35,7 @@ import picomatch from 'picomatch'; import { ErrorCodes, KimiError } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { resolveThinkingEffort, resolveThinkingKeep } from './thinking'; +import { resolveThinkingEffort, resolveThinkingKeep, resolveThinkingLevel } from './thinking'; import type { LoopControl } from '#/agent/loop/configSection'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -63,6 +63,7 @@ import type { } from './profile'; import { IAgentProfileService } from './profile'; import { + DEFAULT_THINKING_SECTION, THINKING_SECTION, type ThinkingConfig, } from './configSection'; @@ -153,22 +154,27 @@ export class AgentProfileService implements IAgentProfileService { } // Resolve eagerly so an unknown model id fails the bind here rather than on // the first turn. - this.modelFactory.resolve(input.model); + const model = this.modelFactory.resolve(input.model); const context = await this.buildSystemPromptContext(input.cwd); const systemPrompt = profile.systemPrompt(context); const { agentsMdWarning } = context; this.agentsMdWarning = agentsMdWarning; + const thinkingLevel = resolveThinkingLevel(input.thinking, { + defaultThinking: this.config.get(DEFAULT_THINKING_SECTION), + thinking: this.config.get(THINKING_SECTION), + model, + }); + this.update({ cwd: input.cwd, profileName: profile.name, - modelAlias: input.model, - thinkingLevel: input.thinking, systemPrompt, - activeToolNames: profile.tools, }); - this.wire.dispatch(configUpdate({})); + this.setActiveTools(profile.tools); + this.wire.dispatch(configUpdate({ modelAlias: input.model, thinkingEffort: thinkingLevel })); + this.afterConfigDispatch({ modelAlias: input.model, thinkingLevel }); if (agentsMdWarning !== undefined) { this.eventBus.publish({ @@ -387,7 +393,7 @@ export class AgentProfileService implements IAgentProfileService { if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined) { const model = this.resolveModelForThinking(changed.modelAlias); - payload.thinkingLevel = resolveThinkingEffort( + payload.thinkingEffort = resolveThinkingEffort( changed.thinkingLevel, this.config.get(THINKING_SECTION), model, diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index d3967710a..eb12af8c3 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -56,9 +56,13 @@ export class AgentPromptService implements IAgentPromptService { async prompt(message: ContextMessage): Promise { const stamped = ensureMessageId(message); + if (await this.blockedByHook(stamped, false)) { + this.append(stamped); + return undefined; + } + const turn = this.launch({ input: stamped.content, origin: stamped.origin }); this.append(stamped); - if (await this.blockedByHook(stamped, false)) return undefined; - return this.launch({ input: stamped.content, origin: stamped.origin }); + return turn; } steer(message: ContextMessage): PromptSteerHandle { diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts index c0c74843d..5d79f17bd 100644 --- a/packages/agent-core-v2/src/agent/turn/turnService.ts +++ b/packages/agent-core-v2/src/agent/turn/turnService.ts @@ -65,11 +65,12 @@ export class AgentTurnService implements IAgentTurnService { } const turnId = this.wire.getModel(TurnModel).nextTurnId; + const origin = prompt?.origin ?? USER_PROMPT_ORIGIN; this.wire.dispatch( promptTurn({ turnId, input: prompt?.input, - origin: prompt?.origin, + origin, steer: prompt?.steer, }), ); @@ -83,7 +84,7 @@ export class AgentTurnService implements IAgentTurnService { }; void ready.catch(() => undefined); this.activeTurn = turn; - this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: prompt?.origin ?? USER_PROMPT_ORIGIN }); + this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin }); turn.result = this.runTurn(turn, ready); return turn; } diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index 1f921f0c6..62d61bb0f 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -15,6 +15,7 @@ import { addUsage, type TokenUsage } from '#/app/llmProtocol/usage'; import type { LLMRequestSource } from '#/agent/llmRequester/llmRequester'; import type { AgentPhase } from '#/agent/runtime/runtime'; +import { ErrorCodes, KimiError } from '#/errors'; import { defineModel } from '#/wire/model'; import { defineOp } from '#/wire/op'; @@ -46,29 +47,29 @@ export interface UsageModelState { export const UsageModel = defineModel('usage', () => ({ byModel: {} })); +export interface UsageRecordPayload { + readonly model: string; + readonly usage: TokenUsage; + readonly usageScope?: UsageRecordScope; + readonly turnId?: number; + readonly context?: LLMRequestSource; +} + export const recordUsage = defineOp(UsageModel, 'usage.record', { - apply: ( - s, - p: { - model: string; - usage: TokenUsage; - usageScope?: UsageRecordScope; - context?: LLMRequestSource; - }, - ): UsageModelState => { + apply: (s, p: UsageRecordPayload): UsageModelState => { const current = s.byModel[p.model]; const byModel = { ...s.byModel, [p.model]: current === undefined ? copyUsage(p.usage) : addUsage(current, p.usage), }; - const source = p.context; - if (source?.type !== 'turn') { + const turnId = turnIdFromUsagePayload(p); + if (turnId === undefined) { return { byModel, currentTurnId: s.currentTurnId, currentTurn: s.currentTurn }; } - if (s.currentTurnId !== source.turnId) { - return { byModel, currentTurnId: source.turnId, currentTurn: copyUsage(p.usage) }; + if (s.currentTurnId !== turnId) { + return { byModel, currentTurnId: turnId, currentTurn: copyUsage(p.usage) }; } return { byModel, @@ -83,6 +84,26 @@ export const recordUsage = defineOp(UsageModel, 'usage.record', { }), }); +function turnIdFromUsagePayload(p: UsageRecordPayload): number | undefined { + const legacyContext = p.context; + const legacyTurnId = legacyContext?.type === 'turn' ? legacyContext.turnId : undefined; + if (p.turnId !== undefined && legacyTurnId !== undefined && p.turnId !== legacyTurnId) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `usage.record has conflicting turnId (${p.turnId}) and legacy context turnId (${legacyTurnId})`, + { + details: { + type: 'usage.record', + turnId: p.turnId, + legacyTurnId, + }, + }, + ); + } + if (p.turnId !== undefined) return p.turnId; + return legacyTurnId; +} + function copyUsage(usage: TokenUsage): TokenUsage { return { ...usage }; } diff --git a/packages/agent-core-v2/src/agent/usage/usageService.ts b/packages/agent-core-v2/src/agent/usage/usageService.ts index b1c374e6a..1ddaf7f4b 100644 --- a/packages/agent-core-v2/src/agent/usage/usageService.ts +++ b/packages/agent-core-v2/src/agent/usage/usageService.ts @@ -28,7 +28,8 @@ export class AgentUsageService extends Disposable implements IAgentUsageService record(model: string, usage: TokenUsage, source?: LLMRequestSource): void { const usageScope: UsageRecordScope = source?.type === 'turn' ? 'turn' : 'session'; - this.wire.dispatch(recordUsage({ model, usage, usageScope, context: source })); + const turnId = source?.type === 'turn' ? source.turnId : undefined; + this.wire.dispatch(recordUsage({ model, usage, usageScope, turnId })); } status(): UsageStatus { diff --git a/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts b/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts index 29493ac9b..5c6e2fe90 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts @@ -36,7 +36,12 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] { return [out as PersistedRecord]; } case 'context.append_message': { - return [record]; + return [ + { + ...record, + message: stripMessageId(record['message']), + } as PersistedRecord, + ]; } case 'context.splice': { const messages = record['messages'] as @@ -46,7 +51,7 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] { const out: PersistedRecord[] = []; for (const message of messages) { if (message?.origin?.kind === 'injection') { - out.push({ type: 'context.append_message', message }); + out.push({ type: 'context.append_message', message: stripMessageId(message) }); } } return out; @@ -84,16 +89,15 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] { } return [out as PersistedRecord]; } - case 'full_compaction.begin': - return [record]; - case 'full_compaction.complete': - return [{ type: 'full_compaction.complete' } as PersistedRecord]; case 'usage.record': { const rest: Record = { ...(record as Record) }; const context = rest['context'] as | { type?: string; requestKind?: string } | undefined; - let usageScope = context?.type ?? rest['usageScope']; + let usageScope = rest['usageScope']; + if (usageScope === undefined && context !== undefined) { + usageScope = context.type; + } if (context?.type === 'operation' && context.requestKind === 'full_compaction') { usageScope = 'session'; } @@ -121,3 +125,10 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] { return [record]; } } + +function stripMessageId(message: unknown): unknown { + if (message === null || typeof message !== 'object') return message; + const { id: _id, ...rest } = message as Record; + void _id; + return rest; +} diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 8081fc08b..597ff87b5 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -48,6 +48,7 @@ import { resolveThinkingEffortForModel } from './thinking'; /** Shape of the `thinking` config section (owned by `profile`); only the * fields the resolver needs to mirror the production default are read here. */ interface ThinkingSection { + readonly enabled?: boolean; readonly mode?: string; readonly effort?: string; } @@ -175,6 +176,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { undefined, { defaultThinking, + enabled: thinking?.enabled, mode: thinking?.mode, effort: thinking?.effort, }, diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts index 20850143a..a6cf52000 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -11,6 +11,7 @@ import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; export interface ThinkingDefaults { readonly defaultThinking?: boolean; + readonly enabled?: boolean; readonly mode?: string; readonly effort?: string; } @@ -99,7 +100,11 @@ export function resolveThinkingEffortForModel( : (normalized as ThinkingEffort); } else if (defaults?.mode === 'on') { effort = configured ?? enabledThinkingEffortForModel(model); - } else if (defaults?.defaultThinking === false || defaults?.mode === 'off') { + } else if ( + defaults?.enabled === false || + defaults?.defaultThinking === false || + defaults?.mode === 'off' + ) { effort = 'off'; } else { effort = configured ?? defaultThinkingEffortForModel(model); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index b417044bb..227cbe5c0 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -5,10 +5,12 @@ * `agentId === 'main'`; `IAgentLifecycleService` itself knows nothing about * it. What *is* main-specific is session bootstrap business: the plugin * session-start service registers main-agent-only plugin guidance (matching - * v1's `pluginSessionStarts: type === 'main' ? … : undefined`). `ensureMainAgent` - * concentrates that business in one place so every bootstrapper (session - * resume, legacy session/message services, the server edge) creates the main - * agent the same way. + * v1's `pluginSessionStarts: type === 'main' ? … : undefined`) and the + * session cron service registers main-agent-only cron tools before the + * main-created notification fires. `ensureMainAgent` concentrates that + * business in one place so every bootstrapper (session resume, legacy + * session/message services, the server edge) creates the main agent the same + * way. * * Not a Service: a pure composition helper over the session handle. */ @@ -16,6 +18,7 @@ import type { ISessionScopeHandle, IAgentScopeHandle } from '#/_base/di/scope'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; import type { BindAgentInput } from '#/agent/profile/profile'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; import { IAgentLifecycleService } from './agentLifecycle'; @@ -35,6 +38,7 @@ export async function ensureMainAgent( opts?: EnsureMainAgentOptions, ): Promise { const agents = session.accessor.get(IAgentLifecycleService); + session.accessor.get(ISessionCronService); const existing = agents.getHandle(MAIN_AGENT_ID); if (existing !== undefined) return existing; const main = await agents.create({ agentId: MAIN_AGENT_ID, binding: opts?.binding }); diff --git a/packages/agent-core-v2/test/fullCompaction/fullCompaction-wire.test.ts b/packages/agent-core-v2/test/fullCompaction/fullCompaction-wire.test.ts index a4c915777..010ac4f59 100644 --- a/packages/agent-core-v2/test/fullCompaction/fullCompaction-wire.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/fullCompaction-wire.test.ts @@ -59,8 +59,7 @@ describe('fullCompaction ops (wire-backed)', () => { wire.dispatch(fullCompactionBegin({ source: 'manual', instruction: 'keep facts' })); expect(wire.getModel(CompactionModel).phase).toBe('running'); - // Result numbers ride the payload (persisted) but collapse back to idle. - wire.dispatch(fullCompactionComplete({ compactedCount: 2, tokensBefore: 100, tokensAfter: 20 })); + wire.dispatch(fullCompactionComplete({})); expect(wire.getModel(CompactionModel).phase).toBe('idle'); wire.dispatch(fullCompactionBegin({ source: 'auto' })); @@ -84,14 +83,7 @@ describe('fullCompaction ops (wire-backed)', () => { instruction: 'keep facts', }), ); - expect(records[1]).toEqual( - expect.objectContaining({ - type: 'full_compaction.complete', - compactedCount: 2, - tokensBefore: 100, - tokensAfter: 20, - }), - ); + expect(records[1]).toEqual({ type: 'full_compaction.complete' }); }); it('apply returns the same reference on a no-op (gate stays quiet)', () => { @@ -132,4 +124,15 @@ describe('fullCompaction ops (wire-backed)', () => { await stranded.wire.replay({ type: 'full_compaction.begin', source: 'auto' }); expect(stranded.wire.getModel(CompactionModel).phase).toBe('running'); }); + + it('replays legacy complete payloads that carried accounting numbers', async () => { + const host = buildHost('full-compaction-legacy-complete-replay'); + + await host.wire.replay( + { type: 'full_compaction.begin', source: 'manual' }, + { type: 'full_compaction.complete', compactedCount: 1, tokensBefore: 50, tokensAfter: 10 }, + ); + + expect(host.wire.getModel(CompactionModel).phase).toBe('idle'); + }); }); diff --git a/packages/agent-core-v2/test/profile/binding.test.ts b/packages/agent-core-v2/test/profile/binding.test.ts index 12ae70992..028c1a9aa 100644 --- a/packages/agent-core-v2/test/profile/binding.test.ts +++ b/packages/agent-core-v2/test/profile/binding.test.ts @@ -6,8 +6,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { serializeV1WireRecord } from '#/agent/wireRecord/v1WireSerializer'; +import { IAgentWireService } from '#/wire/tokens'; +import type { PersistedRecord } from '#/wire/wireService'; -import { createTestAgent, hostEnvironmentServices, type TestAgentContext } from '../harness'; +import { + InMemoryWireRecordPersistence, + createTestAgent, + hostEnvironmentServices, + type TestAgentContext, +} from '../harness'; const MOCK_MODEL = 'mock-model'; @@ -52,6 +60,59 @@ describe('AgentProfileService.bind', () => { expect(svc.getSystemPrompt()).toContain('Kimi Code CLI'); }); + it('persists bind bootstrap records in the v1-compatible order', async () => { + const persistence = new InMemoryWireRecordPersistence(); + ctx = createTestAgent( + { + persistence, + initialConfig: { + thinking: { enabled: true, effort: 'low' }, + }, + }, + hostEnvironmentServices(homeDir), + ); + const svc = ctx.get(IAgentProfileService); + await ctx.get(IAgentWireService).flush(); + const start = persistence.records.length; + + await svc.bind({ + profile: DEFAULT_AGENT_PROFILE_NAME, + model: MOCK_MODEL, + thinking: 'low', + cwd: homeDir, + }); + await ctx.get(IAgentWireService).flush(); + + const records = persistence.records + .slice(start) + .flatMap((record) => [ + ...serializeV1WireRecord(record as unknown as PersistedRecord), + ]) + .filter( + (record) => + record.type === 'config.update' || record.type === 'tools.set_active_tools', + ); + expect(records).toHaveLength(3); + expect(records[0]).toMatchObject({ + type: 'config.update', + cwd: homeDir, + profileName: DEFAULT_AGENT_PROFILE_NAME, + systemPrompt: expect.stringContaining('Kimi Code CLI'), + }); + expect(records[0]).not.toHaveProperty('modelAlias'); + expect(records[0]).not.toHaveProperty('thinkingEffort'); + expect(records[1]).toMatchObject({ + type: 'tools.set_active_tools', + names: expect.arrayContaining(['Read', 'Write', 'Bash']), + }); + expect(records[2]).toMatchObject({ + type: 'config.update', + modelAlias: MOCK_MODEL, + thinkingEffort: 'low', + }); + expect(records[2]).not.toHaveProperty('thinkingLevel'); + }); + it('setModel applies the default profile when none is bound yet', async () => { const { profile: svc } = buildContext(); diff --git a/packages/agent-core-v2/test/profile/profile-wire.test.ts b/packages/agent-core-v2/test/profile/profile-wire.test.ts index 186447b92..f9741a1df 100644 --- a/packages/agent-core-v2/test/profile/profile-wire.test.ts +++ b/packages/agent-core-v2/test/profile/profile-wire.test.ts @@ -187,7 +187,7 @@ function createRecordingModel( } describe('AgentProfileService (wire-backed config.update)', () => { - it('update persists a flat config.update record and resolves thinkingLevel at the call site', async () => { + it('update persists a flat config.update record and resolves thinkingLevel as wire thinkingEffort at the call site', async () => { svc.update({ profileName: DEFAULT_AGENT_PROFILE_NAME, systemPrompt: 'You are helpful.' }); svc.update({ thinkingLevel: 'on' }); @@ -206,7 +206,7 @@ describe('AgentProfileService (wire-backed config.update)', () => { profileName: DEFAULT_AGENT_PROFILE_NAME, systemPrompt: 'You are helpful.', }, - { type: 'config.update', thinkingLevel: 'on' }, + { type: 'config.update', thinkingEffort: 'on' }, ]); expect(records.every((record) => 'payload' in record === false)).toBe(true); }); @@ -250,7 +250,7 @@ describe('AgentProfileService (wire-backed config.update)', () => { }, }); - void host.wire.replay(...records); + await host.wire.replay(...records); expect(modelOf(host.wire).cwd).toBe('/work'); expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); expect(replayChdir).toBe(0); @@ -270,10 +270,26 @@ describe('AgentProfileService (wire-backed config.update)', () => { // Fresh host whose config section would resolve differently is irrelevant: // the persisted resolved value ('on') is restored verbatim. const host = buildHost('profile-replay-thinking'); - void host.wire.replay(...records); + await host.wire.replay(...records); expect(modelOf(host.wire).thinkingLevel).toBe('on'); }); + it('replays legacy config.update thinkingLevel records', async () => { + const host = buildHost('profile-replay-legacy-thinking-level'); + + await host.wire.replay({ type: 'config.update', thinkingLevel: 'high' }); + + expect(modelOf(host.wire).thinkingLevel).toBe('high'); + }); + + it('rejects conflicting config.update thinking aliases during replay', async () => { + const host = buildHost('profile-replay-conflicting-thinking-aliases'); + + await expect( + host.wire.replay({ type: 'config.update', thinkingEffort: 'low', thinkingLevel: 'high' }), + ).rejects.toThrow('conflicting thinkingEffort'); + }); + it('applies thinking.keep model override when thinking is enabled', () => { const generationKwargs: GenerationKwargs[] = []; const thinkingEfforts: ThinkingEffort[] = []; diff --git a/packages/agent-core-v2/test/prompt/promptService.test.ts b/packages/agent-core-v2/test/prompt/promptService.test.ts index 759efedde..05d7e73de 100644 --- a/packages/agent-core-v2/test/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/prompt/promptService.test.ts @@ -88,6 +88,29 @@ describe('AgentPromptService', () => { })); }); + it('launches the turn before appending the user message', async () => { + const { context, prompt, turn } = createHarness(); + const events: string[] = []; + const originalLaunch = turn.launch.bind(turn); + turn.launch = (...args) => { + events.push('turn.launch'); + return originalLaunch(...args); + }; + const originalAppend = context.append.bind(context); + context.append = (...messages) => { + events.push('context.append'); + originalAppend(...messages); + }; + + await prompt.prompt(userMessage('ordered', { kind: 'user' })); + + expect(events).toEqual(['turn.launch', 'context.append']); + expect(turn.launches).toEqual([0]); + expect(context.messages.map((message) => message.content[0])).toMatchObject([ + { type: 'text', text: 'ordered' }, + ]); + }); + it('runs submit hooks before queuing active steers', async () => { const { context, loop, prompt, turn } = createHarness({ hasActiveTurn: true }); const activeTurn = turn.launch(); @@ -163,7 +186,12 @@ describe('AgentPromptService', () => { expect(result).toBeUndefined(); expect(turn.launches).toEqual([]); - expect(context.messages).toHaveLength(1); + expect(context.messages).toMatchObject([ + { + content: [{ type: 'text', text: 'blocked' }], + origin: { kind: 'system_trigger', name: 'test_block' }, + }, + ]); }); it('delivers a declared steer through onDidExecuteTool and strips delivery', async () => { diff --git a/packages/agent-core-v2/test/turn/turn-ready.test.ts b/packages/agent-core-v2/test/turn/turn-ready.test.ts index 5da1c3fdb..499b41cbc 100644 --- a/packages/agent-core-v2/test/turn/turn-ready.test.ts +++ b/packages/agent-core-v2/test/turn/turn-ready.test.ts @@ -392,11 +392,11 @@ describe('AgentTurnService wire state', () => { expect(ix.get(IAgentWireService).getModel(TurnModel)).toEqual({ nextTurnId: 1 }); }); - it('dispatch persists a flat { type, turnId } record (no payload key)', async () => { + it('dispatch persists a flat record with the default user origin at the source', async () => { turnService.launch(); const records = await readRecords(); - expect(records).toEqual([{ type: 'turn.prompt', turnId: 0 }]); + expect(records).toEqual([{ type: 'turn.prompt', turnId: 0, origin: { kind: 'user' } }]); expect('payload' in records[0]!).toBe(false); }); diff --git a/packages/agent-core-v2/test/usage/usage.test.ts b/packages/agent-core-v2/test/usage/usage.test.ts index 823c6becd..d55050ee0 100644 --- a/packages/agent-core-v2/test/usage/usage.test.ts +++ b/packages/agent-core-v2/test/usage/usage.test.ts @@ -11,7 +11,7 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAgentWireService } from '#/wire/tokens'; -import type { PersistedRecord } from '#/wire/wireService'; +import type { IWireService, PersistedRecord } from '#/wire/wireService'; import { WireService } from '#/wire/wireServiceImpl'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; @@ -46,6 +46,17 @@ async function readRecords(): Promise { return out; } +function createFreshWire(logKey: string): { readonly fresh: IWireService; readonly freshLog: IAppendLogStore } { + const freshIx = disposables.add(new TestInstantiationService()); + freshIx.stub(IFileSystemStorageService, new InMemoryStorageService()); + freshIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + freshIx.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey }])); + return { + fresh: freshIx.get(IAgentWireService), + freshLog: freshIx.get(IAppendLogStore), + }; +} + const a1 = { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }; const a2 = { inputOther: 10, output: 20, inputCacheRead: 30, inputCacheCreation: 40 }; const b1 = { inputOther: 100, output: 200, inputCacheRead: 300, inputCacheCreation: 400 }; @@ -130,7 +141,7 @@ describe('AgentUsageService (wire-backed)', () => { expect('payload' in records[0]!).toBe(false); }); - it('marks turn-scoped sources with usageScope: turn for v1 wire compatibility', async () => { + it('marks turn-scoped sources with usageScope and turnId without persisting request context', async () => { svc.record('model-a', a1, { type: 'turn', turnId: 7, step: 2 }); const records = await readRecords(); @@ -140,7 +151,7 @@ describe('AgentUsageService (wire-backed)', () => { model: 'model-a', usage: a1, usageScope: 'turn', - context: { type: 'turn', turnId: 7, step: 2 }, + turnId: 7, }, ]); }); @@ -150,26 +161,50 @@ describe('AgentUsageService (wire-backed)', () => { svc.record('model-a', a2, { type: 'turn', turnId: 1 }); const records = await readRecords(); - const ix2 = disposables.add(new TestInstantiationService()); - ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); - ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix2.set( - IAgentWireService, - new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'usage-replay' }]), - ); - const log2 = ix2.get(IAppendLogStore); - const fresh = ix2.get(IAgentWireService); + const { fresh, freshLog } = createFreshWire('usage-replay'); - void fresh.replay(...records); + await fresh.replay(...records); expect(fresh.getModel(UsageModel).byModel).toEqual({ 'model-a': { inputOther: 11, output: 22, inputCacheRead: 33, inputCacheCreation: 44 }, }); const written: PersistedRecord[] = []; - for await (const record of log2.read(SCOPE, 'usage-replay')) { + for await (const record of freshLog.read(SCOPE, 'usage-replay')) { written.push(record); } expect(written).toEqual([]); }); + + it('replays legacy turn context records into current turn usage', async () => { + const { fresh } = createFreshWire('usage-legacy-context-replay'); + + await fresh.replay({ + type: 'usage.record', + model: 'model-a', + usage: a1, + usageScope: 'turn', + context: { type: 'turn', turnId: 9, step: 3 }, + }); + + expect(fresh.getModel(UsageModel)).toMatchObject({ + currentTurnId: 9, + currentTurn: a1, + }); + }); + + it('rejects conflicting usage turn ids during replay', async () => { + const fresh = ix.get(IAgentWireService); + + await expect( + fresh.replay({ + type: 'usage.record', + model: 'model-a', + usage: a1, + usageScope: 'turn', + turnId: 1, + context: { type: 'turn', turnId: 2 }, + }), + ).rejects.toThrow('conflicting turnId'); + }); }); diff --git a/packages/agent-core-v2/test/wireRecord/index.test.ts b/packages/agent-core-v2/test/wireRecord/index.test.ts index 61eeca924..21dabae33 100644 --- a/packages/agent-core-v2/test/wireRecord/index.test.ts +++ b/packages/agent-core-v2/test/wireRecord/index.test.ts @@ -40,6 +40,101 @@ describe('serializeV1WireRecord', () => { ]); }); + it('strips v2 message ids from context.append_message records', () => { + const message: ContextMessage = { + id: 'msg_test', + role: 'user', + content: [{ type: 'text', text: 'hello' }], + toolCalls: [], + origin: { kind: 'user' }, + }; + + expect( + serializeV1WireRecord({ + type: 'context.append_message', + message, + }), + ).toEqual([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: expect.any(Number), + }, + ]); + }); + + it('strips v2 message ids from injected context.splice projections', () => { + const message: ContextMessage = { + id: 'msg_injection', + role: 'user', + content: [{ type: 'text', text: 'reminder' }], + toolCalls: [], + origin: { kind: 'injection', variant: 'test' }, + }; + + expect( + serializeV1WireRecord({ + type: 'context.splice', + start: 0, + deleteCount: 0, + messages: [message], + }), + ).toEqual([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'reminder' }], + toolCalls: [], + origin: { kind: 'injection', variant: 'test' }, + }, + time: expect.any(Number), + }, + ]); + }); + + it('passes source-shaped config.update records through without field projection', () => { + expect( + serializeV1WireRecord({ + type: 'config.update', + modelAlias: 'mock-model', + thinkingEffort: 'low', + } satisfies PersistedRecord), + ).toEqual([ + { + type: 'config.update', + modelAlias: 'mock-model', + thinkingEffort: 'low', + time: expect.any(Number), + }, + ]); + + expect(serializeV1WireRecord({ type: 'config.update' } satisfies PersistedRecord)).toEqual([ + { type: 'config.update', time: expect.any(Number) }, + ]); + }); + + it('defaults missing turn.prompt origin to the v1 user origin', () => { + expect( + serializeV1WireRecord({ + type: 'turn.prompt', + input: [{ type: 'text', text: 'hello' }], + } satisfies PersistedRecord), + ).toEqual([ + { + type: 'turn.prompt', + input: [{ type: 'text', text: 'hello' }], + origin: { kind: 'user' }, + time: expect.any(Number), + }, + ]); + }); + it('preserves v1 context.apply_compaction accounting fields', () => { expect( serializeV1WireRecord({