refactor(agent-core-v2): fold replayBuilder into record

The record stream had three services: wireRecord (durable log), record
(live/facet facade), and replayBuilder (replay buffer). replayBuilder was
just one more projection of the same record stream, so fold it into record
and model the split as Event Store + Projection:

- record now owns the replay read model: push/patchLast/removeLastMessages/
  buildReplay/captureLiveRecords, plus the range/segment logic for partial
  resume (seeded via RecordServiceOptions).
- The two onRestoredRecord handlers collapse into one; record no longer
  injects IAgentReplayBuilderService.
- Drop the unused restore/flush/close forwarders from IAgentRecordService;
  orchestration keeps using wireRecord directly.
- replayBuilder keeps only its shared cross-domain types (AgentReplayRecord,
  ResumeSessionResult) as a types-only module; the service is removed.
- Update consumers (contextMemory, fullCompaction, goal, messageLegacy,
  agentLifecycle), the test harness, and stubs to IAgentRecordService.
- Add a record>contextMemory domain-layer exception: replay message records
  carry ContextMessage.
This commit is contained in:
haozhe.yang 2026-07-02 19:19:50 +08:00
parent 643acf454a
commit 8ee741007b
24 changed files with 293 additions and 391 deletions

View file

@ -70,7 +70,6 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>contextInjector</b>\n<size:9><i>Agent</i></size>\n IAgentContextInjectorService" as contextInjector #FDEBD0
rectangle "<b>contextSize</b>\n<size:9><i>Agent</i></size>\n IAgentContextSizeService" as contextSize #FDEBD0
rectangle "<b>systemReminder</b>\n<size:9><i>Agent</i></size>\n IAgentSystemReminderService" as systemReminder #FDEBD0
rectangle "<b>replayBuilder</b>\n<size:9><i>Agent</i></size>\n IAgentReplayBuilderService" as replayBuilder #FDEBD0
rectangle "<b>profile</b>\n<size:9><i>Agent</i></size>\n IAgentProfileService" as profile #FDEBD0
rectangle "<b>prompt</b>\n<size:9><i>Agent</i></size>\n IAgentPromptService" as prompt #FDEBD0
rectangle "<b>turn</b>\n<size:9><i>Agent</i></size>\n IAgentTurnService" as turn #FDEBD0
@ -167,7 +166,7 @@ blobStore --> environment #34495E
filestore --> storage #34495E
record --> wireRecord #34495E
contextMemory --> wireRecord #34495E
contextMemory --> replayBuilder #34495E
contextMemory --> record #34495E
contextInjector --> contextMemory #34495E
contextInjector --> turn #34495E
contextInjector --> loop #34495E
@ -176,10 +175,9 @@ contextSize --> contextMemory #34495E
contextSize --> record #34495E
contextSize --> wireRecord #34495E
systemReminder --> contextMemory #34495E
replayBuilder --> wireRecord #34495E
profile --> wireRecord #34495E
profile --> record #34495E
profile --> replayBuilder #34495E
profile --> record #34495E
profile --> telemetry #34495E
profile --> config #34495E
profile --> modelProvider #34495E
@ -224,24 +222,24 @@ permissionGate --> telemetry #34495E
permissionGate --> toolExecutor #34495E
permissionMode --> wireRecord #34495E
permissionMode --> record #34495E
permissionMode --> replayBuilder #34495E
permissionMode --> record #34495E
permissionMode --> contextInjector #34495E
permissionRules --> wireRecord #34495E
permissionRules --> replayBuilder #34495E
permissionRules --> record #34495E
permissionRules --> config #34495E
plan --> contextMemory #34495E
plan --> wireRecord #34495E
plan --> record #34495E
plan --> agentFs #34495E
plan --> profile #34495E
plan --> replayBuilder #34495E
plan --> record #34495E
plan --> toolRegistry #34495E
plan --> contextInjector #34495E
plan --> telemetry #34495E
goal --> wireRecord #34495E
goal --> record #34495E
goal --> systemReminder #34495E
goal --> replayBuilder #34495E
goal --> record #34495E
goal --> telemetry #34495E
goal --> contextInjector #34495E
goal --> contextMemory #34495E
@ -294,7 +292,7 @@ fullCompaction --> toolStore #34495E
fullCompaction --> telemetry #34495E
fullCompaction --> wireRecord #34495E
fullCompaction --> record #34495E
fullCompaction --> replayBuilder #34495E
fullCompaction --> record #34495E
fullCompaction --> externalHooks #34495E
fullCompaction --> turn #34495E
fullCompaction --> loop #34495E

View file

