fix(agent-core-v2): align v1 wire records

This commit is contained in:
7Sageer 2026-07-08 20:54:21 +08:00
parent e6d3b370f7
commit e5ec0fb005
20 changed files with 404 additions and 95 deletions

View file

@ -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<FullCompactionCompleteData>;
export const fullCompactionComplete = defineOp(CompactionModel, 'full_compaction.complete', {
apply: (s, _p: FullCompactionCompletePayload): CompactionState =>

View file

@ -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[],

View file

@ -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(),

View file

@ -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

View file

@ -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<boolean | undefined>(DEFAULT_THINKING_SECTION),
thinking: this.config.get<ThinkingConfig>(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<ThinkingConfig>(THINKING_SECTION),
model,

View file

@ -56,9 +56,13 @@ export class AgentPromptService implements IAgentPromptService {
async prompt(message: ContextMessage): Promise<Turn | undefined> {
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 {

View file

@ -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;
}

View file

@ -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<UsageModelState>('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 };
}

View file

@ -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 {

View file

@ -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<string, unknown> = { ...(record as Record<string, unknown>) };
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<string, unknown>;
void _id;
return rest;
}

View file

@ -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,
},

View file

@ -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);

View file

@ -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<IAgentScopeHandle> {
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 });

View file

@ -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');
});
});

View file

@ -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();

View file

@ -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[] = [];

View file

@ -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 () => {

View file

@ -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);
});

View file

@ -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<PersistedRecord[]> {
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<PersistedRecord>(SCOPE, 'usage-replay')) {
for await (const record of freshLog.read<PersistedRecord>(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');
});
});

View file

@ -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({