refactor(agent-core-v2): consolidate wire services (#1680)

This commit is contained in:
_Kerman 2026-07-14 19:51:29 +08:00 committed by GitHub
parent 9eff230f97
commit 26d499bca7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
135 changed files with 2697 additions and 3322 deletions

View file

@ -20,15 +20,15 @@ export function isSafeAgentId(id: string): boolean {
}
interface StateJson {
createdAt?: string;
updatedAt?: string;
createdAt?: string | number;
updatedAt?: string | number;
title?: string;
isCustomTitle?: boolean;
lastPrompt?: string;
// Agent metadata comes from an untrusted state.json (a corrupt or imported
// bundle may hold non-object entries like `{ "main": null }`), so the value
// type allows null and inventoryAgents skips anything that isn't an object.
agents?: Record<string, { homedir: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; swarmItem?: string } | null>;
agents?: Record<string, { type: 'main' | 'sub' | 'independent'; parentAgentId?: string | null; swarmItem?: string } | null>;
custom?: Record<string, unknown>;
}
@ -103,7 +103,7 @@ async function readImportedDetail(home: string, importId: string): Promise<Sessi
// `agents` map. When the inventory comes back empty, fall back to probing
// `agents/*` on disk so routes that require an agent (wire/context/…) still
// resolve `main`.
let agents = await inventoryAgents(sessionDir, state, true);
let agents = await inventoryAgents(sessionDir, state);
if (agents.length === 0) {
agents = await discoverAgentsFromDisk(sessionDir);
}
@ -254,7 +254,7 @@ async function readSessionIndex(home: string): Promise<Map<string, SessionIndexE
return out;
}
async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomedir = false): Promise<AgentInfo[]> {
async function inventoryAgents(sessionDir: string, state: StateJson): Promise<AgentInfo[]> {
const result: AgentInfo[] = [];
for (const [id, meta] of Object.entries(state.agents ?? {})) {
if (!isSafeAgentId(id)) continue;
@ -280,11 +280,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomed
result.push({
agentId: id,
type: meta.type,
parentAgentId: meta.parentAgentId,
// For imported bundles the persisted homedir is the exporting machine's
// absolute path; re-derive it from the local extraction so blob reads
// (which join homedir) resolve under the imported directory.
homedir: deriveHomedir ? join(sessionDir, 'agents', id) : meta.homedir,
parentAgentId: meta.parentAgentId ?? null,
homedir: join(sessionDir, 'agents', id),
wireExists: readable,
wireRecordCount: info.count,
wireProtocolVersion: info.protocolVersion,
@ -360,7 +357,8 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion:
return { count, protocolVersion };
}
function parseTs(input: string | undefined): number {
function parseTs(input: string | number | undefined): number {
if (typeof input === 'number') return Number.isFinite(input) ? input : 0;
if (!input) return 0;
const n = Date.parse(input);
return Number.isFinite(n) ? n : 0;

View file

@ -1,9 +1,8 @@
import { cp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { cp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
/** Copy a fixture session into a temp dir, rewriting state.json.agents.*.homedir
* to the real path so wire-reader / agent-tree can resolve them. */
/** Copy a fixture session into a temporary KIMI_CODE_HOME. */
export async function buildSessionFixture(name: string): Promise<{
home: string;
sessionDir: string;
@ -16,14 +15,6 @@ export async function buildSessionFixture(name: string): Promise<{
await mkdir(sessionsDir, { recursive: true });
await cp(src, sessionDir, { recursive: true });
// Rewrite homedir placeholders.
const statePath = join(sessionDir, 'state.json');
const state = JSON.parse(await readFile(statePath, 'utf8'));
for (const id of Object.keys(state.agents)) {
state.agents[id].homedir = join(sessionDir, 'agents', id);
}
await writeFile(statePath, JSON.stringify(state, null, 2));
// Write session_index.jsonl.
await writeFile(
join(home, 'session_index.jsonl'),

View file

@ -234,6 +234,53 @@ describe('session-store', () => {
expect(sub.parentAgentId).toBe('main');
});
it('ignores persisted agent homedirs and uses the standard paths', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const statePath = join(sessionDir, 'state.json');
const state = JSON.parse(await readFile(statePath, 'utf8'));
delete state.agents.main.parentAgentId;
await writeFile(statePath, JSON.stringify(state));
const detail = await readSessionDetail(home, 'session_fixture');
expect(
detail!.agents
.map(({ agentId, homedir, parentAgentId }) => ({ agentId, homedir, parentAgentId }))
.toSorted((a, b) => a.agentId.localeCompare(b.agentId)),
).toEqual([
{
agentId: 'agent-0',
homedir: join(sessionDir, 'agents', 'agent-0'),
parentAgentId: 'main',
},
{
agentId: 'main',
homedir: join(sessionDir, 'agents', 'main'),
parentAgentId: null,
},
]);
});
it('reads v2 epoch millisecond timestamps', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const statePath = join(sessionDir, 'state.json');
const state = JSON.parse(await readFile(statePath, 'utf8'));
state.createdAt = 1_784_012_345_678;
state.updatedAt = 1_784_023_456_789;
await writeFile(statePath, JSON.stringify(state));
const [summary] = await listSessions(home);
expect(summary!.createdAt).toBe(state.createdAt);
expect(summary!.updatedAt).toBe(state.updatedAt);
});
it('surfaces swarmItem from state.json onto AgentInfo (null when absent)', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;

View file

@ -94,10 +94,9 @@ const DOMAIN_LAYER = new Map([
['os/backends', 6],
// L2 — data & cross-cutting capabilities
['records', 2],
['wireRecord', 2],
// `wire` is the scope-agnostic Model/Op/Signal state-machine layer: it
// consumes `persistence/interface` (L1) and is consumed by the scope tiers,
// so it sits in L2 beside the other data/cross-cutting layers.
// `wire` owns the Agent-scoped replayable-state aggregate plus its pure
// Model/Op/record/migration language. It consumes only L1 infrastructure
// and same-layer blob storage, and is consumed by the scope tiers.
['wire', 2],
['blob', 2],
['file', 2],
@ -316,7 +315,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'cron>agentLifecycle',
'cron>sessionContext',
'todo>agentLifecycle',
'wireRecord>hooks',
// L3/L4 type-sharing: tool contract + execution hook contexts now live in
// `tool`; the remaining upward import is a `loop` error/event helper.
'contextMemory>agentTask',
@ -345,9 +343,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'btw>agentLifecycle',
'toolExecutor>loop',
'userTool>profile',
'wireRecord>contextMemory',
'wireRecord>loop',
'wireRecord>tool',
'hostFolderBrowser>os/backends',
'filestore>persistence/backends',
'process>os/backends',

View file

@ -2,15 +2,16 @@
* `activity` domain (L4) Agent / Session activity kernel contracts.
*
* Defines the authoritative activity state machines shared by the Agent and
* Session scopes. `IAgentActivityService` is the Agent-scope lane machine: it
* Session scopes. `IAgentActivityService` is the Agent-scope activity machine: it
* owns turn admission (`begin`/`tryBegin`), cancellation, background-activity
* registration and disposal settlement, and is the sole dispatcher of the
* `activityLane` wire Model (`activityOps`). `ISessionActivityKernel` is the
* registration, disposal settlement, and the live activity projection emitted
* as `agent.activity.updated`. `ISessionActivityKernel` is the
* Session-scope lifecycle lane + admission table that the Agent kernel consults
* synchronously on every `begin` (child-injects-parent), so admission stays
* atomic inside a single event-loop turn. The `ActivityLease` returned by
* `begin` carries the turn's `AbortSignal` and is the only path back to `idle`
* (`lease.end`). Multi-scope domain: `IAgentActivityService` bound at Agent
* `begin` carries the turn's `AbortSignal`; `lease.end` releases the active
* turn independently of the Agent lifecycle. Multi-scope domain:
* `IAgentActivityService` bound at Agent
* scope, `ISessionActivityKernel` bound at Session scope.
*/
@ -19,7 +20,7 @@ import type { IDisposable } from '#/_base/di/lifecycle';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import type { TurnEndReason } from '@moonshot-ai/protocol';
export type AgentLane = 'initializing' | 'idle' | 'turn' | 'disposing' | 'disposed';
export type AgentLifecycleState = 'initializing' | 'ready' | 'disposing' | 'disposed';
export interface BeginOptions {
readonly origin?: PromptOrigin;
@ -45,7 +46,7 @@ export interface BackgroundActivityRef {
export interface IAgentActivityService {
readonly _serviceBrand: undefined;
lane(): AgentLane;
isIdle(): boolean;
begin(kind: 'turn', opts?: BeginOptions): ActivityLease;
@ -144,9 +145,15 @@ export interface ActivityLastTurnState {
readonly at: number;
}
export interface AgentActivitySnapshot {
readonly lane: AgentLane;
export interface AgentActivityState {
readonly lifecycle: AgentLifecycleState;
readonly turn?: ActivityTurnState;
readonly lastTurn?: ActivityLastTurnState;
readonly background: readonly BackgroundActivityRef[];
}
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'agent.activity.updated': AgentActivityState & { readonly type: 'agent.activity.updated' };
}
}

View file

@ -1,87 +1,23 @@
/**
* `activity` domain (L4) wire Model (`LaneModel`) and the `activity.set_lane`
* Op that holds the Agent activity lane.
* `activity` domain (L4) Session activity lane wire state.
*
* The lane is a live-only Model (`persist: false`): nothing is persisted or
* replayed, so a resumed agent starts back at `idle`. The Agent kernel
* (`agentActivityService`) is the sole dispatcher of `setLane`; `apply` returns
* the SAME reference when the incoming state is unchanged under `laneEqual`
* (which ignores the `since` / `at` timestamps) so redundant dispatches do not
* flood subscribers. The Op derives no event here the outward snapshot event
* is emitted by the projector so there is a single event source (PR5). The
* initial lane is `idle` (fresh agents accept turns immediately); the
* half-replay window is gated at the Session kernel (`restoring`), not here.
* Consumed by the Agent-scope `agentActivityService` and (PR5) the projector.
* The Session kernel projects its live lane and active lease count into the
* non-persisted `SessionLaneModel`. Agent activity state is owned directly by
* `IAgentActivityService` and is not duplicated in wire state.
*/
import { z } from 'zod';
import { defineModel } from '#/wire/model';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import type { AgentLane, BackgroundActivityRef, SessionLane } from './activity';
export interface LaneTurnState {
readonly turnId: number;
readonly origin: PromptOrigin;
readonly ending: boolean;
readonly endingReason?: 'aborted' | 'max_steps' | 'error';
readonly since: number;
}
export interface LaneLastTurnState {
readonly turnId: number;
readonly reason: 'completed' | 'cancelled' | 'failed';
readonly at: number;
}
export interface LaneModelState {
readonly lane: AgentLane;
readonly turn?: LaneTurnState;
readonly lastTurn?: LaneLastTurnState;
readonly background: readonly BackgroundActivityRef[];
}
export const LaneModel = defineModel<LaneModelState>('activityLane', () => ({
lane: 'idle',
background: [],
}));
import type { SessionLane } from './activity';
declare module '#/wire/types' {
interface TransientOpMap {
'activity.set_lane': typeof setLane;
'activity.set_session_lane': typeof setSessionLane;
}
}
export const setLane = LaneModel.defineOp('activity.set_lane', {
schema: z.object({ next: z.custom<LaneModelState>() }),
persist: false,
apply: (s, p) => (laneEqual(s, p.next) ? s : p.next),
});
export function laneEqual(a: LaneModelState, b: LaneModelState): boolean {
if (a.lane !== b.lane) return false;
if (a.background.length !== b.background.length) return false;
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
if (a.turn !== undefined && b.turn !== undefined) {
if (
a.turn.turnId !== b.turn.turnId ||
a.turn.ending !== b.turn.ending ||
a.turn.endingReason !== b.turn.endingReason
) {
return false;
}
}
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
return false;
}
}
return true;
}
export interface SessionLaneModelState {
readonly lane: SessionLane;
readonly activeLeases: number;

View file

@ -1,15 +1,16 @@
/**
* `activity` domain (L4) `IAgentActivityService` implementation.
*
* Owns the Agent activity lane (`idle ⇄ turn(active|ending)`, plus `disposing`
* / `disposed`) and is the sole dispatcher of the `activityLane` wire Model
* (`activity.set_lane`). `begin('turn')` atomically consults the Session kernel
* Owns the Agent lifecycle (`initializing → ready → disposing → disposed`) and
* its independent active turn, then projects lifecycle, turn, stream, retry,
* approval, tool-call and background state onto `agent.activity.updated`.
* `begin('turn')` atomically consults the Session kernel
* (`ISessionActivityKernel.admitTurn`, child-injects-parent), reads the next
* turn id from the `turn` `TurnModel`, enters the turn lane and returns an
* turn id from the `turn` `TurnModel`, records the active turn and returns an
* `ActivityLease`; the lease's `AbortSignal` is the only cancellation channel,
* and `lease.end()` is the only path back to `idle`. Background activities
* (`registerBackground`) are tracked so disposal can abort and await them. The
* lane starts at `initializing` and is driven to `idle` by `markReady()` once
* lifecycle starts at `initializing` and is driven to `ready` by `markReady()` once
* the agent bootstrap (`agentLifecycle.create`) finishes; until then `begin`
* rejects with `activity.initializing`. The half-replay window on resume is
* gated by the Session kernel (`restoring`). Bound at Agent scope.
@ -19,25 +20,33 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { userCancellationReason } from '#/_base/utils/abort';
import { IEventBus } from '#/app/event/eventBus';
import { ErrorCodes, Error2 } from '#/errors';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { TurnModel } from '#/agent/loop/turnOps';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import type {
ActivityRetryState,
ActivityLease,
AgentLane,
ActivityLastTurnState,
ActivityTurnState,
AgentActivityState,
AgentLifecycleState,
ApprovalRef,
BackgroundActivityRef,
BeginOptions,
ToolCallRef,
TurnPhase,
} from './activity';
import { IAgentActivityService, ISessionActivityKernel } from './activity';
import { type LaneLastTurnState, setLane } from './activityOps';
let nextBackgroundId = 0;
type ActivityEndingReason = NonNullable<ActivityTurnState['endingReason']>;
interface BackgroundEntry {
readonly ref: BackgroundActivityRef;
readonly controller: AbortController;
@ -51,7 +60,7 @@ class LeaseImpl implements ActivityLease {
private readonly controller = new AbortController();
private _ending = false;
private _ended = false;
private _endingReason: 'aborted' | 'max_steps' | 'error' | undefined;
private _endingReason: ActivityEndingReason | undefined;
registration: IDisposable = Disposable.None;
constructor(
@ -72,7 +81,7 @@ class LeaseImpl implements ActivityLease {
return this._ending;
}
get endingReason(): 'aborted' | 'max_steps' | 'error' | undefined {
get endingReason(): ActivityEndingReason | undefined {
return this._endingReason;
}
@ -83,6 +92,12 @@ class LeaseImpl implements ActivityLease {
this.controller.abort(reason ?? userCancellationReason());
}
markInterrupted(reason: ActivityEndingReason): void {
if (this._ending || this._ended) return;
this._ending = true;
this._endingReason = reason;
}
end(outcome: 'completed' | 'cancelled' | 'failed', detail?: { error?: unknown }): void {
if (this._ended) return;
this._ended = true;
@ -96,44 +111,110 @@ class LeaseImpl implements ActivityLease {
export class AgentActivityService extends Disposable implements IAgentActivityService {
declare readonly _serviceBrand: undefined;
private _lane: AgentLane = 'initializing';
private _lifecycle: AgentLifecycleState = 'initializing';
private _step = 0;
private _phase: TurnPhase = 'running';
private _stream: 'assistant' | 'thinking' | 'tool_call' | undefined;
private _retry: ActivityRetryState | undefined;
private _currentState: AgentActivityState = { lifecycle: 'initializing', background: [] };
private activeLease: LeaseImpl | undefined;
private lastTurn: LaneLastTurnState | undefined;
private lastTurn: ActivityLastTurnState | undefined;
private readonly background = new Map<string, BackgroundEntry>();
private readonly pendingApprovals = new Map<string, ApprovalRef>();
private readonly activeToolCalls = new Map<string, ToolCallRef>();
private readonly settleWaiters: Array<() => void> = [];
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@ISessionActivityKernel private readonly sessionKernel: ISessionActivityKernel,
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this._register(
this.eventBus.subscribe('turn.step.started', (e) => this.onStepStarted(e.step)),
);
this._register(
this.eventBus.subscribe('assistant.delta', () => this.onDelta('assistant')),
);
this._register(
this.eventBus.subscribe('thinking.delta', () => this.onDelta('thinking')),
);
this._register(
this.eventBus.subscribe('tool.call.delta', () => this.onDelta('tool_call')),
);
this._register(
this.eventBus.subscribe('tool.call.started', (e) =>
this.onToolCallStarted(e.toolCallId, e.name),
),
);
this._register(
this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId)),
);
this._register(
this.eventBus.subscribe('turn.step.retrying', (e) => {
this._phase = 'retrying';
this._stream = undefined;
this._retry = {
failedAttempt: e.failedAttempt,
nextAttempt: e.nextAttempt,
maxAttempts: e.maxAttempts,
delayMs: e.delayMs,
errorName: e.errorName,
statusCode: e.statusCode,
};
this.publishActivity();
}),
);
this._register(
this.eventBus.subscribe('turn.step.completed', () => {
this.resetStepState();
this.publishActivity();
}),
);
this._register(
this.eventBus.subscribe('turn.step.interrupted', (e) =>
this.onStepInterrupted(e.turnId, e.reason),
),
);
this._register(this.eventBus.subscribe('turn.ended', () => this.resetTurnState()));
this._register(
this.eventBus.subscribe('permission.approval.requested', (e) =>
this.onApprovalRequested(e.toolCallId),
),
);
this._register(
this.eventBus.subscribe('permission.approval.resolved', (e) =>
this.onApprovalResolved(e.toolCallId),
),
);
}
lane(): AgentLane {
return this._lane;
isIdle(): boolean {
return this._lifecycle === 'ready' && this.activeLease === undefined;
}
begin(kind: 'turn', opts?: BeginOptions): ActivityLease {
if (kind !== 'turn') {
throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `Unsupported activity kind: ${String(kind)}`);
}
switch (this._lane) {
case 'turn':
throw new Error2(
ErrorCodes.ACTIVITY_AGENT_BUSY,
`Cannot begin a new turn while turn ${this.activeLease?.turnId ?? '?'} is active`,
{ details: { turnId: this.activeLease?.turnId } },
);
switch (this._lifecycle) {
case 'disposing':
throw new Error2(ErrorCodes.ACTIVITY_DISPOSING, 'Agent is disposing');
case 'disposed':
throw new Error2(ErrorCodes.ACTIVITY_DISPOSED, 'Agent is disposed');
case 'initializing':
throw new Error2(ErrorCodes.ACTIVITY_INITIALIZING, 'Agent is still restoring');
case 'idle':
case 'ready':
break;
}
if (this.activeLease !== undefined) {
throw new Error2(
ErrorCodes.ACTIVITY_AGENT_BUSY,
`Cannot begin a new turn while turn ${this.activeLease.turnId} is active`,
{ details: { turnId: this.activeLease.turnId } },
);
}
const turnId = opts?.turnId ?? this.wire.getModel(TurnModel).nextTurnId;
const origin = opts?.origin ?? USER_PROMPT_ORIGIN;
@ -141,8 +222,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
lease.registration = this.sessionKernel.admitTurn(this.scopeContext.agentId, lease);
this.activeLease = lease;
this._lane = 'turn';
this.publishLane();
this.publishActivity();
return lease;
}
@ -156,9 +236,9 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
}
markReady(): void {
if (this._lane !== 'initializing') return;
this._lane = 'idle';
this.publishLane();
if (this._lifecycle !== 'initializing') return;
this._lifecycle = 'ready';
this.publishActivity();
}
cancel(reason?: unknown): boolean {
@ -166,7 +246,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
if (lease === undefined) return false;
if (lease.ending) return true;
lease.markEnding(reason);
this.publishLane();
this.publishActivity();
return true;
}
@ -179,10 +259,10 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
signal: controller.signal,
};
this.background.set(id, { ref, controller });
this.publishLane();
this.publishActivity();
const dispose = (): void => {
if (this.background.delete(id)) {
this.publishLane();
this.publishActivity();
}
this.maybeSettle();
};
@ -190,20 +270,20 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
}
beginDisposal(): void {
if (this._lane === 'disposing' || this._lane === 'disposed') return;
this._lane = 'disposing';
if (this._lifecycle === 'disposing' || this._lifecycle === 'disposed') return;
this._lifecycle = 'disposing';
this.activeLease?.markEnding();
for (const entry of this.background.values()) {
entry.controller.abort();
}
this.publishLane();
this.publishActivity();
this.maybeSettle();
}
settled(): Promise<void> {
if (this._lane === 'disposed') return Promise.resolve();
if (this._lifecycle === 'disposed') return Promise.resolve();
if (
this._lane !== 'disposing' &&
this._lifecycle !== 'disposing' &&
this.activeLease === undefined &&
this.background.size === 0
) {
@ -224,48 +304,147 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
lease.registration.dispose();
lease.registration = Disposable.None;
this.lastTurn = { turnId: lease.turnId, reason: outcome, at: Date.now() };
if (this._lane === 'disposing') {
if (this._lifecycle === 'disposing') {
this.maybeSettle();
return;
}
this._lane = 'idle';
this.publishLane();
this.publishActivity();
this.maybeSettle();
}
private maybeSettle(): void {
if (this.activeLease !== undefined || this.background.size > 0) return;
if (this._lane === 'disposing') {
this._lane = 'disposed';
this.publishLane();
if (this._lifecycle === 'disposing') {
this._lifecycle = 'disposed';
this.publishActivity();
}
if (this.settleWaiters.length === 0) return;
const waiters = this.settleWaiters.splice(0);
for (const resolve of waiters) resolve();
}
private publishLane(): void {
const lease = this.activeLease;
this.wire.dispatch(
setLane({
next: {
lane: this._lane,
turn:
lease === undefined
? undefined
: {
turnId: lease.turnId,
origin: lease.origin,
ending: lease.ending,
endingReason: lease.endingReason,
since: lease.since,
},
lastTurn: this.lastTurn,
background: [...this.background.values()].map((entry) => entry.ref),
},
}),
);
private onStepStarted(step: number): void {
this._step = step;
this.resetStepState();
this.publishActivity();
}
private onStepInterrupted(turnId: number, reason: string): void {
if (reason !== 'aborted' && reason !== 'max_steps' && reason !== 'error') return;
const lease = this.activeLease;
if (lease === undefined || lease.turnId !== turnId) return;
lease.markInterrupted(reason);
this.publishActivity();
}
private onDelta(stream: 'assistant' | 'thinking' | 'tool_call'): void {
this._phase = 'streaming';
this._stream = stream;
this._retry = undefined;
this.publishActivity();
}
private onToolCallStarted(toolCallId: string, name: string): void {
this._phase = 'tool_call';
this._stream = undefined;
this._retry = undefined;
this.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() });
this.publishActivity();
}
private onToolResult(toolCallId: string): void {
this.activeToolCalls.delete(toolCallId);
this._phase = this.activeToolCalls.size === 0 ? 'running' : 'tool_call';
this._stream = undefined;
this._retry = undefined;
this.publishActivity();
}
private resetTurnState(): void {
this._step = 0;
this.resetStepState();
this.pendingApprovals.clear();
this.activeToolCalls.clear();
}
private onApprovalRequested(toolCallId: string): void {
this.pendingApprovals.set(toolCallId, {
approvalId: toolCallId,
toolCallId,
since: Date.now(),
});
this.publishActivity();
}
private onApprovalResolved(toolCallId: string): void {
this.pendingApprovals.delete(toolCallId);
this.publishActivity();
}
private resetStepState(): void {
this._phase = 'running';
this._stream = undefined;
this._retry = undefined;
}
private publishActivity(): void {
const lease = this.activeLease;
const turn =
lease === undefined
? undefined
: {
turnId: lease.turnId,
origin: lease.origin,
phase: this._phase,
stream: this._stream,
step: this._step,
ending: lease.ending,
endingReason: lease.endingReason,
retry: this._retry,
pendingApprovals: [...this.pendingApprovals.values()],
activeToolCalls: [...this.activeToolCalls.values()],
since: lease.since,
};
const state: AgentActivityState = {
lifecycle: this._lifecycle,
turn,
lastTurn: this.lastTurn,
background: [...this.background.values()].map((entry) => entry.ref),
};
if (activityEqual(this._currentState, state)) return;
this._currentState = state;
this.eventBus.publish({ type: 'agent.activity.updated', ...state });
}
}
function activityEqual(a: AgentActivityState, b: AgentActivityState): boolean {
if (a.lifecycle !== b.lifecycle) return false;
if (a.background.length !== b.background.length) return false;
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
if (a.turn !== undefined && b.turn !== undefined) {
const ta = a.turn;
const tb = b.turn;
if (
ta.turnId !== tb.turnId ||
ta.phase !== tb.phase ||
ta.stream !== tb.stream ||
ta.step !== tb.step ||
ta.ending !== tb.ending ||
ta.endingReason !== tb.endingReason ||
ta.pendingApprovals.length !== tb.pendingApprovals.length ||
ta.activeToolCalls.length !== tb.activeToolCalls.length
) {
return false;
}
if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false;
}
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
return false;
}
}
return true;
}
registerScopedService(

View file

@ -15,8 +15,7 @@ import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IEventBus } from '#/app/event/eventBus';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
IAgentContextInjectorService,
type ContextInjectionProvider,
@ -38,7 +37,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
@IAgentLoopService loopService: IAgentLoopService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentWireService wire: IWireService,
@IWireService wire: IWireService,
) {
super();
this._register(
@ -52,12 +51,17 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
this.isNewTurn = true;
}),
);
this._register(this.eventBus.subscribe('context.spliced', (e) => {
this.handleSplice(e);
}));
this._register(wire.onRestored(() => {
this.resyncPositions();
}));
this._register(
this.eventBus.subscribe('context.spliced', (e) => {
this.handleSplice(e);
}),
);
this._register(
wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => {
this.resyncPositions();
await next();
}),
);
}
register(

View file

@ -10,11 +10,11 @@
* changes the measured prefix `clear` resets it, `applyCompaction` adopts
* `tokensAfter`, and `undo` rebases it (to an estimate when the measured
* aggregate is truncated); `append` leaves the measured prefix untouched since
* new messages are the unmeasured tail (see `contextSizeService`). Every
* mutation still fires `onSpliced` from the live path only (replay rebuilds
* the Model silently and never invokes these methods), so existing subscribers
* (context-injector, task-notification) observe the same
* splice-shaped change events regardless of which Op was persisted. Messages
* new messages are the unmeasured tail (see `contextSizeService`).
* Splice-shaped mutations publish `context.spliced` from the live path only
* (replay rebuilds the Model silently and never invokes these methods), so
* existing subscribers observe the same change regardless of which Op was
* persisted. Messages
* are persisted without local ids the on-disk record matches v1's field set
* and public message ids are derived from the transcript index. Blob
* dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at
@ -27,9 +27,8 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { IEventBus } from '#/app/event/eventBus';
import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import type { Op } from '#/wire/op';
import type { IWireService } from '#/wire/wireService';
import {
IAgentContextMemoryService,
@ -66,7 +65,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
declare readonly _serviceBrand: undefined;
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
@ -86,6 +85,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
appendLoopEvent(event: LoopRecordedEvent): void {
this.wire.dispatch(contextAppendLoopEvent({ event }));
}
clear(): void {
const deleteCount = this.get().length;
if (deleteCount === 0) return;

View file

@ -36,7 +36,7 @@ import { z } from 'zod';
import type { ContentPart } from '#/app/llmProtocol/message';
import { defineModel, type PartsTransformer } from '#/wire/model';
import type { PersistedRecord } from '#/wire/wireService';
import type { WireRecord } from '#/wire/record';
import {
buildContextCompactionShape,
@ -70,9 +70,9 @@ async function dehydrateMessages(
}
async function dehydrateRecord(
record: PersistedRecord,
record: WireRecord,
transform: PartsTransformer,
): Promise<PersistedRecord> {
): Promise<WireRecord> {
if (record.type === 'context.append_message') {
const message = record['message'] as ContextMessage | undefined;
if (message === undefined) return record;

View file

@ -29,7 +29,7 @@
*/
import { type ContentPart, type ToolCall } from '#/app/llmProtocol/message';
import type { PersistedRecord } from '#/wire/wireService';
import type { WireRecord } from '#/wire/record';
import {
COMPACT_USER_MESSAGE_MAX_TOKENS,
@ -49,6 +49,11 @@ export interface ContextTranscript {
readonly foldedLength: number;
}
export interface ContextTranscriptReducer {
add(record: WireRecord): void;
result(): ContextTranscript;
}
interface MutableMessage {
id?: string;
role: ContextMessage['role'];
@ -64,7 +69,13 @@ interface MutableEntry {
time?: number;
}
export function reduceContextTranscript(records: Iterable<PersistedRecord>): ContextTranscript {
export function reduceContextTranscript(records: Iterable<WireRecord>): ContextTranscript {
const reducer = createContextTranscriptReducer();
for (const record of records) reducer.add(record);
return reducer.result();
}
export function createContextTranscriptReducer(): ContextTranscriptReducer {
const transcript: MutableEntry[] = [];
let foldedLength = 0;
let clearFloor = 0;
@ -176,7 +187,7 @@ export function reduceContextTranscript(records: Iterable<PersistedRecord>): Con
resetOpenState();
};
for (const record of records) {
const add = (record: WireRecord): void => {
switch (record.type) {
case 'context.append_message': {
const entry = toMutableEntry(record['message'] as ContextMessage, record.time);
@ -212,12 +223,15 @@ export function reduceContextTranscript(records: Iterable<PersistedRecord>): Con
default:
break;
}
}
};
return {
entries: transcript.map((e) => e.message),
times: transcript.map((e) => e.time),
foldedLength,
add,
result: () => ({
entries: transcript.map((e) => e.message),
times: transcript.map((e) => e.time),
foldedLength,
}),
};
}
@ -237,7 +251,7 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta
}
function recoverFoldedLength(
record: PersistedRecord,
record: WireRecord,
transcript: readonly MutableEntry[],
clearFloor: number,
foldedLength: number,
@ -258,7 +272,7 @@ function recoverFoldedLength(
return keptUserMessages.length + 1;
}
function readCompactionSummaryText(record: PersistedRecord): string {
function readCompactionSummaryText(record: WireRecord): string {
const summary = record['summary'];
if (typeof summary === 'string') return summary;
const contextSummary = record['contextSummary'];
@ -281,7 +295,7 @@ function textOfParts(content: readonly ContentPart[]): string {
return text;
}
function readNumber(record: PersistedRecord, key: string): number | undefined {
function readNumber(record: WireRecord, key: string): number | undefined {
const value = record[key];
return typeof value === 'number' ? value : undefined;
}

View file

@ -9,7 +9,7 @@
* the v2 live loop emits the same records (`LoopService`
* `ContextMemory.appendLoopEvent`), keeping the on-disk shape byte-compatible.
* This fold turns them into assistant / tool messages at live dispatch time
* and again when `WireService.replay` restores a session. Without it, replay
* and again when `WireService.restore` restores an Agent. Without it, restore
* would skip those records (no Op is registered for the type) and the restored
* `ContextModel` and every consumer built on it (`/messages`, `/snapshot`,
* live resume) would show only the user prompts.

View file

@ -26,8 +26,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import type { ContextMessage } from '#/agent/contextMemory/types';
import type { Message } from '#/app/llmProtocol/message';
import type { TokenUsage } from '#/app/llmProtocol/usage';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import { IAgentContextSizeService, type ContextSize } from './contextSize';
import { ContextSizeModel, contextSizeMeasured } from './contextSizeOps';
@ -39,7 +38,7 @@ export class AgentContextSizeService extends Disposable implements IAgentContext
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
) {
super();
}

View file

@ -24,15 +24,15 @@
* the in-flight worker promise stays OUT of the Model (live-only service
* members): none of it can be resumed, and a session never restores mid-flight.
* A `running` phase stranded by a crash is reset to `idle` by the service's
* `wire.onRestored` handler (mirroring `goal`'s post-replay normalization).
* `wire.hooks.onDidRestore` hook (mirroring `goal`'s post-replay normalization).
*
* The `compaction.*` events publish to `IEventBus` (`compaction.started` via the
* `begin` Op's `toEvent`; the rest directly from the service); they are
* declared here via interface-merge (`error` is already declared by `mcp`, so
* it is not re-declared). The `full_compaction.*` record shapes are registered in
* `PersistedOpMap` (`#/wire/types`, below) because the records still
* ride the per-agent `wire.jsonl` log read by `wireRecord.restore()` /
* `getRecords()`. Consumed by the Agent-scope `fullCompactionService`.
* ride the per-agent `wire.jsonl` journal restored by `IWireService`.
* Consumed by the Agent-scope `fullCompactionService`.
*/
import { z } from 'zod';

View file

@ -39,8 +39,7 @@ import { IEventBus } from '#/app/event/eventBus';
import type { CompactionFinishedEvent } from '#/app/telemetry/events';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors";
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import compactionInstructionTemplate from './compaction-instruction.md?raw';
import {
IAgentFullCompactionService,
@ -123,7 +122,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IInstantiationService private readonly instantiation: IInstantiationService,
@ISessionTodoService private readonly todo: ISessionTodoService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentActivityService private readonly activity: IAgentActivityService,
@ILogService private readonly log: ILogService,
@ -131,7 +130,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
) {
super();
this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax());
this._register(this.wire.onRestored(() => this.normalizeAfterReplay()));
this._register(
this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => {
this.normalizeAfterReplay();
await next();
}),
);
this._register(
this.eventBus.subscribe('turn.started', () => this.resetForTurn()),
);
@ -266,7 +270,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
if (history.length === 0) {
throw new Error2(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.');
}
if (source === 'manual' && this.activity.lane() !== 'idle') {
if (source === 'manual' && !this.activity.isIdle()) {
throw new Error2(
ErrorCodes.COMPACTION_UNABLE,
'Cannot compact while a turn is active. Wait for it to finish, then retry.',

View file

@ -14,8 +14,8 @@
* replay). Each `apply` returns the same reference when nothing changes so the
* wire's reference-equality gate stays quiet. The `goal.updated` fact is
* published live to `IEventBus` by the service (declared here via
* interface-merge); `wire.replay` rebuilds the Model silently and the
* service's `wire.onRestored`
* interface-merge); `wire.restore` rebuilds the Model silently and the
* service's `wire.hooks.onDidRestore`
* forces a replayed `active` goal back to `paused`. Consumed by the Agent-scope
* `goalService`.
*/

View file

@ -5,13 +5,14 @@
* `GoalModel` (`GoalState | null`) through the `goal.create` / `goal.update` /
* `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`,
* publishes `goal.updated` live to `IEventBus`, and forces a replayed `active`
* goal back to `paused` via `wire.onRestored`. The accumulated `wallClockMs`
* lives in the Model (set from each Op payload, never by `Date.now()` inside
* `apply`); the `wallClockResumedAt` cursor is a live-only field, reset on
* replay and (re)started on the live path. A `forked` wire Op clears the Model
* goal back to `paused` via `wire.hooks.onDidRestore`. The accumulated
* `wallClockMs` lives in the Model (set from each Op payload, never by
* `Date.now()` inside `apply`); the `wallClockResumedAt` cursor is a live-only
* field, reset on replay and (re)started on the live path. A `forked` wire Op
* clears the Model
* at a fork boundary; the `goal.*` payload shapes are registered in
* `PersistedOpMap` (`#/wire/types`) inside `goalOps` because they still ride
* the shared wire log read by `getRecords()` and replayed into the Model.
* the Agent wire journal restored into the Model.
* Injects reminders through
* `contextInjector`, drives continuation turns by enqueueing `newTurn`
* `StepRequest`s onto `loop` (the continuation message materializes when the
@ -52,9 +53,8 @@ import {
toKimiErrorPayload,
type KimiErrorPayload,
} from '#/errors';
import { IAgentWireService } from '#/wire/tokens';
import { defineDerivedModel } from '#/wire/model';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import { defineModel } from '#/wire/model';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentGoalService, type GoalReasonInput, type ResumeGoalInput } from './goal';
@ -157,20 +157,22 @@ interface PendingContinuation {
turnId?: number;
}
const GoalForkNoticeModel = defineDerivedModel<GoalForkNoticeState>(
const GoalForkNoticeModel = defineModel<GoalForkNoticeState>(
'goalForkNotice',
() => ({ goalPresent: false, reminderPending: false }),
{
'goal.create': (state) => ({ ...state, goalPresent: true }),
'goal.clear': (state) => ({ ...state, goalPresent: false }),
forked: (state) => ({
goalPresent: false,
reminderPending: state.goalPresent || state.reminderPending,
}),
'context.append_message': (state, payload: { message?: ContextMessage }) =>
state.reminderPending && isGoalForkClearedReminder(payload.message)
? { ...state, reminderPending: false }
: state,
reducers: {
'goal.create': (state) => ({ ...state, goalPresent: true }),
'goal.clear': (state) => ({ ...state, goalPresent: false }),
forked: (state) => ({
goalPresent: false,
reminderPending: state.goalPresent || state.reminderPending,
}),
'context.append_message': (state, payload: { message?: ContextMessage }) =>
state.reminderPending && isGoalForkClearedReminder(payload.message)
? { ...state, reminderPending: false }
: state,
},
},
);
@ -195,7 +197,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
private pendingContinuation?: PendingContinuation;
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@ -214,8 +216,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
dynamicInjector,
),
);
this._register(this.wire.attach(GoalForkNoticeModel));
this._register(this.wire.onRestored(() => this.normalizeAfterReplay()));
this._register(
this.wire.hooks.onDidRestore.register('goal', async (_ctx, next) => {
this.normalizeAfterReplay();
await next();
}),
);
this._register(
this.eventBus.subscribe('turn.started', (e) => this.handleTurnLaunched(e.turnId)),
);

View file

@ -61,9 +61,8 @@ import { applyCompletionBudget, resolveCompletionBudget } from '#/app/model/comp
import type { Protocol } from '#/app/protocol/protocol';
import type { ApiErrorEvent } from '#/app/telemetry/events';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import type { PayloadOf } from '#/wire/types';
import type { IWireService } from '#/wire/wireService';
import { THINKING_SECTION, type ThinkingConfig } from '#/agent/profile/configSection';
import { resolveThinkingKeep } from '#/agent/profile/thinking';
@ -143,7 +142,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
@IConfigService private readonly config: IConfigService,
@ILogService private readonly log: ILogService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IFaultInjectionService private readonly faultInjection: IFaultInjectionService,
) {}

View file

@ -66,8 +66,7 @@ import type {
TurnStartedEvent as TurnStartedTelemetryEvent,
} from '#/app/telemetry/events';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection';
import {
createMaxStepsExceededError,
@ -132,7 +131,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
@IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService,
@IConfigService private readonly config: IConfigService,
@IAgentActivityService private readonly activity: IAgentActivityService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
) {

View file

@ -24,8 +24,7 @@ import type { McpServerEntry } from './connection-manager';
import { IAgentMcpService } from './mcp';
import { qualifyMcpToolName } from './tool-naming';
import type { MCPClient, MCPToolDefinition } from './types';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
McpDiscoveryModel,
mcpToolsDiscovered,
@ -58,7 +57,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {
super();
@ -72,8 +71,12 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
},
),
);
this._register(this.wire.onRestored(() => this.flushPendingDiscoveries()));
this._register(this.wire.onEmission(() => this.flushPendingDiscoveries()));
this._register(
this.wire.hooks.onDidRestore.register('mcp', async (_ctx, next) => {
this.flushPendingDiscoveries();
await next();
}),
);
}
get oauthService() {

View file

@ -2,11 +2,10 @@
* `permissionMode` domain (L3) wire Model (`PermissionModeModel`) and the
* `permission.set_mode` Op (`setMode`) for the agent's permission mode.
*
* Declares the mode as a scalar `wire` Model (initial `manual`) plus the single
* Op that replaces it; `defineOp` registers the Op into the global registry at
* import, so `wire.dispatch(setMode({ mode }))` mutates the model and
* `wire.replay` rebuilds it from persisted records (skipping every other record
* type). Consumed by the Agent-scope `permissionModeService`.
* Declares the mode as a scalar `wire` Model (initial `manual`) plus a replay
* marker that distinguishes an explicit persisted mode from the default. The
* single Op replaces the mode and sets that marker. Consumed by the Agent-scope
* `permissionModeService` and session bootstrap.
*/
import { z } from 'zod';
@ -15,6 +14,11 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { defineModel } from '#/wire/model';
export const PermissionModeModel = defineModel<PermissionMode>('permissionMode', () => 'manual');
export const PermissionModeConfiguredModel = defineModel<boolean>(
'permissionMode.configured',
() => false,
{ reducers: { 'permission.set_mode': () => true } },
);
declare module '#/wire/types' {
interface PersistedOpMap {

View file

@ -4,9 +4,9 @@
* Holds the agent's permission mode (`manual` / `auto`) in the `wire`
* `PermissionModeModel`, mutating it only through the `permission.set_mode` Op
* (`wire.dispatch(setMode({ mode }))`) and reading it through `wire.getModel`.
* The `onDidChangeMode` event is driven by a `wire.subscribe` on that model
* (firing only on actual changes), and mode-aware reminders are registered
* through the permission-mode injection helper. Bound at Agent scope.
* `setMode` emits `onDidChangeMode` after an actual change, and mode-aware
* reminders are registered through the permission-mode injection helper. Bound
* at Agent scope.
*/
import type { PermissionMode } from '#/agent/permissionPolicy/types';
@ -16,10 +16,13 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { Emitter, type Event } from '#/_base/event';
import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode';
import { PermissionModeModel, setMode } from './permissionModeOps';
import {
PermissionModeConfiguredModel,
PermissionModeModel,
setMode,
} from './permissionModeOps';
export class AgentPermissionModeService extends Disposable implements IAgentPermissionModeService {
declare readonly _serviceBrand: undefined;
@ -28,16 +31,10 @@ export class AgentPermissionModeService extends Disposable implements IAgentPerm
readonly onDidChangeMode: Event<PermissionModeChangedContext> = this._onDidChangeMode.event;
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IInstantiationService instantiation: IInstantiationService,
) {
super();
this._register(
wire.subscribe(PermissionModeModel, (mode, previousMode) => {
if (mode === previousMode) return;
this._onDidChangeMode.fire({ mode, previousMode });
}),
);
this._register(instantiation.createInstance(PermissionModeInjection, this));
}
@ -46,7 +43,11 @@ export class AgentPermissionModeService extends Disposable implements IAgentPerm
}
setMode(mode: PermissionMode): void {
const previousMode = this.mode;
const changed = mode !== previousMode;
if (!changed && this.wire.getModel(PermissionModeConfiguredModel)) return;
this.wire.dispatch(setMode({ mode }));
if (changed) this._onDidChangeMode.fire({ mode, previousMode });
}
}

View file

@ -11,8 +11,7 @@
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
IAgentPermissionRulesService,
type PermissionApprovalResultRecord,
@ -27,7 +26,7 @@ import {
export class AgentPermissionRulesService implements IAgentPermissionRulesService {
declare readonly _serviceBrand: undefined;
constructor(@IAgentWireService private readonly wire: IWireService) {}
constructor(@IWireService private readonly wire: IWireService) {}
get rules(): readonly PermissionRule[] {
return [...this.wire.getModel(PermissionRulesModel).rules];

View file

@ -21,8 +21,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
IAgentPlanService,
type PlanData,
@ -43,13 +42,18 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
@IHostFileSystem private readonly hostFs: IHostFileSystem,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@ISessionContext private readonly sessionCtx: ISessionContext,
@IAgentScopeContext private readonly agentCtx: IAgentScopeContext,
) {
super();
this._register(this.wire.onRestored(() => this.restoreTelemetryMode()));
this._register(
this.wire.hooks.onDidRestore.register('plan', async (_ctx, next) => {
this.restoreTelemetryMode();
await next();
}),
);
this._register(new PlanModeInjection(dynamicInjector, this, this.context));
}

View file

@ -54,9 +54,8 @@ import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/
import type { WarningEvent } from '@moonshot-ai/protocol';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import type { PayloadOf } from '#/wire/types';
import type { IWireService } from '#/wire/wireService';
import { IEventBus } from '#/app/event/eventBus';
import { prepareSystemPromptContext } from './context';
import type {
@ -105,7 +104,7 @@ export class AgentProfileService implements IAgentProfileService {
private activeProfile: ResolvedAgentProfile | undefined;
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,

View file

@ -27,8 +27,7 @@ import type { ContentPart } from '#/app/llmProtocol/message';
import { IEventBus } from '#/app/event/eventBus';
import { ErrorCodes, Error2 } from '#/errors';
import { OrderedHookSlot } from '#/hooks';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
IAgentPromptService,
@ -73,7 +72,7 @@ export class AgentPromptService implements IAgentPromptService {
@IInstantiationService private readonly instantiation: IInstantiationService,
@IAgentLoopService private readonly loop: IAgentLoopService,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
) {
toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => {

View file

@ -1,107 +0,0 @@
/**
* `replayBuilder` domain `ReplayTimelineModel`, a derived wire model that
* folds heterogeneous Ops from multiple domains into a single ordered timeline.
*
* This is the v2 replacement for v1's imperative `ReplayBuilder` class: instead
* of each domain service pushing records into a mutable accumulator, the model
* declares which Op types it reduces and the wire engine folds them
* automatically during both `replay` (silent) and `dispatch` (live).
*
* The timeline entries are op-native: they carry the raw op payloads, not the
* v1 `AgentReplayRecordPayload` DTO shape. The projection to the SDK/edge DTO
* (e.g. computing `GoalSnapshot` from `GoalState`) is a read-time concern, not
* a reduce-time concern.
*/
import {
contextAppendMessage,
contextApplyCompaction,
} from '#/agent/contextMemory/contextOps';
import {
fullCompactionBegin,
fullCompactionCancel,
fullCompactionComplete,
} from '#/agent/fullCompaction/compactionOps';
import { clearGoal, createGoal, updateGoal } from '#/agent/goal/goalOps';
import { planModeCancel, planModeEnter, planModeExit } from '#/agent/plan/planOps';
import { configUpdate } from '#/agent/profile/profileOps';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { setMode } from '#/agent/permissionMode/permissionModeOps';
import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules';
import { recordApprovalResult } from '#/agent/permissionRules/permissionRulesOps';
import { type DerivedModelDef, defineDerivedModel } from '#/wire/model';
import type { ModelReducers, OpPayload, OpType, PayloadOf } from '#/wire/types';
type TimelineMapperMap = {
[K in OpType]?: (payload: OpPayload<K>) => unknown;
};
type TimelineEntry<M> = {
[K in keyof M]: M[K] extends (...args: never[]) => infer E ? E : never;
}[keyof M];
type ErasedTimelineMapper<E> = (payload: unknown) => E;
function defineDerivedTimeline<const M extends TimelineMapperMap>(
name: string,
mappers: M & Record<Exclude<keyof M, OpType>, never>,
): DerivedModelDef<readonly TimelineEntry<M>[]> {
type E = TimelineEntry<M>;
const entries = Object.entries(mappers) as [OpType, ErasedTimelineMapper<E>][];
const reducers = Object.fromEntries(
entries.map(
([opType, mapper]) =>
[opType, (state: readonly E[], payload: unknown) => [...state, mapper(payload)]] as const,
),
) as ModelReducers<readonly E[]>;
return defineDerivedModel(name, () => [], reducers);
}
export const ReplayTimelineModel = defineDerivedTimeline('agent.replayTimeline', {
[contextAppendMessage.type]: (p: PayloadOf<typeof contextAppendMessage>) =>
({ type: contextAppendMessage.type, payload: p }) as const,
[contextApplyCompaction.type]: (p: PayloadOf<typeof contextApplyCompaction>) =>
({ type: contextApplyCompaction.type, payload: p }) as const,
[fullCompactionBegin.type]: (p: PayloadOf<typeof fullCompactionBegin>) =>
({ type: fullCompactionBegin.type, payload: p }) as const,
[fullCompactionCancel.type]: () =>
({ type: fullCompactionCancel.type }) as const,
[fullCompactionComplete.type]: (p: PayloadOf<typeof fullCompactionComplete>) =>
({ type: fullCompactionComplete.type, payload: p }) as const,
[createGoal.type]: (p: PayloadOf<typeof createGoal>) =>
({ type: createGoal.type, payload: p }) as const,
[updateGoal.type]: (p: PayloadOf<typeof updateGoal>) =>
({ type: updateGoal.type, payload: p }) as const,
[clearGoal.type]: () =>
({ type: clearGoal.type }) as const,
[planModeEnter.type]: (p: PayloadOf<typeof planModeEnter>) =>
({ type: planModeEnter.type, payload: p }) as const,
[planModeCancel.type]: (p: PayloadOf<typeof planModeCancel>) =>
({ type: planModeCancel.type, payload: p }) as const,
[planModeExit.type]: (p: PayloadOf<typeof planModeExit>) =>
({ type: planModeExit.type, payload: p }) as const,
[configUpdate.type]: (p: PayloadOf<typeof configUpdate>) =>
({ type: configUpdate.type, payload: p }) as const,
[setMode.type]: (p: { mode: PermissionMode }) =>
({ type: setMode.type, payload: p }) as const,
[recordApprovalResult.type]: (p: PermissionApprovalResultRecord) =>
({ type: recordApprovalResult.type, payload: p }) as const,
});
type InferTimelineEntry<D> = D extends DerivedModelDef<readonly (infer E)[]> ? E : never;
export type ReplayTimelineEntry = InferTimelineEntry<typeof ReplayTimelineModel>;
export type ReplayTimeline = readonly ReplayTimelineEntry[];

View file

@ -1,86 +0,0 @@
/**
* `runtime` domain (L4) Agent-scope live phase contract.
*
* Defines the public contract of the agent's whole live phase: the `AgentPhase`
* discriminated union (each variant carries its own ancillary fields) and the
* `IAgentRuntimeService` used to read the current phase via `phase()`. The
* phase is the agent-level, fine-grained counterpart of the session-level
* `sessionActivity` status: it splits `running` into waiting / streaming /
* tool_call / retrying and adds `interrupted` / `ended`. Agent-scoped one
* instance per agent.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { TurnEndedEvent } from '@moonshot-ai/protocol';
export type AgentPhase =
| { readonly kind: 'idle' }
| {
readonly kind: 'running';
readonly turnId: number;
readonly step: number;
readonly stepId: string;
readonly since: number;
}
| {
readonly kind: 'streaming';
readonly turnId: number;
readonly step: number;
readonly stepId: string;
readonly stream: 'assistant' | 'thinking' | 'tool_call';
readonly toolCallId?: string;
readonly toolName?: string;
readonly since: number;
}
| {
readonly kind: 'tool_call';
readonly turnId: number;
readonly step: number;
readonly toolCallId: string;
readonly name: string;
readonly since: number;
}
| {
readonly kind: 'retrying';
readonly turnId: number;
readonly step: number;
readonly stepId: string;
readonly failedAttempt: number;
readonly nextAttempt: number;
readonly maxAttempts: number;
readonly delayMs: number;
readonly errorName?: string;
readonly statusCode?: number;
readonly since: number;
}
| {
readonly kind: 'awaiting_approval';
readonly turnId: number;
readonly step?: number;
readonly approval: unknown;
readonly since: number;
}
| {
readonly kind: 'interrupted';
readonly turnId: number;
readonly step?: number;
readonly reason: 'aborted' | 'max_steps' | 'error';
readonly message?: string;
readonly at: number;
}
| {
readonly kind: 'ended';
readonly turnId: number;
readonly reason: TurnEndedEvent['reason'];
readonly durationMs?: number;
readonly at: number;
};
export interface IAgentRuntimeService {
readonly _serviceBrand: undefined;
phase(): AgentPhase;
}
export const IAgentRuntimeService: ServiceIdentifier<IAgentRuntimeService> =
createDecorator<IAgentRuntimeService>('agentRuntimeService');

View file

@ -1,140 +0,0 @@
/**
* `runtime` domain (L4) wire Model (`RuntimeModel`) and the `runtime.set_phase`
* Op (`setRuntimePhase`) that holds the agent's whole live phase.
*
* Declares the phase as a single-field wire Model (`{ phase }`, initial
* `{ kind: 'idle' }`) plus one Op whose `apply` is a pure, edge-triggered
* replacement: it returns the SAME reference when the incoming phase is
* unchanged under `phaseEqual` (which ignores `since` / `at` timestamps), so the
* wire's reference-equality gate stays quiet and high-frequency deltas do not
* flood subscribers. The Op is live-only because `runtime.set_phase` is not a
* v1 record type: nothing is persisted or replayed, and resumed agents start
* back at `idle`. The `agent.status.updated` `phase` slice is derived from
* the Op's `toEvent` (published on `dispatch`, never on `replay`). Consumed
* by the Agent-scope `runtimeService`.
*/
import { z } from 'zod';
import { defineModel } from '#/wire/model';
import type { AgentPhase } from './runtime';
export interface RuntimeModelState {
readonly phase: AgentPhase;
}
export const RuntimeModel = defineModel<RuntimeModelState>('runtime', () => ({
phase: { kind: 'idle' },
}));
declare module '#/wire/types' {
interface TransientOpMap {
'runtime.set_phase': typeof setRuntimePhase;
'activity.set_snapshot': typeof setActivitySnapshot;
}
}
export const setRuntimePhase = RuntimeModel.defineOp('runtime.set_phase', {
schema: z.object({ phase: z.custom<AgentPhase>() }),
persist: false,
apply: (s, p) => (phaseEqual(s.phase, p.phase) ? s : { phase: p.phase }),
toEvent: (p) => ({ type: 'agent.status.updated' as const, phase: p.phase }),
});
export function phaseEqual(a: AgentPhase, b: AgentPhase): boolean {
if (a.kind !== b.kind) return false;
switch (a.kind) {
case 'idle':
return true;
case 'running': {
const c = b as typeof a;
return a.turnId === c.turnId && a.step === c.step && a.stepId === c.stepId;
}
case 'streaming': {
const c = b as typeof a;
return (
a.turnId === c.turnId &&
a.step === c.step &&
a.stepId === c.stepId &&
a.stream === c.stream &&
a.toolCallId === c.toolCallId
);
}
case 'tool_call': {
const c = b as typeof a;
return a.turnId === c.turnId && a.toolCallId === c.toolCallId;
}
case 'retrying': {
const c = b as typeof a;
return (
a.turnId === c.turnId &&
a.step === c.step &&
a.failedAttempt === c.failedAttempt &&
a.nextAttempt === c.nextAttempt
);
}
case 'awaiting_approval': {
const c = b as typeof a;
return a.turnId === c.turnId;
}
case 'interrupted': {
const c = b as typeof a;
return a.turnId === c.turnId && a.reason === c.reason;
}
case 'ended': {
const c = b as typeof a;
return a.turnId === c.turnId && a.reason === c.reason;
}
}
}
import type { AgentActivitySnapshot } from '#/activity/activity';
export const ActivityModel = defineModel<AgentActivitySnapshot>('activity', () => ({
lane: 'idle',
background: [],
}));
export const setActivitySnapshot = ActivityModel.defineOp('activity.set_snapshot', {
schema: z.object({ next: z.custom<AgentActivitySnapshot>() }),
persist: false,
apply: (s, p) => (snapshotEqual(s, p.next) ? s : p.next),
toEvent: (p) => ({ type: 'agent.activity.updated' as const, ...p.next }),
});
export function snapshotEqual(a: AgentActivitySnapshot, b: AgentActivitySnapshot): boolean {
if (a.lane !== b.lane) return false;
if (a.background.length !== b.background.length) return false;
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
if (a.turn !== undefined && b.turn !== undefined) {
const ta = a.turn;
const tb = b.turn;
if (
ta.turnId !== tb.turnId ||
ta.phase !== tb.phase ||
ta.stream !== tb.stream ||
ta.step !== tb.step ||
ta.ending !== tb.ending ||
ta.endingReason !== tb.endingReason ||
ta.pendingApprovals.length !== tb.pendingApprovals.length ||
ta.activeToolCalls.length !== tb.activeToolCalls.length
) {
return false;
}
if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false;
}
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
return false;
}
}
return true;
}
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'agent.activity.updated': AgentActivitySnapshot & { readonly type: 'agent.activity.updated' };
}
}

View file

@ -1,345 +0,0 @@
/**
* `runtime` domain (L4) `IAgentRuntimeService` implementation and the Agent
* activity projector.
*
* Folds the agent's live activity into a structured `AgentActivitySnapshot`
* (`ActivityModel`, mutated only through the `activity.set_snapshot` Op) and a
* legacy `AgentPhase` (`RuntimeModel`, through `runtime.set_phase`). Inputs:
* the `activity` kernel's `LaneModel` (authoritative lane / turn / lastTurn /
* background) plus the existing `IEventBus` facts (step / stream / retry /
* approval / tool-call). The snapshot adds a pending-approval SET and an
* active-tool-call SET (keyed by id), so a parallel approval resolve no longer
* drops the still-waiting ones ( d) and parallel tool calls are all
* visible. Subscriptions are edge-triggered: `publishSnapshot` only dispatches
* when `snapshotEqual` says it changed. Live-only `wire.replay` stays silent
* and resumes into `idle`. Bound at Agent scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IEventBus } from '#/app/event/eventBus';
import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService';
import type { TurnEndedEvent } from '@moonshot-ai/protocol';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import type {
ActivityRetryState,
AgentActivitySnapshot,
ApprovalRef,
ToolCallRef,
TurnPhase,
} from '#/activity/activity';
import { LaneModel } from '#/activity/activityOps';
import { type AgentPhase, IAgentRuntimeService } from './runtime';
import {
phaseEqual,
RuntimeModel,
setActivitySnapshot,
setRuntimePhase,
} from './runtimeOps';
interface TurnCursor {
readonly turnId: number;
readonly step: number;
readonly stepId: string;
}
export class AgentRuntimeService extends Disposable implements IAgentRuntimeService {
declare readonly _serviceBrand: undefined;
private cursor: TurnCursor = { turnId: -1, step: 0, stepId: '' };
private current: AgentPhase = { kind: 'idle' };
private priorForApproval: AgentPhase | undefined;
private subPhase: TurnPhase = 'running';
private subStream: 'assistant' | 'thinking' | 'tool_call' | undefined;
private subRetry: ActivityRetryState | undefined;
private readonly pendingApprovals = new Map<string, ApprovalRef>();
private readonly activeToolCalls = new Map<string, ToolCallRef>();
constructor(
@IAgentWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this._register(this.eventBus.subscribe('turn.started', (e) => this.onTurnStarted(e.turnId)));
this._register(
this.eventBus.subscribe('turn.step.started', (e) =>
this.onStepStarted(e.turnId, e.step, e.stepId ?? ''),
),
);
this._register(
this.eventBus.subscribe('assistant.delta', () => this.onDelta('assistant')),
);
this._register(
this.eventBus.subscribe('thinking.delta', () => this.onDelta('thinking')),
);
this._register(
this.eventBus.subscribe('tool.call.delta', (e) =>
this.onToolCallDelta(e.toolCallId, e.name),
),
);
this._register(
this.eventBus.subscribe('tool.call.started', (e) =>
this.onToolCallStarted(e.toolCallId, e.name),
),
);
this._register(
this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId)),
);
this._register(
this.eventBus.subscribe('turn.step.retrying', (e) => {
this.subPhase = 'retrying';
this.subStream = undefined;
this.subRetry = {
failedAttempt: e.failedAttempt,
nextAttempt: e.nextAttempt,
maxAttempts: e.maxAttempts,
delayMs: e.delayMs,
errorName: e.errorName,
statusCode: e.statusCode,
};
this.setPhase({
kind: 'retrying',
turnId: e.turnId,
step: e.step,
stepId: e.stepId ?? '',
failedAttempt: e.failedAttempt,
nextAttempt: e.nextAttempt,
maxAttempts: e.maxAttempts,
delayMs: e.delayMs,
errorName: e.errorName,
statusCode: e.statusCode,
since: Date.now(),
});
this.publishSnapshot();
}),
);
this._register(
this.eventBus.subscribe('turn.step.interrupted', (e) =>
this.setPhase({
kind: 'interrupted',
turnId: e.turnId,
step: e.step,
reason: e.reason as 'aborted' | 'max_steps' | 'error',
message: e.message,
at: Date.now(),
}),
),
);
this._register(
this.eventBus.subscribe('turn.step.completed', () => {
this.subPhase = 'running';
this.subStream = undefined;
this.subRetry = undefined;
this.setPhase(this.running());
this.publishSnapshot();
}),
);
this._register(
this.eventBus.subscribe('turn.ended', (e) =>
this.onTurnEnded(e.turnId, e.reason, e.durationMs),
),
);
this._register(
this.eventBus.subscribe('permission.approval.requested', (e) =>
this.onApprovalRequested(e),
),
);
this._register(
this.eventBus.subscribe('permission.approval.resolved', (e) =>
this.onApprovalResolved(e.toolCallId),
),
);
this._register(this.wire.subscribe(LaneModel, () => this.publishSnapshot()));
}
phase(): AgentPhase {
return this.wire.getModel(RuntimeModel).phase;
}
private onTurnStarted(turnId: number): void {
this.cursor = { turnId, step: 0, stepId: '' };
this.priorForApproval = undefined;
this.subPhase = 'running';
this.subStream = undefined;
this.subRetry = undefined;
this.pendingApprovals.clear();
this.activeToolCalls.clear();
this.setPhase(this.running());
this.publishSnapshot();
}
private onStepStarted(turnId: number, step: number, stepId: string): void {
this.cursor = { turnId, step, stepId };
this.subPhase = 'running';
this.subStream = undefined;
this.subRetry = undefined;
this.setPhase(this.running());
this.publishSnapshot();
}
private onDelta(stream: 'assistant' | 'thinking'): void {
this.subPhase = 'streaming';
this.subStream = stream;
this.subRetry = undefined;
this.setPhase({
kind: 'streaming',
turnId: this.cursor.turnId,
step: this.cursor.step,
stepId: this.cursor.stepId,
stream,
since: Date.now(),
});
this.publishSnapshot();
}
private onToolCallDelta(toolCallId: string, name: string | undefined): void {
this.subPhase = 'streaming';
this.subStream = 'tool_call';
this.subRetry = undefined;
this.setPhase({
kind: 'streaming',
turnId: this.cursor.turnId,
step: this.cursor.step,
stepId: this.cursor.stepId,
stream: 'tool_call',
toolCallId,
toolName: name,
since: Date.now(),
});
this.publishSnapshot();
}
private onToolCallStarted(toolCallId: string, name: string): void {
this.subPhase = 'tool_call';
this.subStream = undefined;
this.subRetry = undefined;
this.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() });
this.setPhase({
kind: 'tool_call',
turnId: this.cursor.turnId,
step: this.cursor.step,
toolCallId,
name,
since: Date.now(),
});
this.publishSnapshot();
}
private onToolResult(toolCallId: string): void {
this.activeToolCalls.delete(toolCallId);
this.subPhase = 'running';
this.subStream = undefined;
this.subRetry = undefined;
this.setPhase(this.running());
this.publishSnapshot();
}
private onTurnEnded(
turnId: number,
reason: TurnEndedEvent['reason'],
durationMs: number | undefined,
): void {
this.setPhase({ kind: 'ended', turnId, reason, durationMs, at: Date.now() });
this.cursor = { turnId: -1, step: 0, stepId: '' };
this.priorForApproval = undefined;
this.subPhase = 'running';
this.subStream = undefined;
this.subRetry = undefined;
this.pendingApprovals.clear();
this.activeToolCalls.clear();
this.publishSnapshot();
}
private onApprovalRequested(approval: PermissionApprovalRequestContext): void {
this.priorForApproval = this.current;
this.pendingApprovals.set(approval.toolCallId, {
approvalId: approval.toolCallId,
toolCallId: approval.toolCallId,
since: Date.now(),
});
this.setPhase({
kind: 'awaiting_approval',
turnId: approval.turnId,
step: this.cursor.step || undefined,
approval,
since: Date.now(),
});
this.publishSnapshot();
}
private onApprovalResolved(toolCallId: string): void {
this.pendingApprovals.delete(toolCallId);
const resume = this.priorForApproval;
this.priorForApproval = undefined;
if (this.pendingApprovals.size > 0) {
this.setPhase({
kind: 'awaiting_approval',
turnId: this.cursor.turnId,
step: this.cursor.step || undefined,
approval: undefined,
since: Date.now(),
});
} else if (resume !== undefined && resume.kind !== 'idle' && resume.kind !== 'ended') {
this.setPhase(resume);
} else {
this.setPhase(this.running());
}
this.publishSnapshot();
}
private running(): AgentPhase {
return {
kind: 'running',
turnId: this.cursor.turnId,
step: this.cursor.step,
stepId: this.cursor.stepId,
since: Date.now(),
};
}
private setPhase(phase: AgentPhase): void {
if (phaseEqual(this.current, phase)) return;
this.current = phase;
this.wire.dispatch(setRuntimePhase({ phase }));
}
private publishSnapshot(): void {
const lane = this.wire.getModel(LaneModel);
const turn =
lane.turn === undefined
? undefined
: {
turnId: lane.turn.turnId,
origin: lane.turn.origin,
phase: this.subPhase,
stream: this.subStream,
step: this.cursor.step,
ending: lane.turn.ending,
endingReason: lane.turn.endingReason,
retry: this.subRetry,
pendingApprovals: [...this.pendingApprovals.values()],
activeToolCalls: [...this.activeToolCalls.values()],
since: lane.turn.since,
};
const snapshot = {
lane: lane.lane,
turn,
lastTurn: lane.lastTurn,
background: lane.background,
};
this.wire.dispatch(
setActivitySnapshot({ next: snapshot as unknown as AgentActivitySnapshot }),
);
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentRuntimeService,
AgentRuntimeService,
InstantiationType.Eager,
'runtime',
);

View file

@ -26,8 +26,7 @@ import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCat
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { Turn } from '#/agent/loop/loop';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import { IAgentSkillService, type SkillActivationInput } from './skill';
import { skillActivate } from './skillOps';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
@ -38,7 +37,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
constructor(
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
@IAgentPromptService private readonly prompt: IAgentPromptService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@ISessionContext private readonly sessionContext: ISessionContext,
) {

View file

@ -21,8 +21,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
import { IAgentSwarmService, type SwarmModeTrigger } from './swarm';
@ -32,7 +31,7 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
declare readonly _serviceBrand: undefined;
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IEventBus private readonly eventBus: IEventBus,

View file

@ -13,7 +13,7 @@
* carries no non-determinism. The live `ManagedTask` (the running process, its
* `AbortController`, output ring, timers) stays OUT of the Model (live-only);
* the Model is the restore seed for `ghosts`, applied by the service's single
* `wire.onRestored` handler before disk load + reconcile. The Ops are
* `wire.hooks.onDidRestore` hook before disk load + reconcile. The Ops are
* live-only because task records are not v1 wire types; the durable registry
* lives in `AgentTaskPersistence` and is reconciled on resume. Consumed by the
* Agent-scope `taskService`.

View file

@ -9,10 +9,10 @@
* session-level task root without writing back to it, reads
* limits through `config`, records lifecycle and broadcasts through `wire`
* (`task.started` / `task.terminated` Ops into `TaskModel`, plus the matching
* signals), restores ghosts through a single `wire.onRestored` handler (wire
* replay -> disk load -> reconcile, in that order), delivers live terminal
* notifications by enqueueing `TaskNotificationStepRequest`s onto `loop` with
* `activeOrNewTurn` admission (mid-turn ones fold into the active turn's
* signals), restores ghosts through a single `wire.hooks.onDidRestore` hook
* (wire replay -> disk load -> reconcile, in that order), delivers live
* terminal notifications by enqueueing `TaskNotificationStepRequest`s onto
* `loop` with `activeOrNewTurn` admission (mid-turn ones fold into the active turn's
* following step; idle ones launch a fresh turn themselves, matching v1's
* `turn.steer`, so the model consumes the notification without waiting for
* the user), silently appends restored notifications through `contextMemory`,
@ -58,12 +58,8 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import {
IAgentWireRecordService,
type PersistedWireRecord,
} from '#/agent/wireRecord/wireRecord';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { defineModel } from '#/wire/model';
import { IWireService } from '#/wire/wire';
import {
IAgentTaskService,
type AgentTaskNotificationContext,
@ -108,6 +104,21 @@ interface AgentTaskNotificationBuildContext {
readonly notification: AgentTaskNotification;
}
const TaskNotificationDeliveryModel = defineModel<readonly string[]>(
'task.notificationDelivery',
() => [],
{
reducers: {
'context.append_message': (state, payload: { message?: unknown }) => {
const origin = taskOriginFromMessage(payload.message);
if (origin === undefined) return state;
const key = notificationKey(origin);
return state.includes(key) ? state : [...state, key];
},
},
},
);
interface ManagedTask {
readonly taskId: string;
readonly task: AgentTask | undefined;
@ -215,8 +226,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
@ISessionContext session: ISessionContext,
@IAgentScopeContext scopeContext: IAgentScopeContext,
@ITaskService private readonly taskService: ITaskService,
@IAgentWireRecordService wireRecord: IAgentWireRecordService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentContextInjectorService injector: IAgentContextInjectorService,
@IAgentLoopService private readonly loop: IAgentLoopService,
@ -234,11 +244,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
fallbackRoot,
);
this._register(
this.wire.onRestored(async () => {
for (const record of wireRecord.getRecords()) {
this.markDeliveredNotificationsFromRecord(record);
this.wire.hooks.onDidRestore.register('task', async (_ctx, next) => {
for (const key of this.wire.getModel(TaskNotificationDeliveryModel)) {
this.deliveredNotificationKeys.add(key);
}
await this.restoreAfterReplay();
await next();
}),
);
this._register(
@ -281,12 +292,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
}
}
private markDeliveredNotificationsFromRecord(record: PersistedWireRecord): void {
for (const origin of taskOriginsFromRecord(record)) {
this.markDeliveredNotification(origin);
}
}
registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string {
const detached = options.detached ?? true;
const timeoutMs = options.timeoutMs ?? task.timeoutMs;
@ -1214,21 +1219,10 @@ function notificationKey(origin: TaskNotificationOrigin): string {
return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`;
}
function taskOriginsFromRecord(record: PersistedWireRecord): readonly TaskNotificationOrigin[] {
const raw = record as {
readonly type: string;
readonly message?: unknown;
};
if (raw.type === 'context.append_message') {
return taskOriginFromMessage(raw.message);
}
return [];
}
function taskOriginFromMessage(message: unknown): readonly TaskNotificationOrigin[] {
if (typeof message !== 'object' || message === null) return [];
function taskOriginFromMessage(message: unknown): TaskNotificationOrigin | undefined {
if (typeof message !== 'object' || message === null) return undefined;
const origin = (message as { readonly origin?: unknown }).origin;
return isTaskOrigin(origin) ? [origin] : [];
return isTaskOrigin(origin) ? origin : undefined;
}
function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {

View file

@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import type { AgentPhase } from '@moonshot-ai/protocol';
import { addUsage, type TokenUsage } from '#/app/llmProtocol/usage';
import type { AgentPhase } from '#/agent/runtime/runtime';
import { defineModel } from '#/wire/model';
import type { UsageStatus } from './usage';

View file

@ -19,8 +19,7 @@ import { Emitter, type Event } from '#/_base/event';
import type { LLMRequestSource } from '#/agent/llmRequester/llmRequester';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import type { UsageRecordedContext, UsageStatus } from './usage';
import { IAgentUsageService } from './usage';
import {
@ -41,7 +40,7 @@ export class AgentUsageService extends Disposable implements IAgentUsageService
private currentTurn: TokenUsage | undefined;
constructor(
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus?: IEventBus,
) {
super();

View file

@ -13,8 +13,9 @@
* reference-equality gate stays quiet. The side effects `registry.register`
* and `profile.addActiveTool` (and the matching dispose / `removeActiveTool`)
* are NOT part of `apply`: they run after `wire.dispatch` on the live path and
* are re-derived from the rebuilt Model by `wire.onRestored` after replay, so a
* resumed agent re-registers exactly the tools the persisted ops describe.
* are re-derived from the rebuilt Model by `wire.hooks.onDidRestore` after
* restore, so a resumed agent re-registers exactly the tools the persisted ops
* describe.
* Consumed by the Agent-scope `userToolService`.
*/

View file

@ -7,8 +7,9 @@
* (`wire.dispatch(...)`). The live side effects `registry.register` +
* `profile.addActiveTool` (and the matching dispose / `removeActiveTool`) run
* after the dispatch, and are re-derived from the rebuilt Model by
* `wire.onRestored` after `wire.replay`, so a resumed agent re-registers exactly
* the tools the persisted ops describe without re-firing any live notification.
* `wire.hooks.onDidRestore` after `wire.restore`, so a resumed agent re-registers
* exactly the tools the persisted ops describe without re-firing any live
* notification.
* The restore re-registers into the tool registry only: the active-tool set is
* owned by the persisted `ActiveToolsModel`, so the ephemeral `addActiveTool`
* overlay is not rebuilt (it is live-only by design). The per-tool
@ -28,8 +29,7 @@ import type {
} from '#/tool/toolContract';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { ISessionInteractionService } from '#/session/interaction/interaction';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import { IAgentUserToolService, type UserToolRegistration } from './userTool';
import { registerUserTool, unregisterUserTool, UserToolModel } from './userToolOps';
@ -50,10 +50,15 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ISessionInteractionService private readonly interaction: ISessionInteractionService,
@IAgentWireService private readonly wire: IWireService,
@IWireService private readonly wire: IWireService,
) {
super();
this._register(this.wire.onRestored(() => this.restoreRegisteredTools()));
this._register(
this.wire.hooks.onDidRestore.register('user-tool', async (_ctx, next) => {
this.restoreRegisteredTools();
await next();
}),
);
}
list(): readonly UserToolRegistration[] {

View file

@ -1,44 +0,0 @@
/**
* `wireRecord` domain (L2), Agent scope the `IAgentWireService` binding.
*
* Thin Agent-scope adapter over the scope-agnostic `WireService`: derives the
* persistence addressing (`logScope` / `logKey`) from `IAgentScopeContext`
* instead of receiving it as constructor options, so no per-agent scope seed
* is required. `WireService` itself stays scope-agnostic; a future
* Session-scope wire binds the same way.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IEventBus } from '#/app/event/eventBus';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { WIRE_RECORD_FILENAME } from './wireRecordService';
export class AgentWireService extends WireService {
constructor(
@IAgentScopeContext scopeContext: IAgentScopeContext,
@IAppendLogStore log?: IAppendLogStore,
@IAgentBlobService blobService?: IAgentBlobService,
@IEventBus eventBus?: IEventBus,
) {
super(
{ logScope: scopeContext.scope(), logKey: WIRE_RECORD_FILENAME },
log,
blobService,
eventBus,
);
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentWireService,
AgentWireService,
InstantiationType.Eager,
'wireRecord',
);

View file

@ -1,13 +0,0 @@
/**
* `wireRecord` domain error codes record persistence failures.
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';
export const WireRecordErrors = {
codes: {
RECORDS_WRITE_FAILED: 'records.write_failed',
},
} as const satisfies ErrorDomain;
registerErrorDomain(WireRecordErrors);

View file

@ -1,43 +0,0 @@
/**
* `wireRecord` domain (L6) wire-log metadata envelope op.
*
* Declares a marker-only wire Model and the `metadata` Op whose flattened
* record carries the wire-protocol envelope (`protocol_version`, `created_at`)
* as the first record of each agent `wire.jsonl`. It is the only persisted
* record that opts out of the `time` stamp, matching v1. Defined through the
* low-level `wire` registry so `WireService` can persist the envelope through
* the same append path as every other Op. `metadataRecord()` is the single
* shared factory for the envelope restore-time healing and fork-time log
* copies both use it instead of hand-rolling the shape. Scope-agnostic.
*/
import { z } from 'zod';
import { defineModel } from '#/wire/model';
import {
AGENT_WIRE_PROTOCOL_VERSION,
} from '#/agent/wireRecord/migration/migration';
import type { WireRecordMetadata } from './wireRecord';
const MetadataModel = defineModel<null>('wire.metadata', () => null);
declare module '#/wire/types' {
interface PersistedOpMap {
metadata: typeof wireMetadata;
}
}
export const wireMetadata = MetadataModel.defineOp('metadata', {
schema: z.object({ protocol_version: z.string(), created_at: z.number() }),
stamp: false,
apply: (s) => s,
});
/** A fresh metadata envelope stamped at the current protocol version. */
export function metadataRecord(): WireRecordMetadata {
return {
type: 'metadata',
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: Date.now(),
};
}

View file

@ -1,49 +0,0 @@
/**
* `wireRecord` contract (L6) the persisted wire journal's public surface.
*
* Defines the on-disk record vocabulary (the `metadata` envelope and the
* migration records) and `IAgentWireRecordService`. `seal` starts a fresh log
* with the `metadata` envelope at agent creation (a no-op once any record
* exists) so released v1 builds whose replay hard-rejects a non-empty log
* lacking the envelope can read sessions on a shared `KIMI_CODE_HOME`;
* legacy envelope-less logs are healed by `restore`, never by `seal`. Bound
* at Agent scope.
*/
import { createDecorator } from '#/_base/di/instantiation';
import type { WireMigrationRecord } from '#/agent/wireRecord/migration/migration';
export * from '#/agent/wireRecord/migration/migration';
export interface WireRecordMetadata {
readonly type: 'metadata';
readonly protocol_version: string;
readonly created_at: number;
readonly time?: number;
}
export type PersistedWireRecord = WireRecordMetadata | WireMigrationRecord;
export interface WireRecordRestoreOptions {
readonly rewriteMigratedRecords?: boolean;
}
export interface WireRecordRestoreResult {
readonly warning?: string;
}
export interface IAgentWireRecordService {
readonly _serviceBrand: undefined;
seal(): Promise<void>;
getRecords(): readonly PersistedWireRecord[];
restore(
records?: readonly PersistedWireRecord[],
options?: WireRecordRestoreOptions,
): Promise<WireRecordRestoreResult>;
flush(): Promise<void>;
close(): Promise<void>;
}
export const IAgentWireRecordService = createDecorator<IAgentWireRecordService>('agentWireRecordService');

View file

@ -1,193 +0,0 @@
/**
* `wireRecord` domain (L2) `IAgentWireRecordService` implementation.
*
* Restores and retains the owning agent's wire journal, applies protocol
* migrations, rejects non-empty unversioned logs, and awaits durable atomic
* rewrites before restore completes. Seals fresh logs with the `metadata`
* envelope at creation (`seal`) so released v1 builds whose replay
* hard-rejects envelope-less logs can read sessions on a shared
* `KIMI_CODE_HOME`; legacy envelope-less logs are healed on `restore`.
* Tracks live records through `wire`, uses `agent/scopeContext` for storage
* addressing, and persists through the `appendLog` access-pattern store.
* Bound at Agent scope.
*/
import { relative } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import {
AGENT_WIRE_PROTOCOL_VERSION,
applyWireMigrations,
isNewerWireVersion,
resolveWireMigrations,
type WireMigration,
type WireMigrationRecord,
} from '#/agent/wireRecord/migration/migration';
import { metadataRecord } from './metadataOps';
import {
IAgentWireRecordService,
type PersistedWireRecord,
type WireRecordMetadata,
type WireRecordRestoreOptions,
type WireRecordRestoreResult,
} from './wireRecord';
export class AgentWireRecordService extends Disposable implements IAgentWireRecordService {
declare readonly _serviceBrand: undefined;
private readonly records: PersistedWireRecord[] = [];
private readonly wireScope: string;
constructor(
@IAgentScopeContext scopeContext: IAgentScopeContext,
@IAppendLogStore private readonly log?: IAppendLogStore,
@IAgentWireService private readonly wire?: IWireService,
) {
super();
this.wireScope = scopeContext.scope();
if (this.log !== undefined) {
this._register(this.log.acquire(this.wireScope, WIRE_RECORD_FILENAME));
}
if (wire !== undefined) {
this._register(
wire.onEmission((emission) => {
if (emission.type === 'record' && emission.record.type !== 'metadata') {
this.records.push(emission.record as PersistedWireRecord);
}
}),
);
}
}
getRecords(): readonly PersistedWireRecord[] {
return [...this.records];
}
async seal(): Promise<void> {
if (this.log === undefined) return;
if (await hasAnyRecord(this.log, this.wireScope, WIRE_RECORD_FILENAME)) return;
this.log.append(this.wireScope, WIRE_RECORD_FILENAME, metadataRecord(), {
onError: onUnexpectedError,
});
}
async restore(
records?: readonly PersistedWireRecord[],
options: WireRecordRestoreOptions = {},
): Promise<WireRecordRestoreResult> {
const fromPersistence = records === undefined;
const source =
records ??
(this.log !== undefined
? this.log.read<PersistedWireRecord>(this.wireScope, WIRE_RECORD_FILENAME)
: undefined);
if (source === undefined) {
return {};
}
const rewriteMigratedRecords =
fromPersistence && (options.rewriteMigratedRecords ?? true);
const restoredRecords: PersistedWireRecord[] | undefined =
rewriteMigratedRecords ? [] : undefined;
let migrations: readonly WireMigration[] = [];
let shouldRewrite = false;
let warning: string | undefined;
const collected: PersistedWireRecord[] = [];
for await (const record of source) {
collected.push(record);
}
let sourceRecords = collected;
const firstRecord = sourceRecords[0];
if (firstRecord !== undefined) {
if (firstRecord.type !== 'metadata') {
// Envelope-less log: a fresh agent (creation no longer seals the log)
// or a pre-envelope legacy log. Heal it in place: synthesize the
// envelope at the current protocol version — records written by
// current builds need no migration — and rewrite so the invariant
// holds from now on.
sourceRecords = [metadataRecord(), ...sourceRecords];
shouldRewrite = fromPersistence;
} else {
if (!isWireRecordMetadata(firstRecord)) {
throw new Error('WireRecord restore expected metadata protocol_version');
}
const readVersion = firstRecord.protocol_version;
if (isNewerWireVersion(readVersion)) {
warning = `Session wire protocol version ${readVersion} is newer than the current version ${AGENT_WIRE_PROTOCOL_VERSION}. Records will be restored without migration.`;
shouldRewrite = false;
} else {
migrations = resolveWireMigrations(readVersion);
shouldRewrite = readVersion !== AGENT_WIRE_PROTOCOL_VERSION;
}
}
}
const migratedRecords = applyWireMigrations(
sourceRecords as WireMigrationRecord[],
migrations,
) as PersistedWireRecord[];
for (let migratedRecord of migratedRecords) {
if (migratedRecord.type === 'metadata') {
migratedRecord = {
...migratedRecord,
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
};
}
restoredRecords?.push(migratedRecord);
if (migratedRecord.type === 'metadata') continue;
this.records.push(migratedRecord);
}
if (shouldRewrite && restoredRecords !== undefined && this.log !== undefined) {
await this.log.rewrite(this.wireScope, WIRE_RECORD_FILENAME, restoredRecords);
}
return warning === undefined ? {} : { warning };
}
async flush(): Promise<void> {
await this.wire?.flush();
await this.log?.flush();
}
async close(): Promise<void> {
await this.log?.close();
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentWireRecordService,
AgentWireRecordService,
InstantiationType.Eager,
'wireRecord',
);
function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecordMetadata {
return record.type === 'metadata' && typeof record['protocol_version'] === 'string';
}
async function hasAnyRecord(log: IAppendLogStore, scope: string, key: string): Promise<boolean> {
for await (const record of log.read(scope, key)) {
void record;
return true;
}
return false;
}
export const WIRE_RECORD_FILENAME = 'wire.jsonl';
export function missingWireMetadataError(): Error {
return new Error('WireRecord restore expected metadata as the first record');
}
export function wireRecordScope(homedir: string, homeDir: string): string {
return relative(homeDir, homedir);
}

View file

@ -7,8 +7,7 @@
* `publish(event)` and consumers `subscribe(handler)` (all events) or
* `subscribe(type, handler)` (one type). It is bound at Agent scope one
* instance per agent so a subscription sees only that agent's events (the
* server fans out per agent and tags `agentId` / `sessionId`, exactly like the
* former `IAgentWireService.onEmission`). Process-global events (model catalog,
* server fans out per agent and tags `agentId` / `sessionId`). Process-global events (model catalog,
* session lifecycle, auth) stay on the legacy `IEventService` (`./event`),
* which is retained as the global channel; its payload type is re-exported from
* the barrel as `GlobalEvent`. Domains declare their agent-event shapes by

View file

@ -7,14 +7,12 @@
* The native `IAgentContextMemoryService` (Agent scope, serving `/api/v2`
* `messages:*`) holds the model's CURRENT, folded context and is NOT the full
* transcript: after a compaction it collapses into `[...keptUserMessages,
* compaction_summary]`. The full transcript is reduced from the main agent's
* in-memory record journal (`IAgentWireRecordService.getRecords()`), which
* `ISessionLifecycleService.resume` seeds from `wire.jsonl` and live dispatch
* then keeps current so neither a live nor a cold session is read back from
* disk here. The `ContextMessage → Message` projection is shared with the
* `snapshot` and `:undo` edges via `contextMemory/messageProjection`. Bound at
* App scope a stateless dispatcher that resolves the target session/agent per
* call.
* compaction_summary]`. The full transcript is reduced on demand by streaming
* the main agent's `wire.jsonl`; the service does not make every live Agent
* retain its raw journal in memory. The `ContextMessage → Message` projection
* is shared with the `snapshot` and `:undo` edges via
* `contextMemory/messageProjection`. Bound at App scope a stateless
* dispatcher that resolves the target session/agent per call.
*
* Error contract (mapped at the route layer):
* - `session.not_found` 40401

View file

@ -5,13 +5,10 @@
* its main agent), sources the transcript, and projects it into the v1 wire
* shape.
*
* History source is the main agent's in-memory record journal
* (`IAgentWireRecordService.getRecords()`), seeded from `wire.jsonl` by
* `ISessionLifecycleService.resume` and then kept current as live dispatch
* appends each record so a transcript read never re-reads the file. The
* journal is reduced by `reduceContextTranscript` (the same reducer v1's
* `MessageService` uses), which keeps the full history across compactions
* (inserting a summary marker instead of folding) unlike the live
* History is streamed from the main agent's append log after its pending wire
* writes are flushed. The journal is folded incrementally by the same
* transcript reducer v1's `MessageService` uses, keeping full history across
* compactions (inserting a summary marker instead of folding) unlike the live
* `IAgentContextMemoryService.get()`, whose folded context collapses into
* `[...keptUserMessages, compaction_summary]` and would lose the prefix.
* `foldedLength` is what the live history length WOULD be from the journal's
@ -28,17 +25,19 @@ import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import {
reduceContextTranscript,
createContextTranscriptReducer,
type ContextTranscript,
} from '#/agent/contextMemory/contextTranscript';
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { ErrorCodes, Error2 } from '#/errors';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
import type { PersistedRecord } from '#/wire/wireService';
import { IMessageLegacyService, type MessageListQuery } from './messageLegacy';
@ -51,6 +50,7 @@ export class MessageLegacyService implements IMessageLegacyService {
constructor(
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
@ISessionIndex private readonly index: ISessionIndex,
@IAppendLogStore private readonly appendLog: IAppendLogStore,
) {}
async list(sessionId: string, query: MessageListQuery): Promise<PageResponse<Message>> {
@ -105,7 +105,7 @@ export class MessageLegacyService implements IMessageLegacyService {
if (session === undefined) return [];
const agent = await ensureMainAgent(session);
const transcript = this.readTranscript(agent);
const transcript = await this.readTranscript(agent);
const contextMessages = agent.accessor.get(IAgentContextMemoryService).get();
const merged = mergeLiveTail(transcript, contextMessages);
const entries = await this.rehydrate(agent, merged.messages);
@ -143,11 +143,14 @@ export class MessageLegacyService implements IMessageLegacyService {
return changed ? out : messages;
}
private readTranscript(agent: IAgentScopeHandle): ContextTranscript {
const records = agent
.accessor.get(IAgentWireRecordService)
.getRecords() as readonly PersistedRecord[];
return reduceContextTranscript(records);
private async readTranscript(agent: IAgentScopeHandle): Promise<ContextTranscript> {
await agent.accessor.get(IWireService).flush();
const scope = agent.accessor.get(IAgentScopeContext).scope();
const reducer = createContextTranscriptReducer();
for await (const record of this.appendLog.read<WireRecord>(scope, AGENT_WIRE_RECORD_KEY)) {
reducer.add(record);
}
return reducer.result();
}
}

View file

@ -6,7 +6,7 @@
* version facts, and wire-log activity timestamps discovered during export.
*/
import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord/wireRecord';
import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration';
import type {
ExportSessionManifest,
@ -14,8 +14,6 @@ import type {
} from './sessionExport';
import type { SessionWireScan } from './wire-scan';
export const WIRE_PROTOCOL_VERSION = AGENT_WIRE_PROTOCOL_VERSION;
export interface ExportSessionManifestSummary {
readonly id: string;
readonly title?: string | undefined;

View file

@ -12,7 +12,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import { resolveGlobalLogPath } from '#/_base/log/logConfig';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IWireService } from '#/wire/wire';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
@ -126,7 +126,7 @@ export class SessionExportService implements ISessionExportService {
const agents = handle.accessor.get(IAgentLifecycleService);
for (const agent of agents.list()) {
await this.warnIfFails('export agent wire flush failed', () =>
agent.accessor.get(IAgentWireRecordService).flush(),
agent.accessor.get(IWireService).flush(),
);
}

View file

@ -13,8 +13,8 @@
* roots are remembered through `workspaceRegistry`. On create / fork the
* session is also appended to the shared `session_index.jsonl` so v1 clients
* (TUI, export) can discover sessions created by the v2 engine. Fork flushes
* live agent logs and rejects non-empty logs without a protocol metadata
* envelope instead of stamping legacy data as current.
* live Agent wire journals, normalizes a missing protocol envelope, and
* appends the fork boundary before restoring the target Agent.
*/
import { randomUUID } from 'node:crypto';
@ -35,15 +35,8 @@ import { unwrapErrorCause } from '#/_base/errors/errors';
import { Emitter, type Event } from '#/_base/event';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { ISessionActivityKernel } from '#/activity/activity';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { DEFAULT_PLAN_MODE_SECTION } from '#/agent/plan/configSection';
import { IAgentPlanService } from '#/agent/plan/plan';
import {
IAgentWireRecordService,
type PersistedWireRecord,
} from '#/agent/wireRecord/wireRecord';
import { metadataRecord } from '#/agent/wireRecord/metadataOps';
import { WIRE_RECORD_FILENAME, wireRecordScope } from '#/agent/wireRecord/wireRecordService';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
@ -74,8 +67,12 @@ import { ISessionCronService } from '#/session/cron/sessionCronService';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
AGENT_WIRE_RECORD_KEY,
createWireMetadataRecord,
type WireRecord,
} from '#/wire/record';
import {
type CreateChildSessionOptions,
@ -247,15 +244,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
});
const agents = handle.accessor.get(IAgentLifecycleService);
if (agents.get(MAIN_AGENT_ID) === undefined) {
const main = await agents.create({ agentId: MAIN_AGENT_ID });
// Resolve context memory BEFORE restoring so its reducers are registered;
// otherwise the wire replay applies context records into a void and the
// restored transcript never lands in context memory.
main.accessor.get(IAgentContextMemoryService);
const mainWireRecord = main.accessor.get(IAgentWireRecordService);
await mainWireRecord.restore();
const records = mainWireRecord.getRecords() as readonly PersistedRecord[];
await main.accessor.get(IAgentWireService).replay(...records);
await agents.create({ agentId: MAIN_AGENT_ID });
}
await this.announceCreated({ sessionId, handle, source: 'resume' });
return handle;
@ -370,10 +359,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const sourceAgents = sourceMeta?.agents ?? {};
const agentIds = Object.keys(sourceAgents);
for (const agentId of agentIds) {
const sourceHomedir = sourceAgents[agentId]!.homedir;
await this.copyAgentWire({
sourceHandle,
sourceHomedir,
sourceWorkspaceId: workspaceId,
sourceSessionId: sourceId,
agentId,
targetWorkspaceId: targetCtx.workspaceId,
targetSessionId: targetCtx.sessionId,
@ -394,15 +383,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
for (const agentId of agentIds) {
const sourceAgent = sourceAgents[agentId]!;
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService);
await forkWireRecord.restore();
const forkRecords = forkWireRecord.getRecords() as readonly PersistedRecord[];
await agentHandle.accessor.get(IAgentWireService).replay(...forkRecords);
}
await this.appendSessionIndexEntry(targetId, workspace.root);
@ -459,7 +444,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
private async copyAgentWire(args: {
readonly sourceHandle: ISessionScopeHandle | undefined;
readonly sourceHomedir: string;
readonly sourceWorkspaceId: string;
readonly sourceSessionId: string;
readonly agentId: string;
readonly targetWorkspaceId: string;
readonly targetSessionId: string;
@ -469,34 +455,34 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
.get(IAgentLifecycleService)
.get(args.agentId);
if (agentHandle !== undefined) {
await agentHandle.accessor.get(IAgentWireRecordService).flush();
await agentHandle.accessor.get(IWireService).flush();
}
}
const records = await collect(
this.appendLogStore.read<PersistedWireRecord>(
wireRecordScope(args.sourceHomedir, this.bootstrap.homeDir),
WIRE_RECORD_FILENAME,
this.appendLogStore.read<WireRecord>(
this.bootstrap.agentScope(
args.sourceWorkspaceId,
args.sourceSessionId,
args.agentId,
),
AGENT_WIRE_RECORD_KEY,
),
);
// Keep the copied log well-formed for the target's first restore: prepend
// the metadata envelope when the source lacks one (restore() would heal it
// anyway, but the forked copy should be valid on its own).
if (records.length === 0) {
records.push(metadataRecord());
records.push(createWireMetadataRecord());
} else if (records[0]?.type !== 'metadata') {
records.unshift(metadataRecord());
records.unshift(createWireMetadataRecord());
}
records.push(forkedRecord());
const targetHomedir = this.bootstrap.agentHomedir(
args.targetWorkspaceId,
args.targetSessionId,
args.agentId,
);
await this.appendLogStore.rewrite(
wireRecordScope(targetHomedir, this.bootstrap.homeDir),
WIRE_RECORD_FILENAME,
this.bootstrap.agentScope(
args.targetWorkspaceId,
args.targetSessionId,
args.agentId,
),
AGENT_WIRE_RECORD_KEY,
records,
);
}
@ -520,7 +506,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
): Promise<void> {
for (const entry of entries) {
const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`;
if (rel === 'state.json' || rel === 'logs' || entry.name === WIRE_RECORD_FILENAME) {
if (rel === 'state.json' || rel === 'logs' || entry.name === AGENT_WIRE_RECORD_KEY) {
continue;
}
if (entry.isSymbolicLink === true) continue;
@ -597,8 +583,8 @@ function createSessionId(): string {
return `session_${randomUUID()}`;
}
function forkedRecord(): PersistedWireRecord {
return { type: 'forked', time: Date.now() } as PersistedWireRecord;
function forkedRecord(): WireRecord {
return { type: 'forked', time: Date.now() };
}
function forkCustomMetadata(

View file

@ -34,7 +34,6 @@ import { StorageErrors } from '#/persistence/interface/storage';
import { TerminalErrors } from '#/os/interface/terminalErrors';
import { UsageErrors } from '#/agent/usage/errors';
import { WireErrors } from '#/wire/errors';
import { WireRecordErrors } from '#/agent/wireRecord/errors';
export * from '#/_base/errors/codes';
export * from '#/_base/errors/errorMessage';
@ -67,7 +66,6 @@ export { StorageErrors } from '#/persistence/interface/storage';
export { TerminalErrors } from '#/os/interface/terminalErrors';
export { UsageErrors } from '#/agent/usage/errors';
export { WireErrors } from '#/wire/errors';
export { WireRecordErrors } from '#/agent/wireRecord/errors';
export const ErrorCodes = {
...CoreErrors.codes,
@ -97,5 +95,4 @@ export const ErrorCodes = {
...TerminalErrors.codes,
...UsageErrors.codes,
...WireErrors.codes,
...WireRecordErrors.codes,
} as const;

View file

@ -19,9 +19,10 @@ export * from '#/_base/log/logConfig';
export * from '#/_base/log/formatter';
export * from '#/_base/log/fileLog';
export * from '#/_base/log/logService';
export { IAgentWireService, ISessionWireService } from '#/wire/tokens';
export { type IWireService, type WireEmission } from '#/wire/wireService';
export { defineDerivedModel, type DerivedModelDef } from '#/wire/model';
export * from '#/wire/wire';
export * from '#/wire/wireService';
export * from '#/wire/record';
export * from '#/wire/migration/migration';
export * from '#/session/sessionLog/sessionLogService';
export * from '#/app/telemetry/telemetry';
export * from '#/app/telemetry/events';
@ -189,9 +190,6 @@ export * from '#/agent/swarm/swarm';
export * from '#/agent/swarm/swarmService';
export * from '#/agent/usage/usage';
export * from '#/agent/usage/usageService';
export * from '#/agent/runtime/runtime';
export * from '#/agent/runtime/runtimeOps';
export * from '#/agent/runtime/runtimeService';
export * from '#/agent/toolDedupe/toolDedupe';
export * from '#/agent/toolDedupe/toolDedupeService';
import '#/agent/toolSelect/flag';
@ -425,7 +423,6 @@ export * from '#/agent/prompt/promptService';
import '#/app/messageLegacy/errors';
export * from '#/app/messageLegacy/messageLegacy';
export * from '#/app/messageLegacy/messageLegacyService';
export * from '#/agent/replayBuilder/replayTimelineModel';
export * from '#/agent/replayBuilder/types';
export * from '#/agent/shellCommand/shellCommand';
export * from '#/agent/shellCommand/shellCommandService';
@ -464,7 +461,3 @@ export type { ToolContribution, ToolContributionOptions } from '#/agent/toolRegi
export * from '#/agent/userTool/userTool';
export * from '#/agent/userTool/userToolOps';
export * from '#/agent/userTool/userToolService';
export * from '#/agent/wireRecord/wireRecord';
export * from '#/agent/wireRecord/wireRecordService';
export * from '#/agent/wireRecord/agentWireService';
export * from '#/agent/wireRecord/metadataOps';

View file

@ -17,7 +17,7 @@
* It uses raw `node:fs` rather than `kaos`: the storage kernel needs direct
* control over append offsets, fsync, atomic rename and streaming, which the
* agent-execution-environment abstraction does not expose. Higher-level code
* (`wireRecord`, `blobStore`) goes through the Store / Storage interfaces above
* (wire journal, blob store) goes through the Store / Storage interfaces above
* this backend, never `node:fs` directly.
*/

View file

@ -34,6 +34,7 @@ import { IConfigService } from '#/app/config/config';
import { IEventBus } from '#/app/event/eventBus';
import { ErrorCodes, Error2 } from '#/errors';
import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection';
import { PermissionModeConfiguredModel } from '#/agent/permissionMode/permissionModeOps';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe';
import { IAgentTaskService } from '#/agent/task/task';
@ -49,14 +50,19 @@ import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentGoalService } from '#/agent/goal/goal';
import { IAgentPlanService } from '#/agent/plan/plan';
import { IAgentUserToolService } from '#/agent/userTool/userTool';
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry/builtinToolsRegistrar';
import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools';
import { IImageConfigBridge } from '#/agent/media/imageConfigBridge';
import { IAgentMcpService } from '#/agent/mcp/mcp';
import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks';
import { IAgentPluginService } from '#/agent/plugin/agentPlugin';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { ISessionInteractionService } from '#/session/interaction/interaction';
import { IWireService } from '#/wire/wire';
import {
type AgentListFilter,
type CreateAgentOptions,
@ -144,9 +150,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
private async doCreate(agentId: string, opts: CreateAgentOptions): Promise<IAgentScopeHandle> {
const mcpReady = this.sessionMcp.ensureMcpReady();
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
// Bootstrap computes it under the session dir, mirroring v1's
// `<sessionDir>/agents/<id>`; business code never assembles the path itself.
const agentHomedir = this.bootstrap.agentHomedir(
this.ctx.workspaceId,
this.ctx.sessionId,
@ -163,13 +166,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
agentId,
// The only per-agent seed: identity facts. Every other agent-scope
// service either derives its configuration from `IAgentScopeContext`
// (wire, wireRecord, blob) or resolves it through the scope tree (the
// (wire, blob) or resolves it through the scope tree (the
// session's shared MCP manager via `ISessionMcpService`).
{ extra: [[IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })]] },
) as IAgentScopeHandle;
this.handles.set(agentId, handle);
try {
await handle.accessor.get(IAgentWireRecordService).seal();
const wire = handle.accessor.get(IWireService);
await wire.seal();
await this.sessionMetadata.registerAgent(agentId, {
homedir: agentHomedir,
type: agentId === 'main' ? 'main' : 'sub',
@ -180,6 +184,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
this.onDidCreateEmitter.fire(handle);
this.igniteEagerServices(handle);
await mcpReady;
await wire.restore();
await this.bindBootstrap(handle, opts);
// Bootstrap (profile binding and the force-instantiated observer
// services) is complete: drive the activity kernel `initializing → idle`
@ -232,6 +237,13 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
handle.accessor.get(IAgentToolSelectAnnouncementsService);
handle.accessor.get(IAgentStepRetryService);
handle.accessor.get(IAgentLoopContinuationService);
handle.accessor.get(IAgentContextMemoryService);
handle.accessor.get(IAgentContextInjectorService);
handle.accessor.get(IAgentGoalService);
handle.accessor.get(IAgentPlanService);
handle.accessor.get(IAgentTaskService);
handle.accessor.get(IAgentUserToolService);
handle.accessor.get(IAgentFullCompactionService);
}
private async bindBootstrap(
@ -241,12 +253,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
if (opts.binding !== undefined) {
await handle.accessor.get(IAgentProfileService).bind(opts.binding);
}
// Every fresh agent starts from the configured default permission posture;
// dispatchers that want a specific mode (subagent inheritance) set it on
// the child themselves after creation. On resume the wire replay
// overwrites this with the persisted mode.
// Apply the configured default only when restore found no persisted mode.
// A resumed Agent's journal owns its permission posture; callers that need
// an explicit override (for example subagent inheritance) do so after
// creation through the permission service.
const wire = handle.accessor.get(IWireService);
const permissionMode = this.config.get<PermissionMode>(DEFAULT_PERMISSION_MODE_SECTION);
if (permissionMode !== undefined) {
const hasRestoredPermissionMode = wire.getModel(PermissionModeConfiguredModel);
if (permissionMode !== undefined && !hasRestoredPermissionMode) {
handle.accessor.get(IAgentPermissionModeService).setMode(permissionMode);
}
}

View file

@ -6,7 +6,7 @@
* (tick / coalesce / jitter / cursor), persists mutations through the
* App-scoped `ICronTaskPersistence`, mirrors mutations as `cron.add` /
* `cron.delete` / `cron.cursor` Ops on the main agent's `wire` (cross-scope
* borrow) so `wire.replay` can rebuild the `CronModel`, publishes `cron.fired`
* borrow) so wire restore can rebuild the `CronModel`, publishes `cron.fired`
* to the main agent's `IEventBus`, steers the main agent
* through `IAgentPromptService` when a task fires, and registers the cron
* tools (`CronCreate` / `CronList` / `CronDelete`) into the main agent's
@ -39,7 +39,7 @@ import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import type { Op } from '#/wire/op';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentLoopService, type Turn } from '#/agent/loop/loop';
@ -91,13 +91,13 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
this._register(
this.agentLifecycle.onDidCreate((handle) => {
if (handle.id !== 'main') return;
void this.bindMainAgent(handle);
this.bindMainAgent(handle);
}),
);
const existingMain = this.agentLifecycle.get('main');
if (existingMain) {
void this.bindMainAgent(existingMain);
this.bindMainAgent(existingMain);
}
this._register(
@ -107,24 +107,23 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
);
}
private async bindMainAgent(handle: IAgentScopeHandle): Promise<void> {
await this.config.ready;
this.resolveClocks();
const wire = handle.accessor.get(IAgentWireService);
private bindMainAgent(handle: IAgentScopeHandle): void {
const wire = handle.accessor.get(IWireService);
this._register(
wire.onRestored(() => {
wire.hooks.onDidRestore.register('cron', async (_ctx, next) => {
await this.config.ready;
this.resolveClocks();
this.tasks.clear();
for (const [id, task] of wire.getModel(CronModel)) {
this.tasks.set(id, task as CronTask);
}
void this.loadFromStore({ replace: false }).then(() => this.start());
await this.loadFromStore({ replace: false });
await this.start();
await next();
}),
);
this.registerCronTools(handle);
await this.loadFromStore();
await this.start();
}
private registerCronTools(handle: IAgentScopeHandle): void {
@ -485,7 +484,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
private dispatchCron(op: Op): void {
const mainHandle = this.agentLifecycle.get('main');
if (!mainHandle) return;
mainHandle.accessor.get(IAgentWireService).dispatch(op);
mainHandle.accessor.get(IWireService).dispatch(op);
}
private signalCron(event: DomainEvent): void {

View file

@ -10,7 +10,7 @@
* subagent finishes, reloads `AGENTS.md` through the `profile` context helper
* (over the os `hostFs` + host home dir, with the `bootstrap` brand dir) and
* appends an `init`-variant system reminder to the main agent via
* `systemReminder`, then flushes the main agent's `wireRecord` log. Bound at
* `systemReminder`, then flushes the main agent's wire journal. Bound at
* Session scope.
*
* Port of v1 `Session.generateAgentsMd()`. The main-agent lookup is a hard
@ -31,7 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile';
import { loadAgentsMd } from '#/agent/profile/context';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IWireService } from '#/wire/wire';
import { ErrorCodes, Error2 } from '#/errors';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun';
@ -117,7 +117,7 @@ export class SessionInitService implements ISessionInitService {
kind: 'injection',
variant: 'init',
});
await main.accessor.get(IAgentWireRecordService).flush();
await main.accessor.get(IWireService).flush();
} catch (error) {
// User cancellations (Ctrl+C → cancelInit) must surface as aborts, not
// as init failures — the TUI resets quietly on `isAbortError`.

View file

@ -13,7 +13,9 @@ import type { Event } from '#/_base/event';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface AgentMeta {
readonly homedir: string;
/** Absolute standard path retained for older v1 readers. Current readers
* derive the agent directory from the session scope and ignore this field. */
readonly homedir?: string;
readonly type?: 'main' | 'sub' | 'independent';
readonly parentAgentId?: string | null;
readonly forkedFrom?: string;

View file

@ -4,21 +4,17 @@
* Holds the session's shared todo list as a stateless facade over the main
* agent's `TodoModel`: `getTodos` reads `wire.getModel(TodoModel)` live, and
* every mutation only dispatches a `tools.update_store` Op to the main agent's
* wire (the
* single source of truth and replayable timeline); `onDidChange` is bridged
* from `wire.subscribe(TodoModel)`. The service keeps no list copy of its own,
* so the live view and the post-replay view can never drift. Binds the
* wire (the single source of truth and replayable timeline), then emits
* `onDidChange` from the rebuilt Model. The service keeps no list copy of its
* own, so the live view and the post-replay view can never drift. Binds the
* `TodoListTool` and the stale-todo reminder into every agent (`onDidCreate`),
* and the model subscription into the main agent (`onDidCreateMain`),
* borrowing each agent's services through its `IAgentScopeHandle.accessor`.
* Per-agent bindings are disposed when the agent is disposed. Bound at Session
* scope.
* Per-agent bindings are disposed when the agent is disposed. Bound at
* Session scope.
*
* Debt: the session's todo list is still persisted on the MAIN agent's wire (a
* Session Agent edge), so it follows the main agent's lifetime. Once
* `ISessionWireService` is wired up with its own log + replay, move `TodoModel`
* there swap `@IAgentWireService` for `@ISessionWireService` and drop the
* main-agent subscription. The stateless facade makes that a one-line change.
* The session owns the todo facade and tool bindings, while the main Agent wire
* owns the replayable state. This is an explicit cross-scope orchestration
* boundary: there is no second session-level wire aggregate or journal.
*/
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
@ -30,7 +26,7 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInj
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import { ISessionTodoService } from './sessionTodo';
import { TodoModel, todoSet } from './todoOps';
@ -55,7 +51,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
this._register(
this.agentLifecycle.onDidCreate((handle) => {
this.bindAgent(handle);
if (handle.id === MAIN_AGENT_ID) this.bindMainWire(handle);
}),
);
this._register(
@ -64,7 +59,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
for (const handle of this.agentLifecycle.list()) {
this.bindAgent(handle);
if (handle.id === MAIN_AGENT_ID) this.bindMainWire(handle);
}
this._register(
@ -79,7 +73,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
getTodos(): readonly TodoItem[] {
const main = this.agentLifecycle.get(MAIN_AGENT_ID);
if (main === undefined) return [];
return main.accessor.get(IAgentWireService).getModel(TodoModel);
return main.accessor.get(IWireService).getModel(TodoModel);
}
setTodos(todos: readonly TodoItem[]): void {
@ -97,16 +91,9 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
private dispatchTodoSet(todos: readonly TodoItem[]): void {
const main = this.agentLifecycle.get(MAIN_AGENT_ID);
if (main === undefined) return;
const wire = main.accessor.get(IAgentWireService);
const wire = main.accessor.get(IWireService);
wire.dispatch(todoSet({ key: 'todo', value: todos }));
}
private bindMainWire(handle: IAgentScopeHandle): void {
const wire = handle.accessor.get(IAgentWireService);
const disposable = wire.subscribe(TodoModel, (state) => {
this.onDidChangeEmitter.fire(state);
});
this.trackAgentBinding(handle.id, disposable);
this.onDidChangeEmitter.fire(wire.getModel(TodoModel));
}
private bindAgent(handle: IAgentScopeHandle): void {

View file

@ -11,10 +11,10 @@
* render, the stale reminder, the compaction summary) can trust the Model
* without re-validating. Consumed cross-scope by the Session-scope
* `SessionTodoService`: it dispatches to the MAIN agent's wire (the single
* source of truth and replayable timeline) and, on `wire.onRestored`, reads the
* rebuilt Model back from that same wire. The Ops register into the global
* `OP_REGISTRY` at import time, so they are in place before the main agent
* replays.
* source of truth and replayable timeline), and `getTodos` reads the rebuilt
* Model back from that same wire after restore. The Ops register into the
* global `OP_REGISTRY` at import time, so they are in place before the main
* agent restores.
*/
import { z } from 'zod';

View file

@ -4,7 +4,7 @@
*
* Aggregates the wire domain's coded errors: `DuplicateOpError` (thrown by
* `defineOp` in `op.ts`) and `CycleError` (thrown by the dispatch drain in
* `wireServiceImpl.ts`) stay co-located with their throw sites but extend
* `wireService.ts`) stay co-located with their throw sites but extend
* `WireError`; `wire.unknown_record` is constructed here for replay-time
* reporting of records whose Op type is absent from `OP_REGISTRY`.
*/
@ -17,6 +17,7 @@ export const WireErrors = {
WIRE_DUPLICATE_OP: 'wire.duplicate_op',
WIRE_CYCLE: 'wire.cycle',
WIRE_UNKNOWN_RECORD: 'wire.unknown_record',
RECORDS_WRITE_FAILED: 'records.write_failed',
},
info: {
'wire.duplicate_op': {
@ -37,6 +38,11 @@ export const WireErrors = {
public: true,
action: 'The record was written by a newer version; upgrade or drop it.',
},
'records.write_failed': {
title: 'Wire journal write failed',
retryable: false,
public: true,
},
},
} as const satisfies ErrorDomain;

View file

@ -1,3 +1,5 @@
import type { WireRecord } from '#/wire/record';
import { migrateV1_0ToV1_1 } from './v1.1';
import { migrateV1_1ToV1_2 } from './v1.2';
import { migrateV1_2ToV1_3 } from './v1.3';
@ -10,12 +12,9 @@ export {
migrateV1_3ToV1_4,
};
export const AGENT_WIRE_PROTOCOL_VERSION = '1.4';
export const WIRE_PROTOCOL_VERSION = '1.4';
export interface WireMigrationRecord {
readonly type: string;
[key: string]: unknown;
}
export type WireMigrationRecord = WireRecord;
export interface WireMigration {
readonly sourceVersion: string;
@ -31,17 +30,17 @@ const MIGRATIONS: readonly WireMigration[] = [
];
export function isNewerWireVersion(readVersion: string): boolean {
return compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) > 0;
return compareWireVersions(readVersion, WIRE_PROTOCOL_VERSION) > 0;
}
export function resolveWireMigrations(readVersion: string): readonly WireMigration[] {
if (compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) >= 0) {
if (compareWireVersions(readVersion, WIRE_PROTOCOL_VERSION) >= 0) {
return [];
}
const migrations: WireMigration[] = [];
let version = readVersion;
while (compareWireVersions(version, AGENT_WIRE_PROTOCOL_VERSION) < 0) {
while (compareWireVersions(version, WIRE_PROTOCOL_VERSION) < 0) {
const migration = findMigration(version);
if (migration === undefined) {
throw new Error(`Missing wire migration for version ${version}`);

View file

@ -29,11 +29,10 @@
* cast happens once inside `WireService`.
*
* A primary Model may register cross-model reducers keyed by foreign op types:
* `WireService.execute` runs them on both dispatch and replay, so v1-derived
* `WireService` runs them on both dispatch and restore, so v1-derived
* restore effects can stay replayable without persisting extra records.
*
* `DeepReadonly<T>` recursively maps a state type to its deeply-readonly view
* for the references returned by `getModel` / `subscribe`: functions pass
* for the references returned by `getModel`: functions pass
* through, `Map` / `Set` widen to `ReadonlyMap` / `ReadonlySet`, arrays and
* tuples widen to `ReadonlyArray`, plain objects become a readonly mapped type,
* and primitives are unchanged. It pairs with the runtime `Object.freeze`
@ -42,12 +41,12 @@
import { bindDefineOp, type DefineOpFn } from '#/wire/op';
import type { ModelReducers } from '#/wire/types';
import type { PersistedRecord } from '#/wire/wireService';
import type { WireRecord } from '#/wire/record';
export type PartsTransformer = (parts: readonly unknown[]) => Promise<readonly unknown[]>;
export interface ModelBlobCodec<S> {
dehydrate(record: PersistedRecord, transform: PartsTransformer): PersistedRecord | Promise<PersistedRecord>;
dehydrate(record: WireRecord, transform: PartsTransformer): WireRecord | Promise<WireRecord>;
rehydrate(state: S, transform: PartsTransformer): S | Promise<S>;
}
@ -95,22 +94,6 @@ export function defineModel<S>(
return def;
}
export interface DerivedModelDef<S> {
readonly name: string;
readonly initial: () => S;
readonly reducers: Readonly<ModelReducers<S>>;
readonly blobs?: ModelBlobCodec<S>;
}
export function defineDerivedModel<S>(
name: string,
initial: () => S,
reducers: ModelReducers<S>,
opts?: { blobs?: ModelBlobCodec<S> },
): DerivedModelDef<S> {
return { name, initial, reducers, blobs: opts?.blobs };
}
export type DeepReadonly<T> = T extends (...args: infer A) => infer R
? (...args: A) => R
: T extends ReadonlyMap<infer K, infer V>

View file

@ -8,12 +8,12 @@
* callable (`goalCreate(payload)`) and inspectable (`goalCreate.apply`,
* `goalCreate.type`). Every Op carries a mandatory pure `apply` and may carry
* an optional `toEvent` that derives an `IEventBus` fact from the payload and
* the post-apply state (published by `WireService` on `dispatch`, never on
* `replay`). A mandatory `schema` (zod, declared before `apply`) is the
* the post-apply state (published by `WireService` on live `dispatch`,
* never during `restore`). A mandatory `schema` (zod, declared before `apply`) is the
* payload's single source of truth: `P` is inferred from it, so Op authors
* never restate payload interfaces, and it is stored on the descriptor for
* payload validation at wire boundaries; the runtime paths (`dispatch` /
* `replay`) never consult it. The descriptor's payload is erased
* `restore`) never consult it. The descriptor's payload is erased
* to `any` on `Op.descriptor` (mirroring `OP_REGISTRY`) so `Op` stays
* covariant in `P` a heterogeneous batch of Ops, each with a different
* payload type, stays assignable to the single `dispatch(...ops: Op[])` rest
@ -23,8 +23,7 @@
* definition into the `types.ts` registries (which map op types to `typeof`
* the Op); registration constrains only the persistence policy a registered
* type must honor its map, an unregistered type keeps its free `persist`
* option. Descriptors may opt out of timestamp stamping (`stamp: false`) for
* the metadata envelope. Scope-agnostic.
* option. Scope-agnostic.
*/
import type { z } from 'zod';
@ -50,7 +49,6 @@ export interface OpDescriptor<K extends string, S, P> {
readonly apply: (state: S, payload: P) => S;
readonly toEvent?: (payload: P, state: S) => unknown;
readonly persist?: boolean;
readonly stamp?: boolean;
}
export interface Op<K extends string = string, P = unknown> {
@ -67,7 +65,6 @@ interface OpBehaviorOptions<S, P> {
readonly schema: z.ZodType<P>;
readonly apply: (state: S, payload: P) => S;
readonly toEvent?: (payload: P, state: S) => unknown;
readonly stamp?: boolean;
}
type RegisteredOpConstraint<K extends string> = K extends ConflictingOpType
@ -122,7 +119,6 @@ export function defineOp<const K extends string, S, P>(
apply: behavior.apply,
toEvent: behavior.toEvent,
persist: behavior.persist,
stamp: behavior.stamp,
};
OP_REGISTRY.set(type, descriptor);
const factory = (payload: P): Op<K, P> => ({ type, payload, descriptor });

View file

@ -0,0 +1,59 @@
/**
* `wire` domain (L2) the persisted journal record language.
*
* A `WireRecord` is the flat JSONL representation of one persisted Op. The
* first line of an Agent journal is a `WireMetadataRecord`; metadata is a
* journal envelope, not an Op, so it never enters the model reducer registry.
* This module owns only pure encoding and decoding.
*/
import type { Op } from '#/wire/op';
import { WIRE_PROTOCOL_VERSION } from './migration/migration';
export const AGENT_WIRE_RECORD_KEY = 'wire.jsonl';
export interface WireRecord {
readonly type: string;
readonly time?: number;
readonly [key: string]: unknown;
}
export interface WireMetadataRecord extends WireRecord {
readonly type: 'metadata';
readonly protocol_version: string;
readonly created_at: number;
}
export function createWireMetadataRecord(now = Date.now()): WireMetadataRecord {
return {
type: 'metadata',
protocol_version: WIRE_PROTOCOL_VERSION,
created_at: now,
};
}
export function isWireMetadataRecord(record: WireRecord): record is WireMetadataRecord {
return (
record.type === 'metadata' &&
typeof record['protocol_version'] === 'string' &&
typeof record['created_at'] === 'number'
);
}
export function opToWireRecord(op: Op, now = Date.now()): WireRecord {
const payload = op.payload;
const record: Record<string, unknown> =
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? { type: op.type, ...(payload as Record<string, unknown>) }
: { type: op.type, payload };
if (record['time'] === undefined) record['time'] = now;
return record as WireRecord;
}
export function wireRecordToPayload(record: WireRecord): unknown {
const { type: _type, time: _time, ...payload } = record;
return Object.keys(payload).length === 1 && 'payload' in payload
? payload['payload']
: payload;
}

View file

@ -1,20 +0,0 @@
/**
* `wire` domain (L2) scope-specific DI tokens (`IAgentWireService`,
* `ISessionWireService`) over the single `IWireService` contract.
*
* One `WireService` implementation serves every scope; per-scope isolation
* comes from distinct tokens, each seeded with its own persistence key at scope
* creation. Domain services inject the token for their scope
* (`@IAgentWireService`, `@ISessionWireService`). No App-scope token yet add
* one when a use case appears.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IWireService } from './wireService';
export const IAgentWireService: ServiceIdentifier<IWireService> =
createDecorator<IWireService>('agentWireService');
export const ISessionWireService: ServiceIdentifier<IWireService> =
createDecorator<IWireService>('sessionWireService');

View file

@ -0,0 +1,36 @@
/**
* `wire` domain (L2) the single Agent-scoped wire aggregate contract.
*
* The service owns one Agent's replayable model state and its journal as one
* consistency boundary: restore reads, validates, migrates, rewrites, replays,
* rehydrates, and then runs the ordered restore hook. Seal initializes a fresh
* journal before session metadata makes the Agent visible to legacy readers.
* Live dispatch applies an Op and appends its record. Callers do not coordinate
* journal and model state through separate services.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Hooks } from '#/hooks';
import type { DeepReadonly, ModelDef } from './model';
import type { Op } from './op';
export type WireHooks = {
readonly onDidRestore: Record<string, never>;
};
export interface IWireService {
readonly _serviceBrand: undefined;
readonly hooks: Hooks<WireHooks>;
dispatch(...ops: Op[]): void;
seal(): Promise<void>;
restore(): Promise<void>;
flush(): Promise<void>;
getModel<S>(model: ModelDef<S>): DeepReadonly<S>;
}
export const IWireService: ServiceIdentifier<IWireService> =
createDecorator<IWireService>('wireService');

View file

@ -1,67 +1,313 @@
/**
* `wire` domain (L2) `IWireService` contract and its supporting types
* (`PersistedRecord`, `OpGroup`, `ModelChange`).
* `wire` domain (L2) `IWireService` implementation.
*
* The scope-agnostic state-machine engine: `dispatch` persists + applies +
* notifies (OpGroup `{ silent: false }`), `replay` (async rehydrates blob
* references via `ModelDef.blobs` first) applies only (`{ silent: true }`);
* `flush` drains the serialized persist queue. Reads go through `getModel` /
* `subscribe`; the live append-log record stream streams via `onEmission`,
* restore completion via `onRestored`, and Op-derived facts flow out through
* `IEventBus` (see `op.ts` `toEvent`). A single implementation serves every
* scope instances are isolated per scope through the distinct DI tokens in
* `tokens`, each seeded with its own persistence key. `PersistedRecord` is the
* on-the-wire append-log shape (`wire.jsonl`): intentionally flat
* (`{ type, ...payload }`, optional `time`) so it stays byte-compatible with the
* existing wire journal (`{ type, time?, ...fields }`) payload fields
* sit at the top level next to `type`, never nested under a `payload` key; the
* index signature keeps it scope-agnostic and domains narrow via their Op
* payload types. Scope-agnostic.
* `WireService` is the sole runtime owner of an Agent wire aggregate. It
* combines the model reducer engine with the `wire.jsonl` journal protocol,
* including creation-time sealing, metadata, migrations, atomic healing
* rewrites, blob dehydration and rehydration plus an ordered post-restore hook.
* It is bound at Agent scope because the aggregate identity is the Agent
* identity.
*/
import type { IDisposable } from '#/_base/di/lifecycle';
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { DeepReadonly, DerivedModelDef, ModelDef } from './model';
import { InstantiationType } from '#/_base/di/extensions';
import { BugIndicatingError } from '#/_base/errors/errors';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import type { ContentPart } from '#/app/llmProtocol/message';
import { OrderedHookSlot } from '#/hooks';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { StorageError, StorageErrors } from '#/persistence/interface/storage';
import { IWireService } from './wire';
import { WireError, WireErrors } from './errors';
import {
WIRE_PROTOCOL_VERSION,
isNewerWireVersion,
migrateWireRecord,
resolveWireMigrations,
type WireMigration,
} from './migration/migration';
import type { DeepReadonly, ModelDef, PartsTransformer } from './model';
import { MODEL_CROSS_REDUCERS } from './model';
import type { Op } from './op';
import { OP_REGISTRY } from './op';
import {
AGENT_WIRE_RECORD_KEY,
createWireMetadataRecord,
isWireMetadataRecord,
opToWireRecord,
wireRecordToPayload,
type WireRecord,
} from './record';
export interface PersistedRecord {
readonly type: string;
readonly time?: number;
readonly [key: string]: unknown;
const MAX_DRAIN = 100;
export class CycleError extends WireError {
constructor(readonly depth: number, readonly opTypes: readonly string[]) {
super(
WireErrors.codes.WIRE_CYCLE,
`Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`,
{ details: { depth, opTypes: opTypes.slice(0, 20) } },
);
this.name = 'CycleError';
}
}
export interface OpGroup {
interface ModelInstance {
state: any;
}
interface OpGroup {
readonly ops: readonly Op[];
readonly silent: boolean;
}
export interface ModelChange<S> {
readonly state: S;
readonly prev: S;
type RestorePhase = 'new' | 'restoring' | 'ready' | 'failed';
export class WireService extends Disposable implements IWireService {
declare readonly _serviceBrand: undefined;
readonly hooks: IWireService['hooks'] = {
onDidRestore: new OrderedHookSlot(),
};
private readonly models = new Map<ModelDef<any>, ModelInstance>();
private readonly wireScope: string;
private restorePhase: RestorePhase = 'new';
private dispatching = false;
private queue: Op[] = [];
private drainDepth = 0;
private persistQueue: Promise<void> | undefined;
constructor(
@IAgentScopeContext scopeContext: IAgentScopeContext,
@IAppendLogStore private readonly log: IAppendLogStore,
@IAgentBlobService private readonly blobService: IAgentBlobService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this.wireScope = scopeContext.scope();
this._register(this.log.acquire(this.wireScope, AGENT_WIRE_RECORD_KEY));
}
getModel<S>(model: ModelDef<S>): DeepReadonly<S> {
return this.ensureModel(model).state as DeepReadonly<S>;
}
dispatch(...ops: Op[]): void {
if (ops.length === 0) return;
if (this.dispatching) {
this.queue.push(...ops);
return;
}
this.dispatching = true;
try {
this.execute({ ops, silent: false });
while (this.queue.length > 0) {
if (++this.drainDepth > MAX_DRAIN) {
throw new CycleError(this.drainDepth, this.queue.map((op) => op.type));
}
this.execute({ ops: this.queue.splice(0), silent: false });
}
} finally {
this.queue.length = 0;
this.dispatching = false;
this.drainDepth = 0;
}
}
async seal(): Promise<void> {
for await (const record of this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY)) {
void record;
return;
}
this.appendRecord(createWireMetadataRecord());
}
async restore(): Promise<void> {
if (
this.restorePhase === 'restoring' ||
this.restorePhase === 'failed' ||
this.restorePhase === 'ready'
) {
throw new BugIndicatingError(`Agent wire restore called while phase is ${this.restorePhase}`);
}
this.restorePhase = 'restoring';
try {
const source = this.log.read<WireRecord>(this.wireScope, AGENT_WIRE_RECORD_KEY);
let migrations: readonly WireMigration[] = [];
let rewrittenRecords: WireRecord[] | undefined;
let newerWireVersion = false;
let recordIndex = 0;
let hasRecords = false;
for await (const sourceRecord of source) {
if (!hasRecords) {
hasRecords = true;
if (sourceRecord.type !== 'metadata') {
rewrittenRecords = [createWireMetadataRecord()];
} else if (!isWireMetadataRecord(sourceRecord)) {
throw new StorageError(
StorageErrors.codes.STORAGE_CORRUPTED,
'Agent wire metadata is malformed',
{ details: { scope: this.wireScope, key: AGENT_WIRE_RECORD_KEY } },
);
} else if (isNewerWireVersion(sourceRecord.protocol_version)) {
newerWireVersion = true;
} else {
migrations = resolveWireMigrations(sourceRecord.protocol_version);
if (sourceRecord.protocol_version !== WIRE_PROTOCOL_VERSION) {
rewrittenRecords = [];
}
}
}
const migratedRecord = migrateWireRecord(sourceRecord, migrations);
const record =
!newerWireVersion && migratedRecord.type === 'metadata'
? { ...migratedRecord, protocol_version: WIRE_PROTOCOL_VERSION }
: migratedRecord;
rewrittenRecords?.push(record);
if (record.type === 'metadata') continue;
this.replayRecord(record, recordIndex);
recordIndex++;
}
if (!hasRecords) {
rewrittenRecords = [createWireMetadataRecord()];
}
if (rewrittenRecords !== undefined) {
await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords);
}
await this.rehydrateModels();
this.restorePhase = 'ready';
await this.hooks.onDidRestore.run({});
} catch (error) {
this.restorePhase = 'failed';
throw error;
}
}
async flush(): Promise<void> {
await this.persistQueue;
await this.log.flush();
}
private replayRecord(record: WireRecord, index: number): void {
const descriptor = OP_REGISTRY.get(record.type);
if (descriptor === undefined) {
onUnexpectedError(
new WireError(
WireErrors.codes.WIRE_UNKNOWN_RECORD,
`Unknown wire record type '${record.type}' skipped during restore`,
{ details: { type: record.type, index } },
),
);
return;
}
this.execute({
ops: [{ type: record.type, payload: wireRecordToPayload(record), descriptor }],
silent: true,
});
}
private execute(group: OpGroup): void {
for (const op of group.ops) {
const inst = this.ensureModel(op.descriptor.model);
const prev = inst.state;
inst.state = Object.freeze(op.descriptor.apply(prev, op.payload));
if (!group.silent) {
if (op.descriptor.persist !== false) {
const record = opToWireRecord(op);
this.appendToJournal(record, op.descriptor.model);
}
const event = op.descriptor.toEvent?.(op.payload, inst.state);
if (event !== undefined) {
this.eventBus.publish(event as DomainEvent);
}
}
const crossReducers = MODEL_CROSS_REDUCERS.get(op.type);
if (crossReducers !== undefined) {
for (const entry of crossReducers) {
if (entry.model === op.descriptor.model) continue;
const crossInst = this.ensureModel(entry.model);
crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload));
}
}
}
}
private ensureModel<S>(def: ModelDef<S>): ModelInstance {
let inst = this.models.get(def);
if (inst === undefined) {
inst = { state: Object.freeze(def.initial()) };
this.models.set(def, inst);
}
return inst;
}
private appendToJournal(record: WireRecord, model: ModelDef<any>): void {
const dehydrate = model.blobs?.dehydrate?.bind(model.blobs);
if (dehydrate === undefined && this.persistQueue === undefined) {
try {
this.appendRecord(record);
} catch (error) {
onUnexpectedError(error);
}
return;
}
const transform: PartsTransformer = (parts) =>
this.blobService.offloadParts(
parts as readonly ContentPart[],
) as Promise<readonly unknown[]>;
const queued = (this.persistQueue ?? Promise.resolve())
.then(async () => {
let output = record;
if (dehydrate !== undefined) {
const prepared = dehydrate(record, transform);
output = await prepared;
}
this.appendRecord(output);
})
.catch((error: unknown) => onUnexpectedError(error));
this.persistQueue = queued;
void queued.then(() => {
if (this.persistQueue === queued) this.persistQueue = undefined;
});
}
private appendRecord(record: WireRecord): void {
this.log.append(this.wireScope, AGENT_WIRE_RECORD_KEY, record, {
onError: onUnexpectedError,
});
}
private async rehydrateModels(): Promise<void> {
const transform: PartsTransformer = (parts) =>
this.blobService.loadParts(
parts as readonly ContentPart[],
) as Promise<readonly unknown[]>;
for (const [def, inst] of this.models) {
if (def.blobs?.rehydrate === undefined) continue;
const result = def.blobs.rehydrate(inst.state, transform);
inst.state = Object.freeze(await result);
}
}
}
export interface ReplayResult {
readonly unknownRecords: number;
}
export interface WireEmission {
readonly type: 'record';
readonly record: PersistedRecord;
}
export interface IWireService {
readonly _serviceBrand: undefined;
dispatch(...ops: Op[]): void;
replay(...records: PersistedRecord[]): Promise<ReplayResult>;
flush(): Promise<void>;
attach<S>(model: DerivedModelDef<S>): IDisposable;
getModel<S>(model: ModelDef<S> | DerivedModelDef<S>): DeepReadonly<S>;
subscribe<S>(
model: ModelDef<S> | DerivedModelDef<S>,
handler: (state: DeepReadonly<S>, prev: DeepReadonly<S>) => void,
): IDisposable;
onEmission(handler: (emission: WireEmission) => void): IDisposable;
onRestored(handler: () => void | Promise<void>): IDisposable;
}
registerScopedService(
LifecycleScope.Agent,
IWireService,
WireService,
InstantiationType.Eager,
'wire',
);

View file

@ -1,403 +0,0 @@
/**
* `wire` domain (L2) `WireService`, the single scope-agnostic implementation
* of `IWireService`, plus its construction options (`WireServiceOptions`)
* and the coded `CycleError`.
*
* One class serves every scope: per-scope isolation comes from the distinct DI
* tokens in `tokens`, each seeded with its own `WireServiceOptions`
* (`logScope` / `logKey`) as the leading (non-service) constructor argument
* through a `SyncDescriptor`, mirroring `WireRecordServiceOptions`. `dispatch`
* and `replay` both lower to one primitive, `execute(OpGroup)` apply-all THEN
* onChange-all, so a subscriber never observes a partially-applied group with
* `dispatch` adding persistence + emission + Op-derived `IEventBus` events
* (`silent: false`) and `replay` staying silent (apply only, skipping
* unknown record types, then `onRestored`). A reentrancy guard (`dispatching` +
* `queue` + `drain`, capped by `MAX_DRAIN = 100`) lets onChange handlers enqueue
* further ops without reentering `execute`; a cascade past the cap throws
* `CycleError` (`wire.cycle`), co-located here like `DuplicateOpError`
* (`wire.duplicate_op` in `op.ts`) both extend `WireError` from
* `wire/errors.ts`. After every
* `apply` the new state is `Object.freeze`d the runtime half of the
* immutability guarantee whose compile-time half is `DeepReadonly`. Internally
* each per-model instance is erased to `any` (the same localized erasure as
* `OP_REGISTRY`) and restored at the public boundary; an Op's optional `toEvent`
* derives an `IEventBus` fact on `dispatch` (never on `replay`).
*
* Persists each dispatched op through `persistence` (`IAppendLogStore`) as a
* flat `{ type, ...payload }` record scalar / array payloads nested so a
* JSONL line stays an object, stamped with `time` unless the op opts out
* (`stamp: false`, only the `metadata` envelope), with `type` / `time`
* stripped back out on replay. Ops declared `persist: false` apply and notify
* like any other but never reach the emission stream or the log the on-disk
* record vocabulary stays exactly v1's. After each op, cross-model reducers
* registered via `defineModel(..., { reducers })` (`MODEL_CROSS_REDUCERS`)
* fold the op into foreign primary models on both dispatch and replay.
*
* Blob handling is driven by each `ModelDef`'s optional `blobs` codec
* (`ModelBlobCodec`), which declares two symmetric directions:
*
* - **Dehydrate (dispatch persist)**: `model.blobs.dehydrate(record, transform)`
* lets the model traverse its own record structure, pass each `ContentPart[]`
* through `transform` (which offloads oversized inline data to blob storage),
* and return the transformed record. `apply` and the live emission still see
* the original inline payload. Records whose model has no `blobs` codec
* short-circuit synchronously (no queue, no microtask).
*
* - **Rehydrate (replay model)**: after all records are applied,
* `rehydrateModels` calls `model.blobs.rehydrate(state, transform)` on each
* model that declares a `blobs` codec, replacing blobref URLs with inline data
* *only* in the surviving final state skipping I/O for data later removed by
* compaction (a 20×+ speedup for long sessions with many images).
*
* Scope-agnostic.
*/
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
import { Emitter } from '#/_base/event';
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import type { ContentPart } from '#/app/llmProtocol/message';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { WireError, WireErrors } from './errors';
import type { DeepReadonly, DerivedModelDef, ModelDef, PartsTransformer } from './model';
import { MODEL_CROSS_REDUCERS } from './model';
import type { Op } from './op';
import { OP_REGISTRY } from './op';
import type {
IWireService,
ModelChange,
OpGroup,
PersistedRecord,
ReplayResult,
WireEmission,
} from './wireService';
const MAX_DRAIN = 100;
export class CycleError extends WireError {
constructor(readonly depth: number, readonly opTypes: readonly string[]) {
super(
WireErrors.codes.WIRE_CYCLE,
`Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`,
{
details: { depth, opTypes: opTypes.slice(0, 20) },
},
);
this.name = 'CycleError';
}
}
export interface WireServiceOptions {
readonly logScope: string;
readonly logKey: string;
}
interface ModelInstance {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
state: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitter: Emitter<ModelChange<any>>;
}
interface ReducerEntry {
readonly inst: ModelInstance;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly reducer: (state: any, payload: any) => any;
}
export class WireService extends Disposable implements IWireService {
declare readonly _serviceBrand: undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly models = new Map<ModelDef<any>, ModelInstance>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly derivedModels = new Map<DerivedModelDef<any>, ModelInstance>();
private readonly reducerIndex = new Map<string, ReducerEntry[]>();
private readonly emissionEmitter = this._register(new Emitter<WireEmission>());
private readonly restoredHandlers = new Set<() => void | Promise<void>>();
private dispatching = false;
private queue: Op[] = [];
private drainDepth = 0;
private persistQueue: Promise<void> = Promise.resolve();
constructor(
private readonly options: WireServiceOptions,
@IAppendLogStore private readonly log?: IAppendLogStore,
@IAgentBlobService private readonly blobService?: IAgentBlobService,
@IEventBus private readonly eventBus?: IEventBus,
) {
super();
if (this.log !== undefined) {
this._register(this.log.acquire(this.options.logScope, this.options.logKey));
}
}
getModel<S>(model: ModelDef<S> | DerivedModelDef<S>): DeepReadonly<S> {
if ('reducers' in model) {
const inst = this.derivedModels.get(model);
return (inst?.state ?? Object.freeze(model.initial())) as DeepReadonly<S>;
}
return this.ensureModel(model).state as DeepReadonly<S>;
}
subscribe<S>(
model: ModelDef<S> | DerivedModelDef<S>,
handler: (state: DeepReadonly<S>, prev: DeepReadonly<S>) => void,
): IDisposable {
const inst = 'reducers' in model
? this.derivedModels.get(model)
: this.ensureModel(model);
if (inst === undefined) return { dispose: () => {} };
return inst.emitter.event((change) =>
handler(change.state as DeepReadonly<S>, change.prev as DeepReadonly<S>),
);
}
onEmission(handler: (emission: WireEmission) => void): IDisposable {
return this.emissionEmitter.event(handler);
}
onRestored(handler: () => void | Promise<void>): IDisposable {
this.restoredHandlers.add(handler);
return toDisposable(() => this.restoredHandlers.delete(handler));
}
attach<S>(model: DerivedModelDef<S>): IDisposable {
const inst: ModelInstance = {
state: Object.freeze(model.initial()),
emitter: new Emitter<ModelChange<unknown>>(),
};
this._register(inst.emitter);
this.derivedModels.set(model, inst);
for (const [opType, reducer] of Object.entries(model.reducers)) {
if (reducer === undefined) continue;
let list = this.reducerIndex.get(opType);
if (list === undefined) {
list = [];
this.reducerIndex.set(opType, list);
}
list.push({ inst, reducer });
}
return {
dispose: () => {
this.derivedModels.delete(model);
for (const [opType, list] of this.reducerIndex) {
const filtered = list.filter((e) => e.inst !== inst);
if (filtered.length === 0) {
this.reducerIndex.delete(opType);
} else if (filtered.length !== list.length) {
this.reducerIndex.set(opType, filtered);
}
}
},
};
}
dispatch(...ops: Op[]): void {
if (ops.length === 0) return;
if (this.dispatching) {
this.queue.push(...ops);
return;
}
this.dispatching = true;
try {
this.execute({ ops, silent: false });
while (this.queue.length > 0) {
if (++this.drainDepth > MAX_DRAIN) {
throw new CycleError(this.drainDepth, this.queue.map((op) => op.type));
}
this.execute({ ops: this.queue.splice(0), silent: false });
}
} finally {
this.queue.length = 0;
this.dispatching = false;
this.drainDepth = 0;
}
}
async replay(...records: PersistedRecord[]): Promise<ReplayResult> {
const ops: Op[] = [];
let unknownRecords = 0;
for (let index = 0; index < records.length; index++) {
const record = records[index]!;
const descriptor = OP_REGISTRY.get(record.type);
if (descriptor === undefined) {
unknownRecords++;
onUnexpectedError(
new WireError(
WireErrors.codes.WIRE_UNKNOWN_RECORD,
`Unknown wire record type '${record.type}' skipped during replay`,
{ details: { type: record.type, index } },
),
);
continue;
}
ops.push({ type: record.type, payload: recordToPayload(record), descriptor });
}
this.execute({ ops, silent: true });
await this.rehydrateModels();
await this.fireRestored();
return { unknownRecords };
}
async flush(): Promise<void> {
await this.persistQueue;
await this.log?.flush();
}
private execute(group: OpGroup): void {
const changes: { inst: ModelInstance; change: ModelChange<unknown> }[] = [];
for (const op of group.ops) {
const inst = this.ensureModel(op.descriptor.model);
const prev = inst.state;
inst.state = Object.freeze(op.descriptor.apply(prev, op.payload));
if (!group.silent) {
if (op.descriptor.persist !== false) {
const record = this.toRecord(op);
this.emissionEmitter.fire({ type: 'record', record });
this.appendToWireLog(record, op.descriptor.model);
}
const event = op.descriptor.toEvent?.(op.payload, inst.state);
if (event !== undefined && this.eventBus !== undefined) {
this.eventBus.publish(event as DomainEvent);
}
}
if (inst.state !== prev) {
changes.push({ inst, change: { state: inst.state, prev } });
}
const entries = this.reducerIndex.get(op.type);
if (entries !== undefined) {
for (const entry of entries) {
const dPrev = entry.inst.state;
entry.inst.state = Object.freeze(entry.reducer(dPrev, op.payload));
if (entry.inst.state !== dPrev) {
changes.push({ inst: entry.inst, change: { state: entry.inst.state, prev: dPrev } });
}
}
}
const crossReducers = MODEL_CROSS_REDUCERS.get(op.type);
if (crossReducers !== undefined) {
for (const entry of crossReducers) {
if (entry.model === op.descriptor.model) continue;
const crossInst = this.ensureModel(entry.model);
const crossPrev = crossInst.state;
crossInst.state = Object.freeze(entry.reducer(crossPrev, op.payload));
if (crossInst.state !== crossPrev) {
changes.push({
inst: crossInst,
change: { state: crossInst.state, prev: crossPrev },
});
}
}
}
}
if (!group.silent) {
for (const { inst, change } of changes) {
inst.emitter.fire(change);
}
}
}
private ensureModel<S>(def: ModelDef<S>): ModelInstance {
let inst = this.models.get(def);
if (inst === undefined) {
inst = {
state: Object.freeze(def.initial()),
emitter: new Emitter<ModelChange<unknown>>(),
};
this._register(inst.emitter);
this.models.set(def, inst);
}
return inst;
}
private toRecord(op: Op): PersistedRecord {
const payload = op.payload;
const record: Record<string, unknown> =
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? { type: op.type, ...(payload as Record<string, unknown>) }
: { type: op.type, payload };
if (op.descriptor.stamp !== false && record['time'] === undefined) {
record['time'] = Date.now();
}
return record as PersistedRecord;
}
private async fireRestored(): Promise<void> {
for (const handler of Array.from(this.restoredHandlers)) {
try {
await handler();
} catch (error) {
onUnexpectedError(error);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private appendToWireLog(record: PersistedRecord, model: ModelDef<any>): void {
if (this.log === undefined) return;
if (this.blobService === undefined) {
this.log.append(this.options.logScope, this.options.logKey, record, {
onError: onUnexpectedError,
});
return;
}
const dehydrate = model.blobs?.dehydrate?.bind(model.blobs);
const transform: PartsTransformer = (parts) =>
this.blobService!.offloadParts(
parts as readonly ContentPart[],
) as Promise<readonly unknown[]>;
this.persistQueue = this.persistQueue
.then(async () => {
let out = record;
if (dehydrate !== undefined) {
const prepared = dehydrate(record, transform);
out = isPromise(prepared) ? await prepared : prepared;
}
this.log?.append(this.options.logScope, this.options.logKey, out, {
onError: onUnexpectedError,
});
})
.catch((error: unknown) => onUnexpectedError(error));
}
private async rehydrateModels(): Promise<void> {
if (this.blobService === undefined) return;
const transform: PartsTransformer = (parts) =>
this.blobService!.loadParts(
parts as readonly ContentPart[],
) as Promise<readonly unknown[]>;
for (const [def, inst] of this.models) {
if (def.blobs?.rehydrate === undefined) continue;
const result = def.blobs.rehydrate(inst.state, transform);
inst.state = Object.freeze(isPromise(result) ? await result : result);
}
for (const [def, inst] of this.derivedModels) {
if (def.blobs?.rehydrate === undefined) continue;
const result = def.blobs.rehydrate(inst.state, transform);
inst.state = Object.freeze(isPromise(result) ? await result : result);
}
}
}
function recordToPayload(record: PersistedRecord): unknown {
const payload: Record<string, unknown> = {};
for (const key of Object.keys(record)) {
if (key === 'type' || key === 'time') continue;
payload[key] = record[key];
}
return payload;
}
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return value !== null && typeof (value as Promise<T>).then === 'function';
}

View file

@ -1,11 +1,9 @@
/**
* `activity` kernel unit tests drives the real `AgentActivityService` with a
* stub Session kernel and an in-memory wire service.
* stub Session kernel, event bus and in-memory wire service.
*
* Asserts the PR1 turn-lane contract: `begin` admits a turn and rejects a
* concurrent one with `activity.agent_busy`, `cancel` moves the lane to
* `turn(ending)` and aborts the lease signal, and `lease.end` returns the lane
* to `idle` (idempotently). Run:
* Asserts turn admission and lifecycle transitions plus the live projection of
* streaming, tool calls, approvals, retries and step interruptions. Run:
* `pnpm test -- test/activity/activity.test.ts`
*/
@ -18,55 +16,72 @@ import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activi
import type { ActivityLease } from '#/activity/activity';
import { AgentActivityService } from '#/activity/agentActivityService';
import { SessionActivityKernel } from '#/activity/sessionActivityKernel';
import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService';
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { ErrorCodes } from '#/errors';
import { IAgentWireService, ISessionWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { stubSessionActivityKernel } from './stubs';
import { registerTestAgentWireServices } from '../wire/stubs';
describe('AgentActivityService (turn lane)', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let activity: IAgentActivityService;
let eventBus: IEventBus;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(
IAgentWireService,
disposables.add(new WireService({ logScope: 'wire', logKey: 'activity' })),
);
registerTestAgentWireServices(reg, 'wire/activity');
reg.defineInstance(ISessionActivityKernel, stubSessionActivityKernel());
reg.defineInstance(
IAgentScopeContext,
makeAgentScopeContext({ agentId: 'agent', agentScope: 'agent' }),
);
reg.define(IEventBus, EventBusService);
reg.define(IAgentActivityService, AgentActivityService);
},
});
activity = ix.get(IAgentActivityService);
eventBus = ix.get(IEventBus);
});
afterEach(() => {
disposables.dispose();
});
function collectActivity(): DomainEvent<'agent.activity.updated'>[] {
const snapshots: DomainEvent<'agent.activity.updated'>[] = [];
disposables.add(
eventBus.subscribe('agent.activity.updated', (snapshot) => snapshots.push(snapshot)),
);
return snapshots;
}
function startTurn(): ActivityLease {
activity.markReady();
const lease = activity.begin('turn', { turnId: 1 });
eventBus.publish({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } });
eventBus.publish({ type: 'turn.step.started', turnId: 1, step: 1, stepId: 's1' });
return lease;
}
it('starts initializing and admits a turn only after markReady', () => {
expect(activity.lane()).toBe('initializing');
expect(activity.isIdle()).toBe(false);
expect(() => activity.begin('turn')).toThrowError(
expect.objectContaining({ code: ErrorCodes.ACTIVITY_INITIALIZING }),
);
activity.markReady();
expect(activity.lane()).toBe('idle');
expect(activity.isIdle()).toBe(true);
const lease: ActivityLease = activity.begin('turn');
expect(lease.kind).toBe('turn');
expect(lease.signal.aborted).toBe(false);
expect(activity.lane()).toBe('turn');
expect(activity.isIdle()).toBe(false);
lease.end('completed');
expect(activity.lane()).toBe('idle');
expect(activity.isIdle()).toBe(true);
});
it('rejects a concurrent begin with activity.agent_busy', () => {
@ -85,17 +100,155 @@ describe('AgentActivityService (turn lane)', () => {
lease.end('completed');
});
it('cancel aborts the lease signal and keeps the lane until end', () => {
it('cancel aborts the lease signal and keeps the turn active until end', () => {
activity.markReady();
const lease = activity.begin('turn');
expect(activity.cancel('stop')).toBe(true);
expect(lease.signal.aborted).toBe(true);
expect(lease.ending).toBe(true);
expect(activity.lane()).toBe('turn');
expect(activity.isIdle()).toBe(false);
lease.end('cancelled');
expect(activity.lane()).toBe('idle');
expect(activity.isIdle()).toBe(true);
});
it('publishes lifecycle independently from turn activity', () => {
const states: Array<{ lifecycle: string; hasTurn: boolean; ending?: boolean }> = [];
disposables.add(
eventBus.subscribe('agent.activity.updated', (state) => {
states.push({
lifecycle: state.lifecycle,
hasTurn: state.turn !== undefined,
ending: state.turn?.ending,
});
}),
);
activity.markReady();
const lease = activity.begin('turn');
activity.cancel();
lease.end('cancelled');
expect(states).toEqual([
{ lifecycle: 'ready', hasTurn: false, ending: undefined },
{ lifecycle: 'ready', hasTurn: true, ending: false },
{ lifecycle: 'ready', hasTurn: true, ending: true },
{ lifecycle: 'ready', hasTurn: false, ending: undefined },
]);
});
it('publishes the first streaming delta and suppresses equivalent deltas', () => {
const snapshots = collectActivity();
const lease = startTurn();
const baseline = snapshots.length;
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'he' });
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'llo' });
expect(snapshots).toHaveLength(baseline + 1);
expect(snapshots.at(-1)?.turn).toMatchObject({ phase: 'streaming', stream: 'assistant' });
lease.end('completed');
});
it('projects active tool calls until their results arrive', () => {
const snapshots = collectActivity();
const lease = startTurn();
eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c1', name: 'Read', args: {} });
eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c2', name: 'Write', args: {} });
expect(snapshots.at(-1)?.turn?.activeToolCalls.map((tool) => tool.toolCallId)).toEqual([
'c1',
'c2',
]);
eventBus.publish({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'ok', isError: false });
expect(snapshots.at(-1)?.turn?.activeToolCalls.map((tool) => tool.toolCallId)).toEqual(['c2']);
lease.end('completed');
});
it('projects all pending approvals until each is resolved', () => {
const snapshots = collectActivity();
const lease = startTurn();
const approval = (toolCallId: string): PermissionApprovalRequestContext =>
({
toolCallId,
toolName: 'Read',
action: 'read',
display: {},
turnId: 1,
toolInput: { path: '/tmp/example' },
}) as unknown as PermissionApprovalRequestContext;
eventBus.publish({ type: 'permission.approval.requested', ...approval('c1') });
eventBus.publish({ type: 'permission.approval.requested', ...approval('c2') });
expect(snapshots.at(-1)?.turn?.pendingApprovals.map((item) => item.toolCallId)).toEqual([
'c1',
'c2',
]);
eventBus.publish({
type: 'permission.approval.resolved',
...approval('c1'),
decision: 'approved',
});
expect(snapshots.at(-1)?.turn?.pendingApprovals.map((item) => item.toolCallId)).toEqual(['c2']);
lease.end('completed');
});
it('projects retry state for the active turn', () => {
const snapshots = collectActivity();
const lease = startTurn();
eventBus.publish({
type: 'turn.step.retrying',
turnId: 1,
step: 1,
stepId: 's1',
failedAttempt: 1,
nextAttempt: 2,
maxAttempts: 3,
delayMs: 500,
errorName: 'RateLimitError',
errorMessage: 'slow down',
statusCode: 429,
});
expect(snapshots.at(-1)?.turn).toMatchObject({
phase: 'retrying',
retry: {
failedAttempt: 1,
nextAttempt: 2,
maxAttempts: 3,
delayMs: 500,
errorName: 'RateLimitError',
statusCode: 429,
},
});
lease.end('completed');
});
it.each(['max_steps', 'error'] as const)(
'projects %s as the ending reason when a step is interrupted',
(reason) => {
const snapshots = collectActivity();
const lease = startTurn();
eventBus.publish({
type: 'turn.step.interrupted',
turnId: 1,
step: 1,
reason,
});
expect(snapshots.at(-1)?.turn).toMatchObject({
turnId: 1,
step: 1,
ending: true,
endingReason: reason,
});
lease.end('failed');
},
);
it('cancel is a no-op when idle', () => {
activity.markReady();
expect(activity.cancel()).toBe(false);
@ -106,19 +259,22 @@ describe('AgentActivityService (turn lane)', () => {
const lease = activity.begin('turn');
lease.end('completed');
expect(() => lease.end('completed')).not.toThrow();
expect(activity.lane()).toBe('idle');
expect(activity.isIdle()).toBe(true);
});
it('beginDisposal aborts the in-flight lease and settles after end', async () => {
const states = collectActivity();
activity.markReady();
const lease = activity.begin('turn');
activity.beginDisposal();
expect(lease.signal.aborted).toBe(true);
expect(activity.lane()).toBe('disposing');
expect(activity.isIdle()).toBe(false);
expect(states.at(-1)).toMatchObject({ lifecycle: 'disposing', turn: { turnId: lease.turnId } });
const settled = activity.settled();
lease.end('cancelled');
await settled;
expect(activity.lane()).toBe('disposed');
expect(activity.isIdle()).toBe(false);
expect(states.at(-1)).toMatchObject({ lifecycle: 'disposed', turn: undefined });
});
});
@ -126,25 +282,9 @@ describe('SessionActivityKernel (session lane)', () => {
let host: ReturnType<typeof createScopedTestHost>;
let kernel: ISessionActivityKernel;
function stubWire(): IWireService {
return {
_serviceBrand: undefined,
dispatch: () => undefined,
replay: () => Promise.resolve(),
flush: () => Promise.resolve(),
attach: () => ({ dispose: () => undefined }),
getModel: (model: { initial: () => unknown }) => model.initial(),
subscribe: () => ({ dispose: () => undefined }),
onEmission: () => ({ dispose: () => undefined }),
onRestored: () => ({ dispose: () => undefined }),
} as unknown as IWireService;
}
beforeEach(() => {
host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 'session', [
[ISessionWireService, stubWire()],
]);
const session = host.child(LifecycleScope.Session, 'session');
kernel = session.accessor.get(ISessionActivityKernel);
});

View file

@ -23,7 +23,7 @@ import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubWire } from '../loop/stubs';
@ -71,7 +71,7 @@ describe('AgentContextInjectorService', () => {
strict: true,
additionalServices: (reg) => {
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.defineInstance(IAgentWireService, stubWire());
reg.defineInstance(IWireService, stubWire());
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
},

View file

@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IWireService } from '#/wire/wire';
import {
IAgentContextMemoryService,
IAgentContextSizeService,
@ -25,7 +24,7 @@ describe('Agent context', () => {
context = ctx.get(IAgentContextMemoryService);
contextSize = ctx.get(IAgentContextSizeService);
profile = ctx.get(IAgentProfileService);
wire = ctx.get(IAgentWireService);
wire = ctx.get(IWireService);
});
afterEach(async () => {

View file

@ -14,7 +14,7 @@ import {
} from '#/agent/contextMemory/contextTranscript';
import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types';
import type { PersistedRecord } from '#/wire/wireService';
import type { WireRecord } from '#/wire/record';
function userMessage(text: string, origin?: PromptOrigin): ContextMessage {
return {
@ -29,15 +29,15 @@ function assistantMessage(text: string): ContextMessage {
return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] };
}
function appendMessage(message: ContextMessage): PersistedRecord {
function appendMessage(message: ContextMessage): WireRecord {
return { type: 'context.append_message', message };
}
function loopEvent(event: LoopRecordedEvent): PersistedRecord {
function loopEvent(event: LoopRecordedEvent): WireRecord {
return { type: 'context.append_loop_event', event };
}
function assistantStep(uuid: string, text: string): PersistedRecord[] {
function assistantStep(uuid: string, text: string): WireRecord[] {
return [
loopEvent({ type: 'step.begin', uuid }),
loopEvent({ type: 'content.part', stepUuid: uuid, part: { type: 'text', text } }),
@ -50,7 +50,7 @@ function compaction(
compactedCount: number,
keptUserMessageCount?: number,
keptHeadUserMessageCount?: number,
): PersistedRecord {
): WireRecord {
return {
type: 'context.apply_compaction',
summary,
@ -63,7 +63,7 @@ function compaction(
};
}
function undo(count: number): PersistedRecord {
function undo(count: number): WireRecord {
return { type: 'context.undo', count };
}

View file

@ -6,12 +6,10 @@ import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { stubWireRecord } from './stubs';
import { registerTestAgentWire } from '../../wire/stubs';
function textMessage(role: ContextMessage['role'], text: string): ContextMessage {
return {
@ -35,9 +33,8 @@ describe('message history (IAgentContextMemoryService)', () => {
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'message' }]));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
registerTestAgentWire(ix, 'wire/message-history', { eventBus: ix.get(IEventBus) });
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
});
afterEach(() => disposables.dispose());

View file

@ -32,9 +32,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'ctx-live';
@ -152,26 +153,25 @@ function buildHost(key: string): Host {
const ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(
IAgentWireService,
new SyncDescriptor(WireService, [
{ logScope: SCOPE, logKey: key },
]),
);
ix.stub(IAgentBlobService, blob);
ix.set(IEventBus, new SyncDescriptor(EventBusService));
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
log: ix.get(IAppendLogStore),
blob,
eventBus: ix.get(IEventBus),
});
return {
wire: ix.get(IAgentWireService),
wire,
svc: ix.get(IAgentContextMemoryService),
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
};
}
async function readRecords(log: IAppendLogStore, key = KEY): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
async function readRecords(log: IAppendLogStore, key = KEY): Promise<WireRecord[]> {
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -236,7 +236,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
});
it('folds v1 context.append_loop_event records into the ContextModel on replay', async () => {
const records: PersistedRecord[] = [
const records: WireRecord[] = [
{ type: 'context.append_message', message: userMessage('q') },
{ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: '0', step: 1 } },
{
@ -276,7 +276,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
@ -290,7 +295,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
});
it('replays v1 context.apply_compaction records with contextSummary as the model summary', async () => {
const records: PersistedRecord[] = [
const records: WireRecord[] = [
{ type: 'context.append_message', message: userMessage('old') },
{ type: 'context.append_message', message: userMessage('tail') },
{
@ -304,7 +309,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map(textOf)).toEqual(['model-facing summary', 'tail']);
@ -315,7 +325,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
});
it('replays new context.apply_compaction records with kept user messages before contextSummary', async () => {
const records: PersistedRecord[] = [
const records: WireRecord[] = [
{ type: 'context.append_message', message: userMessage('old user') },
{
type: 'context.append_message',
@ -338,7 +348,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']);
@ -349,7 +364,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
});
it('replays pre-contextSummary kept-user records without adding a new prefix', async () => {
const records: PersistedRecord[] = [
const records: WireRecord[] = [
{ type: 'context.append_message', message: userMessage('old user') },
{ type: 'context.append_message', message: userMessage('recent user') },
{
@ -363,7 +378,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']);
@ -380,7 +400,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
const records: PersistedRecord[] = [
const records: WireRecord[] = [
{ type: 'context.append_message', message: userMessage('old') },
{ type: 'context.append_message', message: userMessage('tail') },
{
@ -391,7 +411,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model).toHaveLength(2);
@ -420,7 +445,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
expect(mediaUrl(persisted)).not.toContain(big);
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
expect(blob.loadCalls).toBeGreaterThanOrEqual(1);
const rebuilt = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
@ -446,7 +476,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
disposables.add(replay.eventBus.subscribe('context.spliced', (event) => {
replayed.push({ start: event.start, deleteCount: event.deleteCount });
}));
await replay.wire.replay(...records);
await restoreTestAgentWire(
replay.wire,
replay.log,
testWireScope(SCOPE, REPLAY_KEY),
records,
);
expect(replayed).toHaveLength(0);
expect(replay.wire.getModel(ContextModel) as readonly ContextMessage[]).toHaveLength(2);
});

View file

@ -1,6 +1,6 @@
/**
* `contextMemory` test stubs shared doubles for `IAgentContextMemoryService` and its
* collaborator (`IAgentWireRecordService`).
* collaborator (`IWireService`).
*
* Lives under `test/` (not `src/`) so test-support code stays out of the
* production tree. Import from a relative path (`./stubs` or
@ -19,18 +19,9 @@ import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IWireService } from '#/wire/wire';
export function stubWireRecord(): IAgentWireRecordService {
return {
_serviceBrand: undefined,
seal: () => Promise.resolve(),
restore: () => Promise.resolve({}),
flush: () => Promise.resolve(),
close: () => Promise.resolve(),
getRecords: () => [],
};
}
import { stubAgentWire } from '../../wire/stubs';
export interface StubContextMemory extends IAgentContextMemoryService {
readonly messages: readonly ContextMessage[];
@ -124,7 +115,7 @@ class StubContextMemoryService implements IAgentContextMemoryService {
}
export function registerContextMemoryServices(reg: ServiceRegistration): void {
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.defineInstance(IWireService, stubAgentWire());
reg.define(IEventBus, EventBusService);
reg.define(IAgentContextMemoryService, StubContextMemoryService);
}

View file

@ -15,9 +15,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'full-compaction-test';
@ -30,9 +31,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve
const ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
});
return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
}
beforeEach(() => {
@ -44,9 +48,10 @@ beforeEach(() => {
afterEach(() => disposables.dispose());
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
async function readRecords(key = KEY): Promise<WireRecord[]> {
await wire.flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -97,7 +102,7 @@ describe('fullCompaction ops (wire-backed)', () => {
expect(wire.getModel(CompactionModel)).toBe(running);
});
it('replay rebuilds the phase silently (no emissions, no subscriber notifications)', async () => {
it('replay rebuilds the phase silently', async () => {
wire.dispatch(fullCompactionBegin({ source: 'manual' }));
wire.dispatch(fullCompactionComplete({}));
const records = await readRecords();
@ -107,27 +112,36 @@ describe('fullCompaction ops (wire-backed)', () => {
host.eventBus.subscribe((e) => {
emissions.push(e.type);
});
let modelChanges = 0;
host.wire.subscribe(CompactionModel, () => {
modelChanges += 1;
});
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'full-compaction-replay'),
records,
);
expect(host.wire.getModel(CompactionModel).phase).toBe('idle');
expect(emissions).toEqual([]);
expect(modelChanges).toBe(0);
const stranded = buildHost('full-compaction-stranded');
await stranded.wire.replay({ type: 'full_compaction.begin', source: 'auto' });
await restoreTestAgentWire(
stranded.wire,
stranded.log,
testWireScope(SCOPE, 'full-compaction-stranded'),
[{ 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 },
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'full-compaction-legacy-complete-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

@ -17,7 +17,7 @@ import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Ste
import { MessageStepRequest } from '#/agent/loop/stepRequest';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentUsageService } from '#/agent/usage/usage';
import type { PersistedWireRecord } from '#/agent/wireRecord/wireRecord';
import type { WireRecord } from '#/wire/record';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { APIConnectionError, APIStatusError } from '#/app/llmProtocol/errors';
import type { ToolCall } from '#/app/llmProtocol/message';
@ -38,7 +38,7 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st
import { stubLoopWithHooks, type StubLoop } from '../loop/stubs';
type GoalServiceTestManager = IAgentGoalService & AgentGoalService;
type GoalRecord = PersistedWireRecord & { type: `goal.${string}` };
type GoalRecord = WireRecord & { type: `goal.${string}` };
type AgentEvent = DomainEvent;
type GoalUpdatedEvent = Extract<AgentEvent, { type: 'goal.updated' }>;
type TurnEndedInput = {
@ -53,17 +53,17 @@ const zeroUsage: TokenUsage = {
output: 0,
};
function goalRecords(records: readonly PersistedWireRecord[]): readonly GoalRecord[] {
function goalRecords(records: readonly WireRecord[]): readonly GoalRecord[] {
return records.filter((record): record is GoalRecord => record.type.startsWith('goal.'));
}
async function restoreGoalRecords(
ctx: TestAgentContext,
goals: IAgentGoalService,
records: readonly PersistedWireRecord[],
records: readonly WireRecord[],
): Promise<void> {
goals.getGoal();
await ctx.restore(records as readonly PersistedWireRecord[]);
await ctx.restore(records as readonly WireRecord[]);
}
function makeTurn(id: number): Turn {
@ -146,7 +146,7 @@ describe('AgentGoalService', () => {
let ctx: TestAgentContext;
let context: IAgentContextMemoryService;
let goals: GoalServiceTestManager;
let records: PersistedWireRecord[];
let records: WireRecord[];
let events: GoalUpdatedEvent[];
let telemetry: TelemetryRecord[];
@ -241,7 +241,7 @@ describe('AgentGoalService', () => {
it('replaces an existing goal when replace is set', async () => {
const first = await goals.createGoal({ objective: 'first' });
const second = await goals.createGoal({ objective: 'second', replace: true });
await ctx.wireRecord.flush();
await ctx.wire.flush();
expect(second.goalId).not.toBe(first.goalId);
expect(goals.getGoal().goal?.objective).toBe('second');
@ -469,7 +469,7 @@ describe('AgentGoalService', () => {
await goals.markBlocked({ reason: 'stuck' });
await goals.resumeGoal();
await goals.cancelGoal();
await ctx.wireRecord.flush();
await ctx.wire.flush();
const recordsWithoutMetadata = goalRecords(records);
expect(recordsWithoutMetadata).toEqual([
@ -548,7 +548,7 @@ describe('AgentGoalService', () => {
status: 'paused',
terminalReason: 'Paused after agent resume',
});
expect(goalRecords(records)).toEqual([
expect(goalRecords(records).filter((record) => record.type === 'goal.update')).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',

View file

@ -21,9 +21,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'goal-test';
@ -102,7 +103,6 @@ function buildHost(key: string): {
const ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
ix.stub(IAgentLoopService, createLoopStub());
ix.stub(IAgentUsageService, {
@ -114,9 +114,13 @@ function buildHost(key: string): {
ix.stub(ITelemetryService, createTelemetryStub());
ix.stub(IAgentToolExecutorService, createToolExecutorStub());
ix.stub(IConfigService, createConfigStub());
ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}]));
ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService));
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
});
return {
wire: ix.get(IAgentWireService),
wire,
svc: ix.get(IAgentGoalService),
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
@ -134,9 +138,10 @@ beforeEach(() => {
afterEach(() => disposables.dispose());
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
async function readRecords(key = KEY): Promise<WireRecord[]> {
await wire.flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -179,24 +184,17 @@ describe('AgentGoalService (wire-backed)', () => {
expect(records.map((record) => record.type)).toEqual(['goal.create', 'goal.clear']);
});
it('goal.updated signal and model subscription are live-only and silent on replay', async () => {
it('goal.updated is live-only and silent on replay', async () => {
const signals: string[] = [];
const sub = eventBus.subscribe((e) => {
if (e.type === 'goal.updated') {
signals.push(e.type);
}
});
let modelChanges = 0;
const modelSub = wire.subscribe(GoalModel, () => {
modelChanges += 1;
});
await svc.createGoal({ objective: 'work' });
await svc.pauseGoal();
expect(signals.length).toBeGreaterThanOrEqual(2);
expect(modelChanges).toBeGreaterThanOrEqual(2);
sub.dispose();
modelSub.dispose();
const records = await readRecords();
const host = buildHost('goal-replay');
@ -206,37 +204,44 @@ describe('AgentGoalService (wire-backed)', () => {
replaySignals.push(e.type);
}
});
let replayModelChanges = 0;
host.wire.subscribe(GoalModel, () => {
replayModelChanges += 1;
});
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'goal-replay'),
records,
);
expect(modelOf(host.wire)?.status).toBe('paused');
expect(replaySignals).toEqual([]);
expect(replayModelChanges).toBe(0);
});
it('onRestored forces a replayed active goal to paused after replay', async () => {
it('onDidRestore forces a replayed active goal to paused after replay', async () => {
const created = await svc.createGoal({ objective: 'resume me' });
const records = await readRecords();
const host = buildHost('goal-restore');
void host.svc;
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'goal-restore'),
records,
);
expect(modelOf(host.wire)?.status).toBe('paused');
expect(modelOf(host.wire)?.terminalReason).toBe('Paused after agent resume');
expect(modelOf(host.wire)?.goalId).toBe(created.goalId);
const written = await (async () => {
const out: PersistedRecord[] = [];
for await (const record of host.log.read<PersistedRecord>(SCOPE, 'goal-restore')) {
const out: WireRecord[] = [];
for await (const record of host.log.read<WireRecord>(
testWireScope(SCOPE, 'goal-restore'),
AGENT_WIRE_RECORD_KEY,
)) {
out.push(record);
}
return out;
})();
expect(written).toEqual([
expect(written.filter((record) => record.type === 'goal.update')).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',

View file

@ -246,7 +246,7 @@ async function flushedGoalReminderRecords(
ctx: TestAgentContext,
persistence: InMemoryWireRecordPersistence,
) {
await ctx.wireRecord.flush();
await ctx.wire.flush();
return goalReminderRecords(persistence);
}

View file

@ -40,11 +40,12 @@ import type { LLMEvent, LLMRequestInput, Model } from '#/app/model/modelInstance
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ILogService } from '#/_base/log/log';
import { Error2, ErrorCodes } from '#/errors';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import type { WireRecord } from '#/wire/record';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs';
const capabilities: ModelCapability = {
image_in: false,
video_in: false,
@ -193,21 +194,15 @@ function createService(
ix.stub(IConfigService, config);
ix.stub(ILogService, log);
ix.stub(ITelemetryService, telemetry);
ix.set(
IAgentWireService,
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'strict-resend' }]),
);
const records: WireRecord[] = [];
registerTestAgentWire(ix, 'wire/llm-requester', { log: recordingWireLog(records) });
ix.set(IFaultInjectionService, new SyncDescriptor(FaultInjectionService));
ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService));
const records: PersistedRecord[] = [];
disposables.add(
ix.get(IAgentWireService).onEmission((emission) => records.push(emission.record)),
);
return {
service: ix.get(IAgentLLMRequesterService),
faultInjection: ix.get(IFaultInjectionService),
wire: ix.get(IWireService),
records,
};
}
@ -424,7 +419,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => {
it('records repeated-413 recovery projections on the sticky later request', async () => {
const calls = { value: 0 };
const { service, records } = createService(
const { service, wire, records } = createService(
createModel(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]),
{
project: (messages: readonly ContextMessage[]) => messages,
@ -436,6 +431,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => {
await service.request({ source: { type: 'turn', turnId: 1, step: 1 } });
await service.request({ source: { type: 'turn', turnId: 1, step: 2 } });
await wire.flush();
expect(
records

File diff suppressed because one or more lines are too long

View file

@ -10,7 +10,7 @@ import type { ContentPart } from '#/app/llmProtocol/message';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types';
import { createHooks } from '#/hooks';
import type { Op } from '#/wire/op';
import type { IWireService } from '#/wire/wireService';
import type { IWireService } from '#/wire/wire';
export interface StubLoopOptions { readonly hasActiveTurn?: boolean; readonly currentId?: string | number; readonly pendingTurnResult?: boolean }
export type StubLoop = IAgentLoopService & {
@ -75,5 +75,5 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop {
return stub;
}
export type StubWire = IWireService & { readonly ops: readonly Op[]; readonly steered: readonly { readonly input: readonly ContentPart[]; readonly origin?: PromptOrigin }[] };
export function stubWire(): StubWire { const ops: Op[] = []; const steered: { input: readonly ContentPart[]; origin?: PromptOrigin }[] = []; return { _serviceBrand: undefined, ops, steered, dispatch: (...incoming: Op[]) => { for (const op of incoming) { ops.push(op); if (op.type === 'turn.steer') steered.push(op.payload as never); } }, replay: async () => {}, signal: () => {}, flush: async () => {}, attach: () => toDisposable(() => {}), getModel: () => ({}), subscribe: () => toDisposable(() => {}), onEmission: () => toDisposable(() => {}), onRestored: () => toDisposable(() => {}) } as unknown as StubWire; }
export function stubWire(): StubWire { const ops: Op[] = []; const steered: { input: readonly ContentPart[]; origin?: PromptOrigin }[] = []; return { _serviceBrand: undefined, hooks: createHooks(['onDidRestore']), ops, steered, dispatch: (...incoming: Op[]) => { for (const op of incoming) { ops.push(op); if (op.type === 'turn.steer') steered.push(op.payload as never); } }, replay: async () => {}, signal: () => {}, flush: async () => {}, getModel: () => ({}), subscribe: () => toDisposable(() => {}), onEmission: () => toDisposable(() => {}) } as unknown as StubWire; }
export function stubToolExecutor(): IAgentToolExecutorService { return { _serviceBrand: undefined, execute: async function* () {}, hooks: createHooks(['onBeforeExecuteTool', 'onDidExecuteTool']) as IAgentToolExecutorService['hooks'], recordDupType: () => {}, registerUnavailableToolDescriber: () => ({ dispose() {} }), registerMissingToolDescriber: () => ({ dispose() {} }) }; }

View file

@ -16,11 +16,9 @@ import { ISessionMcpService } from '#/session/mcp/sessionMcp';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import type { McpOAuthService } from '#/agent/mcp/oauth/service';
import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import type { WireRecord } from '#/wire/record';
import { McpDiscoveryModel } from '#/agent/mcp/mcpDiscoveryOps';
import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord/wireRecord';
import { wireMetadata } from '#/agent/wireRecord/metadataOps';
import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
@ -33,6 +31,8 @@ import { createTestAgent, mcpServices, type TestAgentContext } from '../../harne
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
import { stubLoopWithHooks } from '../loop/stubs';
import { stubToolResultTruncationService } from '../toolResultTruncation/stubs';
import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs';
import { discoverTools, executeTool, fakeMcpClient } from './stubs';
const MCP_OUTPUT_TRUNCATED_TEXT =
@ -156,13 +156,15 @@ describe('AgentMcpService', () => {
let ix: TestInstantiationService;
let events: DomainEvent[];
let telemetryEvents: TelemetryRecord[];
let wire: WireService;
let wire: IWireService;
let wireRecordListeners: Set<(record: WireRecord) => void>;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
events = [];
telemetryEvents = [];
wireRecordListeners = new Set();
ix.stub(IEventBus, {
publish: (event) => {
events.push(event);
@ -174,8 +176,12 @@ describe('AgentMcpService', () => {
ix.set(IAgentToolExecutorService, new SyncDescriptor(AgentToolExecutorService));
ix.stub(IAgentToolResultTruncationService, stubToolResultTruncationService());
ix.stub(IAgentLoopService, stubLoopWithHooks());
wire = disposables.add(new WireService({ logScope: 'mcp-test', logKey: 'wire.jsonl' }));
ix.stub(IAgentWireService, wire);
wire = registerTestAgentWire(ix, 'mcp-test', {
eventBus: ix.get(IEventBus),
log: recordingWireLog([], (record) => {
for (const listener of wireRecordListeners) listener(record);
}),
});
});
afterEach(() => {
disposables.dispose();
@ -573,12 +579,13 @@ describe('AgentMcpService', () => {
off: { dispose(): void };
} {
const records: { type: string; [key: string]: unknown }[] = [];
const off = wire.onEmission((e) => {
if (e.record.type === 'mcp.tools_discovered') {
records.push(e.record as { type: string; [key: string]: unknown });
const listener = (record: WireRecord): void => {
if (record.type === 'mcp.tools_discovered') {
records.push(record as { type: string; [key: string]: unknown });
}
});
return { records, off };
};
wireRecordListeners.add(listener);
return { records, off: toDisposable(() => wireRecordListeners.delete(listener)) };
}
it('records tools/list once after restore and dedups unchanged reconnects', async () => {
@ -598,7 +605,8 @@ describe('AgentMcpService', () => {
try {
manager.connect('grafana');
expect(records).toHaveLength(0);
await wire.replay();
await wire.restore();
await wire.flush();
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
type: 'mcp.tools_discovered',
@ -613,6 +621,7 @@ describe('AgentMcpService', () => {
manager.setResolved('grafana', client, await discoverTools(client), new Set(), rawTools);
manager.connect('grafana');
await wire.flush();
expect(records).toHaveLength(2);
} finally {
off.dispose();
@ -636,7 +645,8 @@ describe('AgentMcpService', () => {
try {
manager.connect('grafana');
expect(records).toHaveLength(0);
await wire.replay();
await wire.restore();
await wire.flush();
expect(records).toHaveLength(1);
} finally {
off.dispose();
@ -662,7 +672,8 @@ describe('AgentMcpService', () => {
manager.connect('grafana');
enabledNames.clear();
enabledNames.add('mutated_after_observation');
await wire.replay();
await wire.restore();
await wire.flush();
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
@ -676,41 +687,6 @@ describe('AgentMcpService', () => {
}
});
it('flushes a parked discovery after the first live wire record on a fresh session', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient([RAW_QUERY]);
const rawTools = await client.listTools();
manager.setResolved(
'grafana',
client,
await discoverTools(client),
new Set(['query_range']),
rawTools,
);
createService(manager);
const { records, off } = collectDiscoveries();
try {
manager.connect('grafana');
expect(records).toHaveLength(0);
wire.dispatch(
wireMetadata({
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: 1,
}),
);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
type: 'mcp.tools_discovered',
serverName: 'grafana',
tools: rawTools,
enabledNames: ['query_range'],
});
} finally {
off.dispose();
}
});
it('re-records when only the collision outcome changes', async () => {
const manager = new FakeMcpManager();
const occupant = fakeMcpClient([RAW_QUERY]);
@ -724,7 +700,8 @@ describe('AgentMcpService', () => {
);
createService(manager);
manager.connect('graf.ana');
await wire.replay();
await wire.restore();
await wire.flush();
const { records, off } = collectDiscoveries();
try {
@ -738,11 +715,13 @@ describe('AgentMcpService', () => {
rawTools,
);
manager.connect('graf_ana');
await wire.flush();
expect(records).toHaveLength(1);
expect(records[0]!['collisions']).toHaveLength(1);
manager.disconnect('graf.ana');
manager.connect('graf_ana');
await wire.flush();
expect(records).toHaveLength(2);
expect(records[1]!['collisions']).toBeUndefined();
} finally {

View file

@ -16,9 +16,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'permission-mode-test';
@ -56,18 +57,19 @@ beforeEach(() => {
ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: KEY }]));
ix.stub(IAgentContextInjectorService, injectorStub);
ix.set(IAgentPermissionModeService, new SyncDescriptor(AgentPermissionModeService));
log = ix.get(IAppendLogStore);
registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log });
svc = ix.get(IAgentPermissionModeService);
});
afterEach(() => disposables.dispose());
async function readRecords(): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, KEY)) {
async function readRecords(): Promise<WireRecord[]> {
await ix.get(IWireService).flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, KEY), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -103,6 +105,9 @@ describe('AgentPermissionModeService (wire-backed)', () => {
expect(svc.mode).toBe('manual');
svc.setMode('manual');
expect(changes).toEqual([]);
svc.setMode('auto');
expect(svc.mode).toBe('auto');
expect(changes).toEqual([{ mode: 'auto', previousMode: 'manual' }]);
@ -121,6 +126,14 @@ describe('AgentPermissionModeService (wire-backed)', () => {
expect('payload' in records[0]!).toBe(false);
});
it('persists an explicitly configured manual mode when it matches the initial value', async () => {
svc.setMode('manual');
expect(await readRecords()).toEqual([
{ type: 'permission.set_mode', mode: 'manual', time: expect.any(Number) },
]);
});
it('registers auto-mode reminder injection through the injection service', async () => {
expect(registeredInjection?.name).toBe('permission_mode');
@ -186,21 +199,25 @@ describe('AgentPermissionModeService (wire-backed)', () => {
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: 'permission-mode-replay' }]),
);
const log2 = ix2.get(IAppendLogStore);
const fresh = ix2.get(IAgentWireService);
const fresh = registerTestAgentWire(ix2, testWireScope(SCOPE, 'permission-mode-replay'), {
log: log2,
});
void fresh.replay({ type: 'permission.set_mode', mode: 'auto' });
await restoreTestAgentWire(
fresh,
log2,
testWireScope(SCOPE, 'permission-mode-replay'),
[{ type: 'permission.set_mode', mode: 'auto' }],
);
expect(fresh.getModel(PermissionModeModel)).toBe('auto');
const written: PersistedRecord[] = [];
for await (const record of log2.read<PersistedRecord>(SCOPE, 'permission-mode-replay')) {
const written: WireRecord[] = [];
for await (const record of log2.read<WireRecord>(testWireScope(SCOPE, 'permission-mode-replay'), AGENT_WIRE_RECORD_KEY)) {
written.push(record);
}
expect(written).toEqual([]);
expect(written[0]).toMatchObject({ type: 'metadata' });
expect(written.slice(1)).toEqual([{ type: 'permission.set_mode', mode: 'auto' }]);
});
});

View file

@ -10,9 +10,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'permission-rules-test';
@ -41,17 +42,18 @@ beforeEach(() => {
ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: KEY }]));
ix.set(IAgentPermissionRulesService, new SyncDescriptor(AgentPermissionRulesService));
log = ix.get(IAppendLogStore);
registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log });
svc = ix.get(IAgentPermissionRulesService);
});
afterEach(() => disposables.dispose());
async function readRecords(): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, KEY)) {
async function readRecords(): Promise<WireRecord[]> {
await ix.get(IWireService).flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, KEY), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -120,27 +122,27 @@ describe('AgentPermissionRulesService (wire-backed)', () => {
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: 'permission-rules-replay' }]),
);
const log2 = ix2.get(IAppendLogStore);
const fresh = ix2.get(IAgentWireService);
const fresh = registerTestAgentWire(ix2, testWireScope(SCOPE, 'permission-rules-replay'), {
log: log2,
});
let changes = 0;
disposables.add(fresh.subscribe(PermissionRulesModel, () => (changes += 1)));
void fresh.replay(...records);
await restoreTestAgentWire(
fresh,
log2,
testWireScope(SCOPE, 'permission-rules-replay'),
records,
);
expect(fresh.getModel(PermissionRulesModel)).toEqual({
rules: [],
sessionApprovalRulePatterns: ['Bash(rm *)'],
});
expect(changes).toBe(0);
const written: PersistedRecord[] = [];
for await (const record of log2.read<PersistedRecord>(SCOPE, 'permission-rules-replay')) {
const written: WireRecord[] = [];
for await (const record of log2.read<WireRecord>(testWireScope(SCOPE, 'permission-rules-replay'), AGENT_WIRE_RECORD_KEY)) {
written.push(record);
}
expect(written).toEqual([]);
expect(written[0]).toMatchObject({ type: 'metadata' });
expect(written.slice(1)).toEqual(records);
});
});

File diff suppressed because one or more lines are too long

View file

@ -15,9 +15,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'plan-test';
@ -30,9 +31,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve
const ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
});
return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
}
beforeEach(() => {
@ -44,9 +48,10 @@ beforeEach(() => {
afterEach(() => disposables.dispose());
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
async function readRecords(key = KEY): Promise<WireRecord[]> {
await wire.flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -116,7 +121,7 @@ describe('plan ops (wire-backed)', () => {
expect(wire.getModel(PlanModel)).toBe(active);
});
it('replay rebuilds active state silently (no emissions, no subscriber notifications)', async () => {
it('replay rebuilds active state silently', async () => {
wire.dispatch(planModeEnter({ id: 'p1' }));
const records = await readRecords();
@ -125,23 +130,27 @@ describe('plan ops (wire-backed)', () => {
host.eventBus.subscribe((e) => {
emissions.push(e.type);
});
let modelChanges = 0;
host.wire.subscribe(PlanModel, () => {
modelChanges += 1;
});
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'plan-replay'),
records,
);
expect(host.wire.getModel(PlanModel)).toEqual({
active: true,
id: 'p1',
});
expect(emissions).toEqual([]);
expect(modelChanges).toBe(0);
const cancelled = buildHost('plan-replay-cancel');
await cancelled.wire.replay(
await restoreTestAgentWire(
cancelled.wire,
cancelled.log,
testWireScope(SCOPE, 'plan-replay-cancel'),
[
{ type: 'plan_mode.enter', id: 'p1', planFilePath: '/w/plan/p1.md' },
{ type: 'plan_mode.cancel', id: 'p1' },
],
);
expect(cancelled.wire.getModel(PlanModel).active).toBe(false);
});

View file

@ -6,7 +6,7 @@ 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 { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import {
InMemoryWireRecordPersistence,
@ -74,7 +74,7 @@ describe('AgentProfileService.bind', () => {
},
});
const svc = ctx.get(IAgentProfileService);
await ctx.get(IAgentWireService).flush();
await ctx.get(IWireService).flush();
const start = persistence.records.length;
await svc.bind({
@ -83,7 +83,7 @@ describe('AgentProfileService.bind', () => {
thinking: 'low',
cwd: homeDir,
});
await ctx.get(IAgentWireService).flush();
await ctx.get(IWireService).flush();
const records = persistence.records
.slice(start)

View file

@ -25,9 +25,10 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'profile-test';
@ -92,7 +93,6 @@ function buildHost(key: string): {
const host = disposables.add(new TestInstantiationService());
host.stub(IFileSystemStorageService, new InMemoryStorageService());
host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
host.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
host.stub(ITelemetryService, createTelemetryStub());
host.stub(IAgentTelemetryContextService, new AgentTelemetryContextService());
host.stub(IConfigService, createConfigStub());
@ -105,9 +105,12 @@ function buildHost(key: string): {
host.stub(IAgentProfileCatalogService, stubUnused());
host.stub(ISessionSkillCatalog, stubUnused());
host.set(IAgentProfileService, new SyncDescriptor(AgentProfileService));
const wire = registerTestAgentWire(host, testWireScope(SCOPE, key), {
log: host.get(IAppendLogStore),
});
return {
ix: host,
wire: host.get(IAgentWireService),
wire,
svc: host.get(IAgentProfileService),
log: host.get(IAppendLogStore),
};
@ -126,9 +129,10 @@ beforeEach(() => {
afterEach(() => disposables.dispose());
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
async function readRecords(key = KEY): Promise<WireRecord[]> {
await wire.flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -256,17 +260,26 @@ describe('AgentProfileService (wire-backed config.update)', () => {
},
});
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'profile-replay'),
records,
);
expect(modelOf(host.wire).cwd).toBe('/work');
expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME);
expect(replayChdir).toBe(0);
expect(replayEmits).toBe(0);
const written: PersistedRecord[] = [];
for await (const record of host.log.read<PersistedRecord>(SCOPE, 'profile-replay')) {
const written: WireRecord[] = [];
for await (const record of host.log.read<WireRecord>(
testWireScope(SCOPE, 'profile-replay'),
AGENT_WIRE_RECORD_KEY,
)) {
written.push(record);
}
expect(written).toEqual([]);
expect(written[0]).toMatchObject({ type: 'metadata' });
expect(written.slice(1)).toEqual(records);
});
it('replay rebuilds the resolved thinkingLevel without re-reading config', async () => {
@ -274,14 +287,24 @@ describe('AgentProfileService (wire-backed config.update)', () => {
const records = await readRecords();
const host = buildHost('profile-replay-thinking');
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'profile-replay-thinking'),
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' });
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'profile-replay-legacy-thinking-level'),
[{ type: 'config.update', thinkingLevel: 'high' }],
);
expect(modelOf(host.wire).thinkingLevel).toBe('high');
});
@ -289,11 +312,16 @@ describe('AgentProfileService (wire-backed config.update)', () => {
it('returns the persisted effort when a replayed model alias no longer resolves', async () => {
const host = buildHost('profile-replay-removed-model');
await host.wire.replay({
type: 'config.update',
modelAlias: 'removed-model',
thinkingEffort: 'high',
});
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'profile-replay-removed-model'),
[{
type: 'config.update',
modelAlias: 'removed-model',
thinkingEffort: 'high',
}],
);
expect(host.svc.getEffectiveThinkingLevel()).toBe('high');
});
@ -302,7 +330,12 @@ describe('AgentProfileService (wire-backed config.update)', () => {
const host = buildHost('profile-replay-conflicting-thinking-aliases');
await expect(
host.wire.replay({ type: 'config.update', thinkingEffort: 'low', thinkingLevel: 'high' }),
restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'profile-replay-conflicting-thinking-aliases'),
[{ type: 'config.update', thinkingEffort: 'low', thinkingLevel: 'high' }],
),
).rejects.toMatchObject({
code: 'profile.thinking_alias_conflict',
name: 'ProfileError',

View file

@ -24,7 +24,7 @@ import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { ErrorCodes, Error2 } from '#/errors';
import { createHooks } from '#/hooks';
import { IAgentWireService } from '#/wire/tokens';
import { IWireService } from '#/wire/wire';
import { stubContextMemory } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubToolExecutor, stubWire } from '../loop/stubs';
@ -49,7 +49,7 @@ function harness() {
strict: true, additionalServices: (reg) => {
reg.defineInstance(IAgentContextMemoryService, context);
reg.defineInstance(IAgentLoopService, loop);
reg.defineInstance(IAgentWireService, stubWire());
reg.defineInstance(IWireService, stubWire());
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
reg.defineInstance(IAgentFullCompactionService, fullCompaction);
reg.define(IEventBus, EventBusService);

View file

@ -1,228 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService';
import { type AgentPhase, IAgentRuntimeService } from '#/agent/runtime/runtime';
import { AgentRuntimeService } from '#/agent/runtime/runtimeService';
import { RuntimeModel } from '#/agent/runtime/runtimeOps';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
const SCOPE = 'wire';
const KEY = 'runtime-test';
let disposables: DisposableStore;
let ix: TestInstantiationService;
let log: IAppendLogStore;
let eventBus: IEventBus;
let svc: IAgentRuntimeService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: KEY }]));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
ix.set(IAgentRuntimeService, new SyncDescriptor(AgentRuntimeService));
log = ix.get(IAppendLogStore);
eventBus = ix.get(IEventBus);
svc = ix.get(IAgentRuntimeService);
});
afterEach(() => disposables.dispose());
function collect(): AgentPhase[] {
const phases: AgentPhase[] = [];
disposables.add(
eventBus.subscribe('agent.status.updated', (e: DomainEvent<'agent.status.updated'>) => {
if (e.phase !== undefined) phases.push(e.phase);
}),
);
return phases;
}
async function readRecords(): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, KEY)) {
out.push(record);
}
return out;
}
function startTurn(turnId = 1, step = 1, stepId = 's1'): void {
eventBus.publish({ type: 'turn.started', turnId, origin: USER_PROMPT_ORIGIN });
eventBus.publish({ type: 'turn.step.started', turnId, step, stepId });
}
const approval = {
toolCallId: 'c1',
toolName: 'Read',
action: 'read',
display: {},
turnId: 1,
toolInput: { path: '/tmp/x' },
} as unknown as PermissionApprovalRequestContext;
describe('AgentRuntimeService', () => {
it('starts idle', () => {
expect(svc.phase()).toEqual({ kind: 'idle' });
});
it('turn.started then turn.step.started → running with step cursor', () => {
const phases = collect();
startTurn();
expect(svc.phase()).toMatchObject({ kind: 'running', turnId: 1, step: 1, stepId: 's1' });
expect(phases.map((p) => p.kind)).toEqual(['running', 'running']);
});
it('first assistant.delta enters streaming(assistant); subsequent deltas are debounced', () => {
const phases = collect();
startTurn();
const baseline = phases.length;
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'he' });
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'llo' });
expect(svc.phase()).toMatchObject({ kind: 'streaming', stream: 'assistant' });
expect(phases.length).toBe(baseline + 1);
});
it('thinking.delta switches the stream variant', () => {
startTurn();
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'x' });
eventBus.publish({ type: 'thinking.delta', turnId: 1, delta: 'hmm' });
expect(svc.phase()).toMatchObject({ kind: 'streaming', stream: 'thinking' });
});
it('tool.call.delta → streaming(tool_call); tool.call.started → tool_call; tool.result → running', () => {
const phases = collect();
startTurn();
eventBus.publish({
type: 'tool.call.delta',
turnId: 1,
toolCallId: 'c1',
name: 'Read',
argumentsPart: '{',
});
expect(svc.phase()).toMatchObject({
kind: 'streaming',
stream: 'tool_call',
toolCallId: 'c1',
toolName: 'Read',
});
eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c1', name: 'Read', args: {} });
expect(svc.phase()).toMatchObject({ kind: 'tool_call', toolCallId: 'c1', name: 'Read' });
eventBus.publish({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'ok', isError: false });
expect(svc.phase()).toMatchObject({ kind: 'running', turnId: 1, step: 1 });
expect(phases.map((p) => p.kind)).toEqual([
'running',
'running',
'streaming',
'tool_call',
'running',
]);
});
it('turn.step.retrying → retrying with the backoff fields', () => {
startTurn();
eventBus.publish({
type: 'turn.step.retrying',
turnId: 1,
step: 1,
stepId: 's1',
failedAttempt: 1,
nextAttempt: 2,
maxAttempts: 3,
delayMs: 500,
errorName: 'RateLimitError',
errorMessage: 'slow down',
statusCode: 429,
});
expect(svc.phase()).toMatchObject({
kind: 'retrying',
failedAttempt: 1,
nextAttempt: 2,
maxAttempts: 3,
delayMs: 500,
errorName: 'RateLimitError',
statusCode: 429,
});
});
it('turn.step.interrupted → interrupted(reason)', () => {
startTurn();
eventBus.publish({ type: 'turn.step.interrupted', turnId: 1, step: 1, reason: 'aborted' });
expect(svc.phase()).toMatchObject({ kind: 'interrupted', reason: 'aborted' });
});
it('turn.ended → ended(reason)', () => {
startTurn();
eventBus.publish({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 123 });
expect(svc.phase()).toMatchObject({ kind: 'ended', turnId: 1, reason: 'completed', durationMs: 123 });
});
it('permission approval requests pause into awaiting_approval and resolve back to the prior phase', () => {
startTurn();
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'hi' });
expect(svc.phase().kind).toBe('streaming');
eventBus.publish({ type: 'permission.approval.requested', ...approval });
expect(svc.phase().kind).toBe('awaiting_approval');
eventBus.publish({ type: 'permission.approval.resolved', ...approval, decision: 'approved' });
expect(svc.phase()).toMatchObject({ kind: 'streaming', stream: 'assistant' });
});
it('never persists runtime.set_phase (live-only)', async () => {
startTurn();
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'a' });
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'b' });
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'c' });
eventBus.publish({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 1 });
expect(await readRecords()).toEqual([]);
});
it('fresh replay leaves the phase at idle silently (no persisted phase records)', async () => {
startTurn();
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'hi' });
eventBus.publish({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 5 });
const records = await readRecords();
expect(records).toEqual([]);
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: 'runtime-replay' }]));
ix2.set(IEventBus, new SyncDescriptor(EventBusService));
const fresh = ix2.get(IAgentWireService);
const bus2 = ix2.get(IEventBus);
const emitted: DomainEvent[] = [];
disposables.add(bus2.subscribe((e) => emitted.push(e)));
await fresh.replay(...records);
expect(fresh.getModel(RuntimeModel).phase).toEqual({ kind: 'idle' });
expect(emitted.filter((e) => e.type === 'agent.status.updated')).toHaveLength(0);
});
});

View file

@ -19,10 +19,9 @@ import {
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import type { Turn } from '#/agent/loop/loop';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { executeTool } from '../../tools/fixtures/execute-tool';
import { stubSkill } from '../../app/skillCatalog/stubs';
import { registerTestAgentWireServices } from '../../wire/stubs';
const COMMIT_SKILL = stubSkill('commit', {
description: 'commit changes',
@ -72,10 +71,7 @@ describe('AgentSkillService', () => {
undo: () => 0,
clear: () => {},
});
reg.defineInstance(
IAgentWireService,
new WireService({ logScope: 'wire', logKey: 'skill-test' }),
);
registerTestAgentWireServices(reg, 'wire/skill-test');
reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} });
reg.definePartialInstance(IAgentToolRegistryService, {
register: () => ({ dispose: () => {} }),
@ -167,10 +163,7 @@ describe('SkillTool', () => {
undo: () => 0,
clear: () => {},
});
reg.defineInstance(
IAgentWireService,
new WireService({ logScope: 'wire', logKey: 'skill-test' }),
);
registerTestAgentWireServices(reg, 'wire/skill-test');
reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} });
reg.definePartialInstance(IAgentToolRegistryService, {
register: () => ({ dispose: () => {} }),

View file

@ -8,7 +8,6 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/session/swarm/sessionSwarm';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
@ -20,19 +19,17 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IConfigService } from '#/app/config/config';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs';
import { stubContextMemory } from '../contextMemory/stubs';
import { executeTool } from '../../tools/fixtures/execute-tool';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
import { stubLoopWithHooks } from '../loop/stubs';
const signal = new AbortController().signal;
@ -78,13 +75,8 @@ describe('AgentSwarmService', () => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IAgentContextMemoryService, stubContextMemory());
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(
IAgentWireService,
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'swarm-test' }]),
);
ix.set(IEventBus, new SyncDescriptor(EventBusService));
ix.stub(IAgentLoopService, stubLoopWithHooks());
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
@ -94,7 +86,10 @@ describe('AgentSwarmService', () => {
run: async () => [],
cancel: () => {},
});
ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }));
registerTestAgentWire(ix, testWireScope('wire', 'swarm-test'), {
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
});
ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService));
ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService));
});
@ -123,8 +118,11 @@ describe('AgentSwarmService', () => {
swarm.enter('manual');
const log = ix.get(IAppendLogStore);
const records: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>('wire', 'swarm-test')) {
const records: WireRecord[] = [];
for await (const record of log.read<WireRecord>(
testWireScope('wire', 'swarm-test'),
AGENT_WIRE_RECORD_KEY,
)) {
records.push(record);
}
expect(records).toEqual([
@ -134,12 +132,15 @@ describe('AgentSwarmService', () => {
const ix2 = disposables.add(new TestInstantiationService());
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix2.set(
IAgentWireService,
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'swarm-replay' }]),
const fresh = registerTestAgentWire(ix2, testWireScope('wire', 'swarm-replay'), {
log: ix2.get(IAppendLogStore),
});
await restoreTestAgentWire(
fresh,
ix2.get(IAppendLogStore),
testWireScope('wire', 'swarm-replay'),
records,
);
const fresh = ix2.get(IAgentWireService);
void fresh.replay(...records);
expect(fresh.getModel(SwarmModel)).toBe('manual');
});
});

View file

@ -11,9 +11,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
import { WireService } from '#/wire/wireServiceImpl';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
const SCOPE = 'wire';
const KEY = 'task-test';
@ -26,9 +27,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve
const ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
});
return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
}
beforeEach(() => {
@ -40,9 +44,10 @@ beforeEach(() => {
afterEach(() => disposables.dispose());
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
const out: PersistedRecord[] = [];
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
async function readRecords(key = KEY): Promise<WireRecord[]> {
await wire.flush();
const out: WireRecord[] = [];
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
out.push(record);
}
return out;
@ -84,29 +89,28 @@ describe('task ops (wire-backed)', () => {
expect(after.get('t1')?.status).toBe('running');
});
it('replay rebuilds the task map from legacy task.* records silently (no emissions, no subscriber notifications)', async () => {
const records: PersistedRecord[] = [
it('replay rebuilds the task map from legacy task.* records silently', async () => {
const records: WireRecord[] = [
{ type: 'task.started', info: info('t1', 'running') },
{ type: 'task.terminated', info: info('t1', 'completed') },
{ type: 'task.started', info: info('t2', 'running') },
] as unknown as PersistedRecord[];
] as unknown as WireRecord[];
const host = buildHost('task-replay');
const emissions: string[] = [];
host.eventBus.subscribe((e) => {
emissions.push(e.type);
});
let modelChanges = 0;
host.wire.subscribe(TaskModel, () => {
modelChanges += 1;
});
await host.wire.replay(...records);
await restoreTestAgentWire(
host.wire,
host.log,
testWireScope(SCOPE, 'task-replay'),
records,
);
const model = host.wire.getModel(TaskModel);
expect(model.size).toBe(2);
expect(model.get('t1')?.status).toBe('completed');
expect(model.get('t2')?.status).toBe('running');
expect(emissions).toEqual([]);
expect(modelChanges).toBe(0);
});
});

View file

@ -38,15 +38,14 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { createHooks } from '#/hooks';
import { IWireService, type WireHooks } from '#/wire/wire';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { ITaskService } from '#/app/task/task';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs';
import { stubContextMemory } from '../contextMemory/stubs';
import { stubLoopWithHooks } from '../loop/stubs';
import type { TaskServiceTestManager } from './stubs';
@ -60,24 +59,21 @@ function fakeProcessTask(): AgentTask {
};
}
type RestoreHandler = Parameters<IWireService['onRestored']>[0];
type RestoreHook = IWireService['hooks']['onDidRestore'];
function stubWireService(captureRestored?: (handler: RestoreHandler) => void): IWireService {
function stubWireService(captureRestoreHook?: (hook: RestoreHook) => void): IWireService {
const hooks = createHooks<WireHooks, keyof WireHooks>(['onDidRestore']);
captureRestoreHook?.(hooks.onDidRestore);
return {
_serviceBrand: undefined,
hooks,
dispatch: () => {},
replay: async () => {},
signal: () => {},
seal: async () => {},
restore: async () => {},
flush: async () => {},
attach: () => toDisposable(() => {}),
getModel: () => new Map(),
getModel: (model) => model.initial() as never,
subscribe: () => toDisposable(() => {}),
onEmission: () => toDisposable(() => {}),
onRestored: (handler: RestoreHandler) => {
captureRestored?.(handler);
return toDisposable(() => {});
},
} as unknown as IWireService;
} as IWireService;
}
describe('AgentTaskService', () => {
@ -91,8 +87,7 @@ describe('AgentTaskService', () => {
ix = disposables.add(new TestInstantiationService());
eventBus = disposables.add(new EventBusService());
injectionProviders = new Map();
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IAgentWireService, stubWireService());
ix.stub(IWireService, stubWireService());
ix.stub(IEventBus, eventBus);
ix.stub(IAgentContextInjectorService, {
register: (name, provider) => {
@ -395,11 +390,10 @@ describe('AgentTaskService', () => {
agentId: string,
docs: IAtomicDocumentStore,
bytes: IFileSystemStorageService,
captureRestored?: (handler: RestoreHandler) => void,
captureRestoreHook?: (hook: RestoreHook) => void,
): TestInstantiationService {
const ix = disposables.add(new TestInstantiationService());
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IAgentWireService, stubWireService(captureRestored));
ix.stub(IWireService, stubWireService(captureRestoreHook));
ix.stub(IEventBus, disposables.add(new EventBusService()));
ix.stub(IAgentContextInjectorService, {
register: () => toDisposable(() => {}),
@ -503,12 +497,12 @@ describe('AgentTaskService', () => {
'output.log',
new TextEncoder().encode('legacy output'),
);
let restore!: RestoreHandler;
const main = buildAgentIx('main', docs, bytes, (handler) => {
restore = handler;
let restoreHook!: RestoreHook;
const main = buildAgentIx('main', docs, bytes, (hook) => {
restoreHook = hook;
}).get(IAgentTaskService);
await restore();
await restoreHook.run({});
expect(main.list(false)).toEqual([
expect.objectContaining({ taskId, description: 'legacy task', status: 'completed' }),
@ -540,12 +534,12 @@ describe('AgentTaskService', () => {
status: 'completed',
detached: true,
});
let restore!: RestoreHandler;
const subagent = buildAgentIx('agent-1', docs, bytes, (handler) => {
restore = handler;
let restoreHook!: RestoreHook;
const subagent = buildAgentIx('agent-1', docs, bytes, (hook) => {
restoreHook = hook;
}).get(IAgentTaskService);
await restore();
await restoreHook.run({});
expect(subagent.list(false)).toEqual([]);
});

View file

@ -18,14 +18,11 @@ import { IAgentToolExecutorService, type ToolExecutionResult } from '#/agent/too
import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { stubWireRecord } from '../contextMemory/stubs';
import { registerLogServices } from '../../_base/log/stubs';
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
import { stubLoopWithHooks } from '../loop/stubs';
import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs';
import { registerTestAgentWireServices } from '../../wire/stubs';
const { REMINDER_TEXT_1, REMINDER_TEXT_3, makeReminderText2 } = toolDedupeTesting;
const ZERO_USAGE = emptyUsage();
@ -57,6 +54,7 @@ function createHarness(telemetry: ITelemetryService = recordingTelemetry(telemet
const loop = stubLoopWithHooks();
const ix = createServices(disposables, {
additionalServices: (reg) => {
registerTestAgentWireServices(reg, 'wire/tool-dedupe');
reg.defineInstance(ITelemetryService, telemetry);
reg.defineInstance(IEventBus, noopEventBus);
const homedir = '/tmp/tool-dedupe-homedir';
@ -77,17 +75,11 @@ function createHarness(telemetry: ITelemetryService = recordingTelemetry(telemet
} satisfies IAgentScopeContext);
reg.defineInstance(IBootstrapService, {
homeDir: homedir,
agentHomedir: () => homedir,
} as unknown as IBootstrapService);
reg.defineInstance(IAgentLoopService, loop);
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
reg.define(IAgentToolExecutorService, AgentToolExecutorService);
registerToolResultTruncationServices(reg);
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.defineInstance(
IAgentWireService,
disposables.add(new WireService({ logScope: 'wire', logKey: 'tool-dedupe' })),
);
reg.define(IAgentToolDedupeService, AgentToolDedupeService);
registerLogServices(reg);
},

View file

@ -19,14 +19,11 @@ import { AgentToolExecutorService, parseToolCallArguments } from '#/agent/toolEx
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { stubWireRecord } from '../contextMemory/stubs';
import { registerLogServices } from '../../_base/log/stubs';
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
import { registerTestAgentWireServices } from '../../wire/stubs';
type ToolExecutorEvent =
| { readonly type: 'tool.result'; readonly toolCallId: string; readonly result: ToolResult };
@ -48,13 +45,9 @@ beforeEach(() => {
truncateForModel = async (input) => input.result;
ix = createServices(disposables, {
additionalServices: (reg) => {
registerTestAgentWireServices(reg, 'wire/tool-executor');
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
reg.define(IAgentToolExecutorService, AgentToolExecutorService);
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.defineInstance(
IAgentWireService,
disposables.add(new WireService({ logScope: 'wire', logKey: 'tool-executor' })),
);
reg.defineInstance(ITelemetryService, recordingTelemetry(telemetryEvents));
reg.defineInstance(IAgentToolResultTruncationService, {
_serviceBrand: undefined,

Some files were not shown because too many files have changed in this diff Show more