@ -257,6 +257,10 @@ const ALLOWED_EXCEPTIONS = new Set([
'permissionPolicy>profile',
'permissionRules>replayBuilder',
'record>replayBuilder',
// `record` owns the replay read model, whose `message` records carry
// `ContextMessage` (L4). `removeLastMessages` takes a set of them, so the
// projection side references the context message type by structure only.
'record>contextMemory',
'plugin>externalHooks',
'plugin>mcp',
'profile>session',

View file

@ -2,7 +2,6 @@ import {
Disposable,
} from "#/_base/di";
import { OrderedHookSlot } from '#/hooks';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
import { IAgentContextMemoryService } from './contextMemory';
import { ensureMessageId } from './messageId';
@ -36,7 +35,6 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
constructor(
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentReplayBuilderService private readonly replayBuilder: IAgentReplayBuilderService,
) {
super();
this._register(
@ -87,9 +85,9 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
: [];
const messages = record.messages.map(ensureMessageId);
this.history.splice(record.start, record.deleteCount, ...messages);
this.replayBuilder.removeLastMessages(new Set(removedMessages));
this.record.removeLastMessages(new Set(removedMessages));
for (const message of messages) {
this.replayBuilder.push({ type: 'message', message });
this.record.push({ type: 'message', message });
}
void this.hooks.onSpliced.run({
start: record.start,

View file

@ -20,7 +20,6 @@ import { IAgentLoopService, type TurnContextOverflowContext } from '#/agent/loop
import { isAbortError } from '#/agent/loop/errors';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import {
TODO_STORE_KEY,
renderTodoList,
@ -106,7 +105,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IAgentToolStoreService private readonly toolStore: IAgentToolStoreService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentReplayBuilderService private readonly replayBuilder: IAgentReplayBuilderService,
@IAgentExternalHooksService private readonly externalHooks: IAgentExternalHooksService,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
@ -141,7 +139,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this._register(
record.define('full_compaction.begin', {
resume: (r) => {
this.replayBuilder.push({
this.record.push({
type: 'compaction',
instruction: r.instruction,
});
@ -151,7 +149,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this._register(
record.define('full_compaction.cancel', {
resume: () => {
this.replayBuilder.patchLast('compaction', { result: 'cancelled' });
this.record.patchLast('compaction', { result: 'cancelled' });
},
}),
);
@ -161,8 +159,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
const message = compactionSummaryMessage(this.context.get());
if (message === undefined) return;
const summary = contextMessageText(message);
this.replayBuilder.removeLastMessages(new Set([message]));
this.replayBuilder.patchLast('compaction', {
this.record.removeLastMessages(new Set([message]));
this.record.patchLast('compaction', {
result: {
summary,
compactedCount: r.compactedCount,

View file

@ -34,7 +34,6 @@ import {
type TurnStepUsageContext,
} from '#/agent/loop';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import {
IAgentTurnService,
@ -164,7 +163,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
private readonly options: GoalServiceOptions = {},
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentReplayBuilderService private readonly replayBuilder: IAgentReplayBuilderService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@ -582,7 +580,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
budgetLimits: {},
};
this.state = state;
this.replayBuilder.push({
this.record.push({
type: 'goal_updated',
snapshot: this.toSnapshot(state),
change: { kind: 'created' },
@ -608,7 +606,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
if (record.budgetLimits !== undefined) state.budgetLimits = record.budgetLimits;
if (status === undefined) return;
this.replayBuilder.push({
this.record.push({
type: 'goal_updated',
snapshot: this.toSnapshot(state),
change:

View file

@ -4,22 +4,21 @@
* Single entry point for recording facts that happen inside an agent. One
* `append(record)` call fans out to every facet declared for the record type:
* durable persistence (for resume), live broadcast (`AgentEvent` to the edge),
* and replay capture. `signal(event)` emits a live-only event that is never
* recorded (deltas / progress). Bound at Agent scope.
* and the replay read model. `signal(event)` emits a live-only event that is
* never recorded (deltas / progress). The replay read model (`buildReplay`,
* `push`/`patchLast`/`removeLastMessages`) is owned here too it is just one
* more projection of the same record stream. Bound at Agent scope.
*/
import type { AgentEvent } from '@moonshot-ai/protocol';
import type { IDisposable } from '#/_base/di';
import { createDecorator } from '#/_base/di';
import type { ContextMessage } from '#/agent/contextMemory';
import type {
IAgentWireRecordService,
WireRecord,
WireRecordBlobSelector,
WireRecordMap,
WireRecordRestoreOptions,
WireRecordRestoreResult,
PersistedWireRecord,
WireRecordRestoringContext,
} from '#/agent/wireRecord';
import type { AgentReplayRecord, AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
@ -67,6 +66,20 @@ export interface RecordFacets<K extends keyof AgentRecordMap> {
readonly blobs?: WireRecordBlobSelector<AgentRecord<K>>;
}
export interface ReplayRangeOptions {
readonly start?: number;
readonly count?: number;
}
/**
* Static construction options for `AgentRecordService`, supplied through a
* `SyncDescriptor` when the service is seeded into a scope. `range` limits the
* replay read model to a slice of the restored stream (used by partial-resume).
*/
export interface RecordServiceOptions {
readonly range?: ReplayRangeOptions;
}
export interface IAgentRecordService {
readonly _serviceBrand: undefined;
@ -86,16 +99,24 @@ export interface IAgentRecordService {
*/
define<K extends keyof AgentRecordMap>(type: K, facets: RecordFacets<K>): IDisposable;
restore(
records?: readonly PersistedWireRecord[],
options?: WireRecordRestoreOptions,
): Promise<WireRecordRestoreResult>;
flush(): Promise<void>;
close(): Promise<void>;
/** Replay result built from restored (and optionally live) records. */
/**
* Append a record to the replay read model directly. Used when the projected
* data is computed inside a domain handler rather than derived from a single
* record via `toReplay` (e.g. `contextMemory` projecting spliced messages).
* Gated by phase: captured while restoring/post-restoring, or always when
* `captureLiveRecords` is set.
*/
push(record: AgentReplayRecordPayload): void;
/** Patch the most recent replay record of `type` (restore-time only). */
patchLast<T extends AgentReplayRecord['type']>(
type: T,
patch: Partial<Extract<AgentReplayRecord, { type: T }>>,
): void;
/** Drop replay `message` records whose message is in `removedMessages`. */
removeLastMessages(removedMessages: ReadonlySet<ContextMessage>): void;
/** Replay read model built from restored (and optionally live) records. */
buildReplay(): readonly AgentReplayRecord[];
/** When true, live `append` calls also feed the replay buffer. */
/** When true, live `append` calls also feed the replay read model. */
captureLiveRecords: boolean;
readonly restoring: WireRecordRestoringContext | null;

View file

@ -3,12 +3,17 @@
*
* Owns the unified `append` / `signal` / `define` API: one `append(record)`
* fans out to durable persistence (delegated to `wireRecord`), live broadcast
* (an owned `Emitter<AgentEvent>`), and replay capture (delegated to
* `replayBuilder`). `signal(event)` emits a live-only event that is never
* recorded. Live emission is suppressed while restoring, so edge consumers
* never receive historical events. The former `eventSink` service is folded
* into this class; `wireRecord` / `replayBuilder` remain registered backends
* that this service coordinates.
* (an owned `Emitter<AgentEvent>`), and the replay read model (an owned
* buffer). `signal(event)` emits a live-only event that is never recorded.
* Live emission is suppressed while restoring, so edge consumers never receive
* historical events.
*
* The replay read model (`push` / `patchLast` / `removeLastMessages` /
* `buildReplay`) is owned here too it is one more projection of the same
* record stream, fed by `toReplay` facets (declarative) and by direct `push`
* calls from domain handlers (imperative). The former `eventSink` and
* `replayBuilder` services are folded into this class; `wireRecord` remains the
* registered persistence backend that this service coordinates.
*/
import { Disposable, toDisposable } from '#/_base/di';
@ -18,43 +23,64 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { AgentEvent } from '@moonshot-ai/protocol';
import type { ContextMessage } from '#/agent/contextMemory';
import {
IAgentWireRecordService,
type WireRecord,
type WireRecordBlobSelector,
type WireRecordMap,
type WireRecordRestoreOptions,
type WireRecordRestoreResult,
type PersistedWireRecord,
type WireRecordRestoringContext,
} from '#/agent/wireRecord';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import type { AgentReplayRecord } from '#/agent/replayBuilder/types';
import type { AgentReplayRecord, AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
import {
IAgentRecordService,
type AgentRecord,
type AgentRecordMap,
type RecordFacets,
type RecordServiceOptions,
} from './record';
// An undo boundary is a `context.splice` that removes messages from the start of
// the history. It is the canonical (post v1.5 migration) equivalent of the legacy
// `context.clear` and `context.apply_compaction` records, both of which the v1.5
// migration rewrites into a `context.splice` with `start === 0` and
// `deleteCount > 0` (see wireRecord/migration/v1.5.ts). A splice that only
// appends (`deleteCount === 0`) or removes messages from the middle/end of the
// history (`start > 0`, e.g. a migrated `context.undo`) is not a boundary.
function isUndoBoundaryRecord(record: WireRecord): boolean {
return record.type === 'context.splice' && record.start === 0 && record.deleteCount > 0;
}
export class AgentRecordService extends Disposable implements IAgentRecordService {
declare readonly _serviceBrand: undefined;
private readonly facets = new Map<keyof AgentRecordMap, RecordFacets<keyof AgentRecordMap>>();
private readonly liveEmitter = this._register(new Emitter<AgentEvent>());
// Replay read model state.
captureLiveRecords = false;
private readonly replayRecords: AgentReplayRecord[] = [];
private _postRestoring = false;
private frozen = false;
private segmentStart = 0;
constructor(
private readonly options: RecordServiceOptions = {},
@IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService,
@IAgentReplayBuilderService private readonly replayBuilder: IAgentReplayBuilderService,
) {
super();
// Restore-time replay capture: every restored record runs its `toReplay`
// facet. `replayBuilder.push` gates by phase, so this only records while
// restoring / post-restoring.
// facet (declarative projection), then the range/segment logic applies.
// Domain resumers (which run before this hook) perform the imperative
// `push` calls, so by the time we run here their contributions are already
// in the buffer.
this._register(
wireRecord.hooks.onRestoredRecord.register('record-replay', async (ctx, next) => {
await next();
this.runReplayFacet(ctx.record as unknown as AgentRecord);
if (this.finishRestoringRecord(ctx.record)) {
ctx.stop = true;
}
}),
);
}
@ -109,31 +135,55 @@ export class AgentRecordService extends Disposable implements IAgentRecordServic
});
}
restore(
records?: readonly PersistedWireRecord[],
options?: WireRecordRestoreOptions,
): Promise<WireRecordRestoreResult> {
return this.wireRecord.restore(records, options);
push(record: AgentReplayRecordPayload): void {
if (
!this.captureLiveRecords &&
this.wireRecord.restoring === null &&
!this.postRestoring
) {
return;
}
if (this.frozen) return;
this.replayRecords.push({
...record,
time: this.wireRecord.restoring?.time ?? Date.now(),
});
}
flush(): Promise<void> {
return this.wireRecord.flush();
patchLast<T extends AgentReplayRecord['type']>(
type: T,
patch: Partial<Extract<AgentReplayRecord, { type: T }>>,
): void {
if (this.frozen) return;
if (this.wireRecord.restoring === null) return;
const last = this.replayRecords.at(-1);
if (last?.type === type) {
Object.assign(last, patch);
}
}
close(): Promise<void> {
return this.wireRecord.close();
removeLastMessages(removedMessages: ReadonlySet<ContextMessage>): void {
if (this.frozen) return;
if (removedMessages.size === 0) return;
this.removeMessagesFrom(this.replayRecords, removedMessages);
}
buildReplay(): readonly AgentReplayRecord[] {
return this.replayBuilder.buildResult();
}
get captureLiveRecords(): boolean {
return this.replayBuilder.captureLiveRecords;
}
set captureLiveRecords(value: boolean) {
this.replayBuilder.captureLiveRecords = value;
const range = this.options.range;
if (range !== undefined) {
if (range.start === undefined && range.count !== undefined) {
const offset = Math.max(0, this.replayRecords.length - range.count);
return this.replayRecords.slice(offset);
}
const start = range.start ?? 0;
const offset = Math.max(0, start - this.segmentStart);
const count = range.count;
const end = count === undefined ? undefined : offset + count;
return this.replayRecords.slice(offset, end);
}
return this.replayRecords;
}
get restoring(): WireRecordRestoringContext | null {
@ -141,7 +191,11 @@ export class AgentRecordService extends Disposable implements IAgentRecordServic
}
get postRestoring(): boolean {
return this.replayBuilder.postRestoring;
return this._postRestoring || this.wireRecord.postRestoring;
}
set postRestoring(value: boolean) {
this._postRestoring = value;
}
get hooks(): IAgentWireRecordService['hooks'] {
@ -162,7 +216,38 @@ export class AgentRecordService extends Disposable implements IAgentRecordServic
if (out === undefined) return;
const list = Array.isArray(out) ? out : [out];
for (const replayRecord of list) {
this.replayBuilder.push(replayRecord);
this.push(replayRecord);
}
}
private finishRestoringRecord(record: WireRecord): boolean {
const range = this.options.range;
if (range === undefined) return false;
if (this.frozen) return true;
if (!isUndoBoundaryRecord(record)) return false;
if (range.start === undefined) return false;
const start = range.start;
const nextSegmentStart = this.segmentStart + this.replayRecords.length;
if (nextSegmentStart > start) {
this.frozen = true;
return true;
}
this.segmentStart = nextSegmentStart;
this.replayRecords.splice(0);
return false;
}
private removeMessagesFrom(
records: AgentReplayRecord[],
removedMessages: ReadonlySet<ContextMessage>,
): void {
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]!;
if (record.type === 'message' && removedMessages.has(record.message)) {
records.splice(i, 1);
}
}
}
}

View file

@ -1,6 +1,10 @@
/**
* `replayBuilder` domain barrel - re-exports the replayBuilder service contract and implementation.
* `replayBuilder` barrel shared replay read-model types.
*
* The replay buffer service was folded into `IAgentRecordService` (the
* projection side of the record stream). Only the cross-domain types remain
* here: the replay record shapes produced by `record` and the resume-result
* shapes consumed by the edge (`rpc` / `core-api`).
*/
export * from './replayBuilder';
export * from './replayBuilderService';
export * from './types';

View file

@ -1,33 +0,0 @@
import { createDecorator } from "#/_base/di";
import type { ContextMessage } from '#/agent/contextMemory';
import type { WireRecord } from "#/agent/wireRecord";
import type { AgentReplayRecord, AgentReplayRecordPayload } from './types';
export interface ReplayRangeOptions {
readonly start?: number;
readonly count?: number;
}
export interface ReplayBuilderServiceOptions {
readonly range?: ReplayRangeOptions;
}
export interface IAgentReplayBuilderService {
readonly _serviceBrand: undefined;
postRestoring: boolean;
captureLiveRecords: boolean;
push(record: AgentReplayRecordPayload): void;
patchLast<T extends AgentReplayRecord['type']>(
type: T,
patch: Partial<Extract<AgentReplayRecord, { type: T }>>,
): void;
removeLastMessages(removedMessages: ReadonlySet<ContextMessage>): void;
finishRestoringRecord(record: WireRecord): boolean;
buildResult(): readonly AgentReplayRecord[];
}
export const IAgentReplayBuilderService = createDecorator<IAgentReplayBuilderService>(
'agentReplayBuilderService',
);

View file

@ -1,149 +0,0 @@
import {
Disposable,
} from "#/_base/di";
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { AgentReplayRecord, AgentReplayRecordPayload } from './types';
import type { ContextMessage } from "#/agent/contextMemory";
import type { WireRecord } from "#/agent/wireRecord";
import { IAgentWireRecordService } from '#/agent/wireRecord';
import {
IAgentReplayBuilderService,
type ReplayBuilderServiceOptions,
} from './replayBuilder';
// An undo boundary is a `context.splice` that removes messages from the start of
// the history. It is the canonical (post v1.5 migration) equivalent of the legacy
// `context.clear` and `context.apply_compaction` records, both of which the v1.5
// migration rewrites into a `context.splice` with `start === 0` and
// `deleteCount > 0` (see wireRecord/migration/v1.5.ts). A splice that only
// appends (`deleteCount === 0`) or removes messages from the middle/end of the
// history (`start > 0`, e.g. a migrated `context.undo`) is not a boundary.
function isUndoBoundaryRecord(record: WireRecord): boolean {
return record.type === 'context.splice' && record.start === 0 && record.deleteCount > 0;
}
export class AgentReplayBuilderService extends Disposable implements IAgentReplayBuilderService {
declare readonly _serviceBrand: undefined;
captureLiveRecords = false;
private readonly records: AgentReplayRecord[] = [];
private _postRestoring = false;
private frozen = false;
private segmentStart = 0;
constructor(
private readonly options: ReplayBuilderServiceOptions = {},
@IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService,
) {
super();
this._register(
wireRecord.hooks.onRestoredRecord.register('replay-builder', async (context, next) => {
await next();
if (this.finishRestoringRecord(context.record)) {
context.stop = true;
}
}),
);
}
get postRestoring(): boolean {
return this._postRestoring || this.wireRecord.postRestoring;
}
set postRestoring(value: boolean) {
this._postRestoring = value;
}
push(record: AgentReplayRecordPayload): void {
if (
!this.captureLiveRecords &&
this.wireRecord.restoring === null &&
!this.postRestoring
) {
return;
}
if (this.frozen) return;
this.records.push({
...record,
time: this.wireRecord.restoring?.time ?? Date.now(),
});
}
patchLast<T extends AgentReplayRecord['type']>(
type: T,
patch: Partial<Extract<AgentReplayRecord, { type: T }>>,
): void {
if (this.frozen) return;
if (this.wireRecord.restoring === null) return;
const last = this.records.at(-1);
if (last?.type === type) {
Object.assign(last, patch);
}
}
removeLastMessages(removedMessages: ReadonlySet<ContextMessage>): void {
if (this.frozen) return;
if (removedMessages.size === 0) return;
this.removeMessagesFrom(this.records, removedMessages);
}
finishRestoringRecord(record: WireRecord): boolean {
const range = this.options.range;
if (range === undefined) return false;
if (this.frozen) return true;
if (!isUndoBoundaryRecord(record)) return false;
if (range.start === undefined) return false;
const start = range.start;
const nextSegmentStart = this.segmentStart + this.records.length;
if (nextSegmentStart > start) {
this.frozen = true;
return true;
}
this.segmentStart = nextSegmentStart;
this.records.splice(0);
return false;
}
buildResult(): readonly AgentReplayRecord[] {
const range = this.options.range;
if (range !== undefined) {
if (range.start === undefined && range.count !== undefined) {
const offset = Math.max(0, this.records.length - range.count);
return this.records.slice(offset);
}
const start = range.start ?? 0;
const offset = Math.max(0, start - this.segmentStart);
const count = range.count;
const end = count === undefined ? undefined : offset + count;
return this.records.slice(offset, end);
}
return this.records;
}
private removeMessagesFrom(
records: AgentReplayRecord[],
removedMessages: ReadonlySet<ContextMessage>,
): void {
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]!;
if (record.type === 'message' && removedMessages.has(record.message)) {
records.splice(i, 1);
}
}
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentReplayBuilderService,
AgentReplayBuilderService,
InstantiationType.Delayed,
'replayBuilder',
);

View file

@ -8,7 +8,7 @@
* holds the model's CURRENT, folded context and is left untouched. For a live
* session this adapter reads that folded history (its transcript is in memory
* by definition); for a cold session it loads the session, restores the main
* agent's wire log, and reads the FULL transcript from `IAgentReplayBuilderService`
* agent's wire log, and reads the FULL transcript from `IAgentRecordService`
* (pre-compaction messages preserved, matching v1's `wire.jsonl` rebuild). The
* `ContextMessage → Message` projection is shared with the `snapshot` and
* `:undo` edges via `contextMemory/messageProjection`. Bound at App scope a

View file

@ -6,7 +6,7 @@
* shape. Live sessions are read from the main agent's `IAgentContextMemoryService` (the
* folded history already in memory); cold sessions are loaded, their main agent
* is restored from the persisted wire log, and the FULL transcript is read from
* `IAgentReplayBuilderService` v2's own replay reducer, so no reduction logic is
* `IAgentRecordService` v2's own replay read model, so no reduction logic is
* duplicated here. Pagination, id derivation, and the role filter mirror v1's
* `MessageService` (`packages/agent-core/src/services/message/messageService.ts`).
*/
@ -22,7 +22,7 @@ import {
type ContextMessage,
} from '#/agent/contextMemory';
import { ErrorCodes, KimiError } from '#/errors';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService } from '#/agent/record';
import { ISessionIndex } from '#/app/sessionIndex';
import { ISessionLifecycleService } from '#/app/sessionLifecycle';
@ -109,7 +109,7 @@ export class MessageLegacyService implements IMessageLegacyService {
// agent was restored and the replay holds the full pre-compaction history.
// Otherwise the agent is fresh (never restored) and the folded context
// memory IS the transcript.
const replay = agent.accessor.get(IAgentReplayBuilderService).buildResult();
const replay = agent.accessor.get(IAgentRecordService).buildReplay();
const restored: ContextMessage[] = [];
for (const record of replay) {
if (record.type === 'message') restored.push(record.message);

View file

@ -34,10 +34,6 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry';
import { IAgentWireRecordService, AgentWireRecordService } from '#/agent/wireRecord';
import { IAgentBlobStoreService, AgentBlobStoreService } from '#/agent/blobStore';
import {
IAgentReplayBuilderService,
AgentReplayBuilderService,
} from '#/agent/replayBuilder';
import {
IAgentExternalHooksService,
AgentExternalHooksService,
@ -106,12 +102,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
[IAgentWireRecordService, new SyncDescriptor(AgentWireRecordService, [{ homedir: agentHomedir }])],
[IAgentBlobStoreService, new SyncDescriptor(AgentBlobStoreService, [{}])],
[IAgentMcpService, new SyncDescriptor(AgentMcpService, [{ manager: this.getMcpManager() }])],
// These two carry a leading static `options` param; the scoped
// External hooks carries a leading static `options` param; the scoped
// registry supplies none, so seed an empty one to satisfy the DI
// contract (static args must fill the slots before the first `@IX`).
// Kept delayed so they only instantiate (with their full dependency
// set) when a turn actually resolves them.
[IAgentReplayBuilderService, new SyncDescriptor(AgentReplayBuilderService, [{}], true)],
// Kept delayed so it only instantiates (with its full dependency set)
// when a turn actually resolves it.
[IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService, [{}], true)],
],
},

View file

@ -1,6 +1,6 @@
/**
* `contextMemory` test stubs shared doubles for `IAgentContextMemoryService` and its
* collaborators (`IAgentWireRecordService`, `IAgentReplayBuilderService`).
* collaborators (`IAgentWireRecordService`, `IAgentRecordService`).
*
* Lives under `test/` (not `src/`) so test-support code stays out of the
* production tree. Import from a relative path (`./stubs` or
@ -12,7 +12,7 @@ import type { ServiceRegistration } from '#/_base/di/test';
import { createHooks } from '#/hooks';
import type { Hooks } from '#/hooks';
import { ensureMessageId, IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService } from '#/agent/record';
import { IAgentWireRecordService } from '#/agent/wireRecord';
/**
@ -36,17 +36,23 @@ export function stubWireRecord(): IAgentWireRecordService {
};
}
/** A no-op `IAgentReplayBuilderService` — every mutator is a no-op. */
export function stubReplayBuilder(): IAgentReplayBuilderService {
/** A no-op `IAgentRecordService` — every mutator is a no-op and `buildReplay` is empty. */
export function stubRecord(): IAgentRecordService {
const hooks = createHooks(['onRestoredRecord', 'onResumeEnded']) as IAgentRecordService['hooks'];
return {
_serviceBrand: undefined,
restoring: null,
postRestoring: false,
captureLiveRecords: false,
hooks,
append: () => {},
on: () => toDisposable(() => {}),
signal: () => {},
define: () => toDisposable(() => {}),
push: () => {},
patchLast: () => {},
removeLastMessages: () => {},
finishRestoringRecord: () => false,
buildResult: () => [],
buildReplay: () => [],
};
}
@ -94,12 +100,12 @@ export function stubContextMemory(): StubContextMemory {
/**
* Register the default collaborators consumed by `AgentContextMemoryService`
* (`IAgentWireRecordService`, `IAgentReplayBuilderService`) and an in-memory `IAgentContextMemoryService`.
* (`IAgentWireRecordService`, `IAgentRecordService`) and an in-memory `IAgentContextMemoryService`.
* Tests that exercise the real `AgentContextMemoryService` should override
* `IAgentContextMemoryService` via `additionalServices`.
*/
export function registerContextMemoryServices(reg: ServiceRegistration): void {
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.defineInstance(IAgentReplayBuilderService, stubReplayBuilder());
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentContextMemoryService, stubContextMemory());
}

View file

@ -6,7 +6,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentGoalService, type AgentGoalService } from '#/agent/goal';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn';
import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
@ -95,7 +95,7 @@ describe('AgentGoalService', () => {
let context: IAgentContextMemoryService;
let goals: GoalServiceTestManager;
let records: PersistedWireRecord[];
let replayBuilder: IAgentReplayBuilderService;
let replayBuilder: IAgentRecordService;
let events: Array<{ readonly type: string; readonly snapshot?: GoalSnapshot | null; readonly change?: GoalChange }>;
let telemetry: TelemetryRecord[];
@ -110,7 +110,7 @@ describe('AgentGoalService', () => {
context = ctx.get(IAgentContextMemoryService);
goals = ctx.get(IAgentGoalService) as GoalServiceTestManager;
records = persistence.records;
replayBuilder = ctx.get(IAgentReplayBuilderService);
replayBuilder = ctx.get(IAgentRecordService);
const eventSink = ctx.get(IAgentEventSinkService);
eventSink.on((event) => {
if (event.type === 'goal.updated') events.push(event);
@ -428,7 +428,7 @@ describe('AgentGoalService', () => {
},
]);
expect(replayBuilder.buildResult()).toEqual([
expect(replayBuilder.buildReplay()).toEqual([
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ objective: 'work', status: 'active' }),
@ -478,7 +478,7 @@ describe('AgentGoalService', () => {
},
]);
expect(replayBuilder.buildResult().at(-1)).toMatchObject({
expect(replayBuilder.buildReplay().at(-1)).toMatchObject({
type: 'goal_updated',
snapshot: { status: 'paused', terminalReason: 'Paused after agent resume' },
change: {

View file

@ -129,10 +129,10 @@ import {
type InteractionResolution,
} from '#/session/interaction';
import {
IAgentReplayBuilderService,
AgentReplayBuilderService,
type ReplayBuilderServiceOptions,
} from '#/agent/replayBuilder';
AgentRecordService,
IAgentRecordService,
type RecordServiceOptions,
} from '#/agent/record';
import type { AgentAPI } from '#/agent/rpc/core-api';
import { IAgentSkillService } from '#/agent/skill/skill';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
@ -145,7 +145,6 @@ import type {
WireRecordRestoreOptions,
WireRecordRestoreResult,
} from '#/agent/wireRecord';
import { IAgentRecordService } from '#/agent/record';
import type { PathAccessOperation } from '#/session/workspaceContext';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
@ -623,8 +622,8 @@ export function goalServices(options: GoalServiceOptions): TestAgentServiceOverr
return agentService(IAgentGoalService, new SyncDescriptor(AgentGoalService, [options]));
}
export function replayServices(options: ReplayBuilderServiceOptions = {}): TestAgentServiceOverride {
return agentService(IAgentReplayBuilderService, new SyncDescriptor(AgentReplayBuilderService, [options]));
export function replayServices(options: RecordServiceOptions = {}): TestAgentServiceOverride {
return agentService(IAgentRecordService, new SyncDescriptor(AgentRecordService, [options]));
}
/**
@ -1024,7 +1023,6 @@ export class AgentTestContext {
reg.defineDescriptor(IAgentCronService, new SyncDescriptor(AgentCronService, [{}]));
reg.defineDescriptor(IAgentBackgroundService, new SyncDescriptor(AgentBackgroundService));
reg.defineDescriptor(IAgentMcpService, new SyncDescriptor(AgentMcpService, [{}]));
reg.defineDescriptor(IAgentReplayBuilderService, new SyncDescriptor(AgentReplayBuilderService, [{}]));
reg.defineDescriptor(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}]));
reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService));
reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService));

View file

@ -5,9 +5,9 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService } from '#/agent/record';
import { IAgentWireRecordService } from '#/agent/wireRecord';
import { stubReplayBuilder, stubWireRecord } from '../contextMemory/stubs';
import { stubRecord, stubWireRecord } from '../contextMemory/stubs';
function textMessage(role: ContextMessage['role'], text: string): ContextMessage {
return {
@ -37,7 +37,7 @@ describe('message history (IAgentContextMemoryService)', () => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IAgentReplayBuilderService, stubReplayBuilder());
ix.stub(IAgentRecordService, stubRecord());
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
});
afterEach(() => disposables.dispose());

View file

@ -1,10 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { DisposableStore, toDisposable } from '#/_base/di';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { createServices } from '#/_base/di/test';
import { OrderedHookSlot } from '#/hooks';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentWireRecordService } from '#/agent/wireRecord';
import type { WireRecord, WireRecordRestoredContext } from '#/agent/wireRecord';
import {
@ -13,7 +12,7 @@ import {
type AgentRecord,
} from '#/agent/record';
import type { AgentEvent } from '@moonshot-ai/protocol';
import type { AgentReplayRecord, AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
import type { AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
declare module '#/agent/record' {
interface AgentRecordMap {
@ -24,8 +23,6 @@ declare module '#/agent/record' {
interface StubHost {
readonly record: IAgentRecordService;
readonly wire: ReturnType<typeof createWireStub>;
readonly eventSink: ReturnType<typeof createEventSinkStub>;
readonly replay: ReturnType<typeof createReplayStub>;
readonly dispose: () => void;
}
@ -49,6 +46,7 @@ function createWireStub() {
restore: vi.fn(async () => ({}) as { warning?: string }),
flush: vi.fn(async () => {}),
close: vi.fn(async () => {}),
getRecords: vi.fn(() => []),
get restoring() {
return restoring;
},
@ -59,53 +57,31 @@ function createWireStub() {
};
}
function createEventSinkStub() {
const emitted: AgentEvent[] = [];
return {
emitted,
emit: vi.fn((event: AgentEvent) => emitted.push(event)),
on: vi.fn(() => toDisposable(() => {})),
};
}
function createReplayStub() {
const records: AgentReplayRecord[] = [];
return {
records,
push: vi.fn((record: AgentReplayRecordPayload) =>
records.push(record as unknown as AgentReplayRecord),
),
buildResult: vi.fn(() => records),
captureLiveRecords: false,
postRestoring: false,
};
}
function createHost(): StubHost {
function createHost(captureLiveRecords = false): StubHost {
const wire = createWireStub();
const eventSink = createEventSinkStub();
const replay = createReplayStub();
const disposables = new DisposableStore();
const services = createServices(disposables, {
const ix = createServices(disposables, {
additionalServices: (reg) => {
reg.definePartialInstance(IAgentWireRecordService, wire);
reg.definePartialInstance(IAgentEventSinkService, eventSink);
reg.definePartialInstance(IAgentReplayBuilderService, replay);
reg.define(IAgentRecordService, AgentRecordService);
},
});
// Seed the leading static `options` argument (range) so createInstance does
// not warn about a static/service-dependency conflict.
ix.set(IAgentRecordService, new SyncDescriptor(AgentRecordService, [{}]));
const record = ix.get(IAgentRecordService);
record.captureLiveRecords = captureLiveRecords;
return {
record: services.get(IAgentRecordService),
record,
wire,
eventSink,
replay,
dispose: () => disposables.dispose(),
};
}
describe('AgentRecordService facade', () => {
it('append fans out to durable + live + replay facets', () => {
const host = createHost();
const host = createHost(true);
const live: AgentEvent[] = [];
host.record.on((event) => live.push(event));
host.record.define('test.fact', {
toLive: (r) => ({ type: 'test.live', value: r.value }) as unknown as AgentEvent,
toReplay: (r) =>
@ -117,36 +93,51 @@ describe('AgentRecordService facade', () => {
expect(host.wire.append).toHaveBeenCalledWith(
expect.objectContaining({ type: 'test.fact', value: 42 }),
);
expect(host.eventSink.emitted).toContainEqual(
expect.objectContaining({ type: 'test.live', value: 42 }),
);
expect(host.replay.records).toContainEqual(
expect(live).toContainEqual(expect.objectContaining({ type: 'test.live', value: 42 }));
expect(host.record.buildReplay()).toContainEqual(
expect.objectContaining({ type: 'message', value: 42 }),
);
host.dispose();
});
it('append omits facets that are not declared', () => {
const host = createHost();
const host = createHost(true);
const live: AgentEvent[] = [];
host.record.on((event) => live.push(event));
host.record.define('test.fact', {});
host.record.append({ type: 'test.fact', value: 1 });
expect(host.wire.append).toHaveBeenCalledTimes(1);
expect(host.eventSink.emit).not.toHaveBeenCalled();
expect(host.replay.push).not.toHaveBeenCalled();
expect(live).toHaveLength(0);
expect(host.record.buildReplay()).toHaveLength(0);
host.dispose();
});
it('live append does not feed the replay buffer unless captureLiveRecords is set', () => {
const host = createHost(false);
host.record.define('test.fact', {
toReplay: (r) =>
({ type: 'message', value: r.value }) as unknown as AgentReplayRecordPayload,
});
host.record.append({ type: 'test.fact', value: 7 });
expect(host.wire.append).toHaveBeenCalledTimes(1);
expect(host.record.buildReplay()).toHaveLength(0);
host.dispose();
});
it('signal emits live only and never persists or captures replay', () => {
const host = createHost();
const host = createHost(true);
const live: AgentEvent[] = [];
host.record.on((event) => live.push(event));
host.record.signal({ type: 'test.delta', delta: 'x' } as unknown as AgentEvent);
expect(host.eventSink.emitted).toContainEqual(
expect.objectContaining({ type: 'test.delta', delta: 'x' }),
);
expect(live).toContainEqual(expect.objectContaining({ type: 'test.delta', delta: 'x' }));
expect(host.wire.append).not.toHaveBeenCalled();
expect(host.replay.push).not.toHaveBeenCalled();
expect(host.record.buildReplay()).toHaveLength(0);
host.dispose();
});
@ -155,7 +146,7 @@ describe('AgentRecordService facade', () => {
const resume = vi.fn();
host.record.define('test.fact', { resume });
expect(host.wire.register).toHaveBeenCalledWith('test.fact', expect.any(Function));
expect(host.wire.register).toHaveBeenCalledWith('test.fact', expect.any(Function), undefined);
const registered = host.wire.resumers.get('test.fact');
expect(registered).toBeDefined();
await registered?.({ type: 'test.fact', value: 7 });
@ -177,22 +168,30 @@ describe('AgentRecordService facade', () => {
stop: false,
});
expect(host.replay.records).toContainEqual(
expect.objectContaining({ type: 'message', value: 5 }),
expect(host.record.buildReplay()).toContainEqual(
expect.objectContaining({ type: 'message', value: 5, time: 123 }),
);
host.dispose();
});
it('on() delegates to the live event sink', () => {
it('suppresses live emission while restoring', () => {
const host = createHost();
const handler = vi.fn();
host.record.on(handler);
expect(host.eventSink.on).toHaveBeenCalledWith(handler);
const live: AgentEvent[] = [];
host.record.on((event) => live.push(event));
host.record.define('test.fact', {
toLive: (r) => ({ type: 'test.live', value: r.value }) as unknown as AgentEvent,
});
host.wire.setRestoring({ time: 1 });
host.record.append({ type: 'test.fact', value: 3 });
expect(host.wire.append).toHaveBeenCalledTimes(1);
expect(live).toHaveLength(0);
host.dispose();
});
it('dispose returned by define unregisters the resumer and facets', () => {
const host = createHost();
const host = createHost(true);
const subscription = host.record.define('test.fact', { resume: vi.fn() });
expect(host.wire.resumers.has('test.fact')).toBe(true);

View file

@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService } from '#/agent/record';
import { InMemorySkillCatalog, type SkillCatalog, type SkillDefinition } from '#/app/globalSkillCatalog';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import {
@ -282,7 +282,7 @@ describe('ToolManager SkillTool wire behavior', () => {
describe('ToolManager SkillTool restore behavior', () => {
let ctx: TestAgentContext;
let context: IAgentContextMemoryService;
let replay: IAgentReplayBuilderService;
let replay: IAgentRecordService;
let skills: InMemorySkillCatalog;
let emit: ReturnType<typeof vi.spyOn>;
let track: ReturnType<typeof vi.spyOn>;
@ -298,7 +298,7 @@ describe('ToolManager SkillTool restore behavior', () => {
);
context = ctx.get(IAgentContextMemoryService);
const events = ctx.get(IAgentEventSinkService);
replay = ctx.get(IAgentReplayBuilderService);
replay = ctx.get(IAgentRecordService);
emit = vi.spyOn(events, 'emit');
});
@ -351,7 +351,7 @@ describe('ToolManager SkillTool restore behavior', () => {
);
expect(track).not.toHaveBeenCalledWith('skill_invoked', expect.anything());
expect(context.get()).toMatchObject([message]);
expect(replay.buildResult()).toContainEqual(
expect(replay.buildReplay()).toContainEqual(
expect.objectContaining({
type: 'message',
message: expect.objectContaining({

View file

@ -18,7 +18,6 @@ import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { Turn } from '#/agent/turn';
import { IAgentWireRecordService } from '#/agent/wireRecord';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { AgentRecordService, IAgentRecordService } from '#/agent/record';
import { stubWireRecord } from '../contextMemory/stubs';
import { executeTool } from '../tools/fixtures/execute-tool';
@ -67,12 +66,6 @@ describe('AgentSkillService', () => {
clear: () => {},
});
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.definePartialInstance(IAgentReplayBuilderService, {
push: () => {},
buildResult: () => [],
captureLiveRecords: false,
postRestoring: false,
});
reg.define(IAgentRecordService, AgentRecordService);
reg.definePartialInstance(ITelemetryService, { track: () => {} });
reg.definePartialInstance(IAgentToolRegistryService, {
@ -170,12 +163,6 @@ describe('SkillTool', () => {
clear: () => {},
});
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.definePartialInstance(IAgentReplayBuilderService, {
push: () => {},
buildResult: () => [],
captureLiveRecords: false,
postRestoring: false,
});
reg.define(IAgentRecordService, AgentRecordService);
reg.definePartialInstance(ITelemetryService, { track: () => {} });
reg.definePartialInstance(IAgentToolRegistryService, {

View file

@ -5,7 +5,6 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { OrderedHookSlot } from '#/hooks';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { AgentRecordService, IAgentRecordService } from '#/agent/record';
import { IAgentUsageService, type UsageStatus } from '#/agent/usage';
import { AgentUsageService } from '#/agent/usage/usageService';
@ -243,12 +242,6 @@ function createUsageHarness(): {
},
on: () => toDisposable(() => {}),
});
reg.definePartialInstance(IAgentReplayBuilderService, {
push: () => {},
buildResult: () => [],
captureLiveRecords: false,
postRestoring: false,
});
reg.define(IAgentRecordService, AgentRecordService);
reg.define(IAgentUsageService, AgentUsageService);
},

View file

@ -5,12 +5,12 @@ import {
IAgentContextMemoryService,
IAgentContextSizeService,
IAgentFullCompactionService,
IAgentReplayBuilderService,
IAgentRecordService,
IAgentWireRecordService,
type ContextMessage,
type PersistedWireRecord,
} from '#/index';
import type { ReplayRangeOptions } from '#/agent/replayBuilder';
import type { ReplayRangeOptions } from '#/agent/record';
import {
InMemoryWireRecordPersistence,
createTestAgent,
@ -26,7 +26,7 @@ describe('AgentRecords persistence metadata', () => {
let expectResumeMatches: boolean;
let persistence: RecordingInMemoryWireRecordPersistence;
let records: IAgentWireRecordService;
let replay: IAgentReplayBuilderService;
let replay: IAgentRecordService;
beforeEach(() => {
expectResumeMatches = true;
@ -35,7 +35,7 @@ describe('AgentRecords persistence metadata', () => {
context = ctx.get(IAgentContextMemoryService);
contextSize = ctx.get(IAgentContextSizeService);
records = ctx.get(IAgentWireRecordService);
replay = ctx.get(IAgentReplayBuilderService);
replay = ctx.get(IAgentRecordService);
});
afterEach(async () => {
@ -237,7 +237,7 @@ describe('AgentRecords persistence metadata', () => {
await expect(ctx.restorePersisted()).resolves.toEqual({});
expect(context.get()).toHaveLength(0);
expect(replay.buildResult()).toEqual([
expect(replay.buildReplay()).toEqual([
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ goalId: 'g1', status: 'active' }),
@ -652,12 +652,12 @@ async function buildReplayFromPersistence(
replayServices(range === undefined ? {} : { range }),
);
const fullCompaction = ctx.get(IAgentFullCompactionService);
const replay = ctx.get(IAgentReplayBuilderService);
const replay = ctx.get(IAgentRecordService);
try {
const isCompacting = fullCompaction.isCompacting;
if (isCompacting) throw new Error('Unexpected active compaction before restore');
await ctx.restorePersisted({ rewriteMigratedRecords: false });
return replay.buildResult();
return replay.buildReplay();
} finally {
try {
await ctx.expectResumeMatches();

View file

@ -14,7 +14,7 @@ import { IBootstrapService } from '#/app/bootstrap';
import { IHostFileSystem, HostFileSystem } from '#/app/hostFs';
import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentRecordService } from '#/agent/record';
import {
AppendLogStore,
AGENT_WIRE_PROTOCOL_VERSION,
@ -29,7 +29,7 @@ import { FileStorageService } from '#/app/storage/fileStorageService';
import { InMemoryStorageService } from '#/app/storage/inMemoryStorageService';
import type { IStorageService } from '#/app/storage';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubReplayBuilder } from '../contextMemory/stubs';
import { stubRecord } from '../contextMemory/stubs';
const cleanups: string[] = [];
const disposables: DisposableStore[] = [];
@ -101,7 +101,7 @@ async function createWireHarness(): Promise<{
ix.stub(IBlobStorage, storage);
ix.stub(IBootstrapService, stubBootstrap(dir));
ix.stub(IHostFileSystem, new HostFileSystem());
ix.stub(IAgentReplayBuilderService, stubReplayBuilder());
ix.stub(IAgentRecordService, stubRecord());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentBlobStoreService, new SyncDescriptor(AgentBlobStoreService));
ix.set(IAgentWireRecordService, new SyncDescriptor(AgentWireRecordService, [{}]));

View file

@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
AGENT_WIRE_PROTOCOL_VERSION,
IAgentReplayBuilderService,
IAgentRecordService,
type PersistedWireRecord,
type PromptOrigin,
} from '#/index';
@ -483,7 +483,7 @@ describe('Agent resume', () => {
origin: { kind: 'compaction_summary' },
}),
]);
expect(ctx.get(IAgentReplayBuilderService).buildResult()).toEqual([
expect(ctx.get(IAgentRecordService).buildReplay()).toEqual([
expect.objectContaining({
type: 'message',
message: expect.objectContaining({
@ -519,7 +519,7 @@ describe('Agent resume', () => {
await ctx.restorePersisted();
expect(ctx.get(IAgentReplayBuilderService).buildResult()).toEqual([
expect(ctx.get(IAgentRecordService).buildReplay()).toEqual([
expect.objectContaining({
type: 'compaction',
result: 'cancelled',
@ -632,7 +632,7 @@ describe('Agent resume', () => {
await ctx.restorePersisted();
expect(ctx.get(IAgentReplayBuilderService).buildResult()).toContainEqual(
expect(ctx.get(IAgentRecordService).buildReplay()).toContainEqual(
expect.objectContaining({
type: 'message',
message: expect.objectContaining({
@ -733,7 +733,7 @@ describe('Agent resume', () => {
await ctx.restorePersisted();
expect(ctx.context.get()).toHaveLength(0);
expect(ctx.get(IAgentReplayBuilderService).buildResult()).toContainEqual(
expect(ctx.get(IAgentRecordService).buildReplay()).toContainEqual(
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({
@ -847,7 +847,7 @@ describe('Agent resume', () => {
expect(ctx.context.get()[0]?.role).toBe('user');
expect(ctx.context.get()[1]?.role).toBe('assistant');
const replay = ctx.get(IAgentReplayBuilderService).buildResult();
const replay = ctx.get(IAgentRecordService).buildReplay();
expect(replay).toHaveLength(2);
expect(replay[0]).toMatchObject({
type: 'message',