refactor(agent-core-v2): introduce session interaction kernel

- add Session-scoped `interaction` domain, a unified blocking
  request/response kernel (pending set + onDidChange) for
  human-in-the-loop requests
- rebuild `approval` and `question` as typed facades over the kernel;
  their public contracts are unchanged
- derive the full session phase (awaiting_approval / awaiting_question /
  running / idle) in `session-activity` from the kernel's pending set and
  each agent's active turn; move `SessionStatus` there
- implement `session` facade `archive()` (persist flag, remove agents,
  publish event) and brand `ISessionContext` with `_serviceBrand`
This commit is contained in:
haozhe.yang 2026-06-26 16:51:23 +08:00
parent 99bbd80925
commit 402ebee779
17 changed files with 389 additions and 60 deletions

View file

@ -107,6 +107,7 @@ const DOMAIN_LAYER = new Map([
['subagentHost', 5],
// L6 — coordination
['agent-lifecycle', 6],
['interaction', 6],
['session-context', 6],
['session-activity', 6],
['session', 6],

View file

@ -1,12 +1,13 @@
/**
* `approval` domain (L7) `IApprovalService` implementation.
*
* Owns the pending-approval set and resolves requests when a decision arrives.
* Bound at Session scope.
* Typed facade over the `interaction` kernel for approval requests; owns no
* pending state of its own (the kernel holds it). Bound at Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IInteractionService } from '#/interaction';
import {
type ApprovalRequest,
@ -14,30 +15,28 @@ import {
IApprovalService,
} from './approval';
interface Pending {
readonly req: ApprovalRequest;
readonly resolve: (decision: ApprovalResponse) => void;
}
export class ApprovalService implements IApprovalService {
declare readonly _serviceBrand: undefined;
private readonly pending = new Map<string, Pending>();
constructor(@IInteractionService private readonly interaction: IInteractionService) {}
request(req: ApprovalRequest): Promise<ApprovalResponse> {
return new Promise<ApprovalResponse>((resolve) => {
this.pending.set(requestId(req), { req, resolve });
return this.interaction.request<ApprovalRequest, ApprovalResponse>({
id: requestId(req),
kind: 'approval',
payload: req,
origin: { agentId: req.agentId, turnId: req.turnId },
});
}
decide(id: string, response: ApprovalResponse): void {
const entry = this.pending.get(id);
if (entry === undefined) return;
this.pending.delete(id);
entry.resolve(response);
this.interaction.respond(id, response);
}
listPending(): readonly ApprovalRequest[] {
return [...this.pending.values()].map((p) => p.req);
return this.interaction
.listPending('approval')
.map((i) => i.payload as ApprovalRequest);
}
}

View file

@ -32,6 +32,7 @@ export * from './background/index';
import './cron/index';
export * from './agent-lifecycle/index';
export * from './interaction/index';
export * from './session-context/index';
export * from './session-activity/index';
export * from './session/index';

View file

@ -0,0 +1,8 @@
/**
* `interaction` domain barrel re-exports the interaction contract
* (`interaction`) and its scoped service (`interactionService`). Importing this
* barrel registers the `IInteractionService` binding into the scope registry.
*/
export * from './interaction';
export * from './interactionService';

View file

@ -0,0 +1,46 @@
/**
* `interaction` domain (L6) blocking human-in-the-loop request kernel.
*
* Defines the `Interaction` model and the `IInteractionService` kernel that
* owns the session's pending interaction set: a unified, blocking request /
* response primitive (`request` `respond`) with change notification
* (`onDidChange`) and a `listPending` view. `approval` and `question` are
* typed specializations layered on top of this kernel; the kernel itself is
* domain-agnostic. Session-scoped the pending set is keyed by session and
* dies with it.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
export type InteractionKind = 'approval' | 'question';
export interface InteractionOrigin {
readonly agentId?: string;
readonly turnId?: number;
}
export interface InteractionRequest<TPayload = unknown> {
readonly id?: string;
readonly kind: InteractionKind;
readonly payload: TPayload;
readonly origin?: InteractionOrigin;
}
export interface Interaction<TPayload = unknown> {
readonly id: string;
readonly kind: InteractionKind;
readonly payload: TPayload;
readonly origin: InteractionOrigin;
}
export interface IInteractionService {
readonly _serviceBrand: undefined;
request<TPayload, TResponse>(req: InteractionRequest<TPayload>): Promise<TResponse>;
respond(id: string, response: unknown): void;
listPending(kind?: InteractionKind): readonly Interaction[];
readonly onDidChange: Event<void>;
}
export const IInteractionService: ServiceIdentifier<IInteractionService> =
createDecorator<IInteractionService>('interactionService');

View file

@ -0,0 +1,74 @@
/**
* `interaction` domain (L6) `IInteractionService` implementation.
*
* Owns the pending interaction set and resolves requests when a response
* arrives; announces add/remove through a typed `onDidChange`. Bound at
* Session scope.
*/
import { Emitter, type Event } from '#/_base/event';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
type Interaction,
type InteractionKind,
type InteractionOrigin,
type InteractionRequest,
IInteractionService,
} from './interaction';
interface Pending {
readonly interaction: Interaction;
readonly resolve: (response: unknown) => void;
}
export class InteractionService extends Disposable implements IInteractionService {
declare readonly _serviceBrand: undefined;
private readonly pending = new Map<string, Pending>();
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this._onDidChange.event;
private nextId = 0;
request<TPayload, TResponse>(req: InteractionRequest<TPayload>): Promise<TResponse> {
const id = req.id ?? this.generateId();
const origin: InteractionOrigin = req.origin ?? {};
return new Promise<TResponse>((resolve) => {
const interaction: Interaction<TPayload> = {
id,
kind: req.kind,
payload: req.payload,
origin,
};
this.pending.set(id, { interaction, resolve: resolve as (response: unknown) => void });
this._onDidChange.fire();
});
}
respond(id: string, response: unknown): void {
const entry = this.pending.get(id);
if (entry === undefined) return;
this.pending.delete(id);
entry.resolve(response);
this._onDidChange.fire();
}
listPending(kind?: InteractionKind): readonly Interaction[] {
const all = [...this.pending.values()].map((p) => p.interaction);
return kind === undefined ? all : all.filter((i) => i.kind === kind);
}
private generateId(): string {
return `interaction-${this.nextId++}`;
}
}
registerScopedService(
LifecycleScope.Session,
IInteractionService,
InteractionService,
InstantiationType.Delayed,
'interaction',
);

View file

@ -1,38 +1,37 @@
/**
* `question` domain (L7) `IQuestionService` implementation.
*
* Brokers ask-user requests and resolves their answers. Bound at Session scope.
* Typed facade over the `interaction` kernel for ask-user requests; owns no
* pending state of its own (the kernel holds it). Bound at Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IInteractionService } from '#/interaction';
import { type QuestionRequest, IQuestionService } from './question';
interface Pending {
readonly req: QuestionRequest;
readonly resolve: (answer: string) => void;
}
export class QuestionService implements IQuestionService {
declare readonly _serviceBrand: undefined;
private readonly pending = new Map<string, Pending>();
constructor(@IInteractionService private readonly interaction: IInteractionService) {}
request(req: QuestionRequest): Promise<string> {
return new Promise<string>((resolve) => {
this.pending.set(req.id, { req, resolve });
return this.interaction.request<QuestionRequest, string>({
id: req.id,
kind: 'question',
payload: req,
});
}
answer(id: string, answer: string): void {
const entry = this.pending.get(id);
if (entry === undefined) return;
this.pending.delete(id);
entry.resolve(answer);
this.interaction.respond(id, answer);
}
listPending(): readonly QuestionRequest[] {
return [...this.pending.values()].map((p) => p.req);
return this.interaction
.listPending('question')
.map((i) => i.payload as QuestionRequest);
}
}

View file

@ -1,15 +1,20 @@
/**
* `session-activity` domain (L6) session-level idle predicate.
* `session-activity` domain (L6) session-level activity and status.
*
* Defines the public contract of session activity: the `ISessionActivity` used
* to query whether the session is idle. Session-scoped one instance per
* session.
* Defines the public contract of session activity: the `SessionStatus` model
* and the `ISessionActivity` used to query the session's derived lifecycle
* phase (`status`) and whether it is idle (`isIdle`). Session-scoped one
* instance per session. The status is derived from the session's pending
* interactions and each agent's active turn; it owns no state.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting_question';
export interface ISessionActivity {
readonly _serviceBrand: undefined;
status(): SessionStatus;
isIdle(): boolean;
}

View file

@ -1,29 +1,45 @@
/**
* `session-activity` domain (L6) `ISessionActivity` implementation.
*
* Derives session idleness by checking each agent's turn; drives agent
* lifecycle through `agent-lifecycle` and observes turns through `turn`. Bound
* at Session scope.
* Derives the session's lifecycle phase from the pending interactions held by
* the `interaction` kernel (awaiting approval / question) and each agent's
* active turn (`turn`, reached through `agent-lifecycle` handles). Bound at
* Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { IInteractionService } from '#/interaction';
import { ITurnService } from '#/turn';
import { ISessionActivity } from './sessionActivity';
import { ISessionActivity, type SessionStatus } from './sessionActivity';
export class SessionActivity implements ISessionActivity {
declare readonly _serviceBrand: undefined;
constructor(@IAgentLifecycleService private readonly agents: IAgentLifecycleService) {}
constructor(
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@IInteractionService private readonly interaction: IInteractionService,
) {}
status(): SessionStatus {
if (this.interaction.listPending('approval').length > 0) return 'awaiting_approval';
if (this.interaction.listPending('question').length > 0) return 'awaiting_question';
if (this.hasActiveTurn()) return 'running';
return 'idle';
}
isIdle(): boolean {
return this.status() === 'idle';
}
private hasActiveTurn(): boolean {
for (const handle of this.agents.list()) {
const turn = handle.accessor.get(ITurnService);
if (turn.getActiveTurn() !== undefined) return false;
if (turn.getActiveTurn() !== undefined) return true;
}
return true;
return false;
}
}

View file

@ -11,6 +11,7 @@ import type { ScopeSeed } from '#/_base/di/scope';
import type { ISessionMetaStore } from '#/sessionMetaStore';
export interface ISessionContext {
readonly _serviceBrand: undefined;
readonly sessionId: string;
readonly meta: ISessionMetaStore;
}
@ -25,7 +26,7 @@ export function sessionContextSeed(
return [
[
ISessionContext as ServiceIdentifier<unknown>,
{ sessionId, meta } satisfies ISessionContext,
{ _serviceBrand: undefined, sessionId, meta } satisfies ISessionContext,
],
];
}

View file

@ -1,17 +1,19 @@
/**
* `session` domain (L6) session facade.
*
* Defines the public contract of a session: the `SessionStatus` model and the
* `ISessionService` used by upper layers to query status, manage child agents
* (`fork` / `listChildren`), and run session operations (`compact` / `undo` /
* `archive`). Session-scoped one instance per session. The agent loop itself
* is driven by `agent-lifecycle` and `turn`, not here.
* Defines the public contract of a session: the `ISessionService` used by upper
* layers to query status, manage child agents (`fork` / `listChildren`), and
* run session operations (`compact` / `undo` / `archive`). The `SessionStatus`
* model lives in `session-activity` and is re-exported here for convenience.
* Session-scoped one instance per session. The agent loop itself is driven
* by `agent-lifecycle` and `turn`, not here.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IScopeHandle } from '#/_base/di/scope';
import type { SessionStatus } from '#/session-activity';
export type SessionStatus = 'running' | 'idle' | 'awaiting_approval';
export type { SessionStatus };
export interface SessionMeta {
readonly id: string;

View file

@ -1,16 +1,19 @@
/**
* `session` domain (L6) `ISessionService` implementation.
*
* Owns the session's child-agent set and session-level operations; drives
* agent lifecycle through `agent-lifecycle`, broadcasts through `event`,
* persists session metadata through `sessionMetaStore`, and records activity
* through `session-activity`. Bound at Session scope.
* Owns the session's child-agent set and session-level operations; reads its
* identity through `session-context`, drives agent lifecycle through
* `agent-lifecycle`, broadcasts through `event`, persists session metadata
* through `sessionMetaStore`, and records activity through `session-activity`.
* Bound at Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { NotImplementedError } from '#/errors';
import { IEventService } from '#/event';
import { ISessionContext } from '#/session-context';
import { ISessionMetaStore } from '#/sessionMetaStore';
import { ISessionActivity } from '#/session-activity/sessionActivity';
@ -20,14 +23,15 @@ export class SessionService implements ISessionService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionMetaStore _meta: ISessionMetaStore,
@ISessionContext private readonly ctx: ISessionContext,
@ISessionMetaStore private readonly meta: ISessionMetaStore,
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
@ISessionActivity private readonly activity: ISessionActivity,
@IEventService _event: IEventService,
@IEventService private readonly event: IEventService,
) {}
status(): SessionStatus {
return this.activity.isIdle() ? 'idle' : 'running';
return this.activity.status();
}
agents(): readonly IScopeHandle[] {
@ -35,19 +39,26 @@ export class SessionService implements ISessionService {
}
fork(): Promise<IScopeHandle> {
throw new Error('TODO: SessionService.fork');
throw new NotImplementedError('SessionService.fork');
}
listChildren(): readonly IScopeHandle[] {
return [];
}
compact(): Promise<void> {
throw new Error('TODO: SessionService.compact');
throw new NotImplementedError('SessionService.compact');
}
undo(): Promise<void> {
throw new Error('TODO: SessionService.undo');
throw new NotImplementedError('SessionService.undo');
}
archive(): Promise<void> {
throw new Error('TODO: SessionService.archive');
async archive(): Promise<void> {
await this.meta.write({ archived: true });
for (const handle of this.agentLifecycle.list()) {
await this.agentLifecycle.remove(handle.id);
}
this.event.publish({
type: 'event.session.archived',
payload: { sessionId: this.ctx.sessionId },
});
}
}

View file

@ -7,6 +7,8 @@ import { TestInstantiationService } from '#/_base/di/test';
import type { ApprovalRequest } from '#/approval';
import { IApprovalService } from '#/approval';
import { ApprovalService } from '#/approval/approvalService';
import { IInteractionService } from '#/interaction';
import { InteractionService } from '#/interaction/interactionService';
const display: ToolInputDisplay = { kind: 'command', command: 'bash' };
@ -21,6 +23,7 @@ describe('ApprovalService', () => {
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.set(IInteractionService, new SyncDescriptor(InteractionService));
ix.set(IApprovalService, new SyncDescriptor(ApprovalService));
});
afterEach(() => disposables.dispose());

View file

@ -0,0 +1,65 @@
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 { IInteractionService } from '#/interaction/interaction';
import { InteractionService } from '#/interaction/interactionService';
describe('InteractionService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.set(IInteractionService, new SyncDescriptor(InteractionService));
});
afterEach(() => disposables.dispose());
it('request blocks until respond resolves it', async () => {
const svc = ix.get(IInteractionService);
const pending = svc.request<{ n: number }, string>({
kind: 'question',
payload: { n: 1 },
});
expect(svc.listPending()).toHaveLength(1);
svc.respond(svc.listPending()[0]!.id, 'ok');
await expect(pending).resolves.toBe('ok');
expect(svc.listPending()).toHaveLength(0);
});
it('uses the caller-provided id for correlation', async () => {
const svc = ix.get(IInteractionService);
const pending = svc.request({ id: 'tool-1', kind: 'approval', payload: {} });
expect(svc.listPending()[0]!.id).toBe('tool-1');
svc.respond('tool-1', { decision: 'approved' });
await expect(pending).resolves.toEqual({ decision: 'approved' });
});
it('listPending filters by kind', () => {
const svc = ix.get(IInteractionService);
void svc.request({ kind: 'approval', payload: {} });
void svc.request({ kind: 'question', payload: {} });
expect(svc.listPending('approval')).toHaveLength(1);
expect(svc.listPending('question')).toHaveLength(1);
expect(svc.listPending()).toHaveLength(2);
});
it('onDidChange fires on request and on respond', async () => {
const svc = ix.get(IInteractionService);
let count = 0;
disposables.add(svc.onDidChange(() => count++));
const pending = svc.request({ kind: 'question', payload: {} });
expect(count).toBe(1);
svc.respond(svc.listPending()[0]!.id, 'x');
await pending;
expect(count).toBe(2);
});
it('respond to an unknown id is a no-op', () => {
const svc = ix.get(IInteractionService);
expect(() => svc.respond('nope', 'x')).not.toThrow();
});
});

View file

@ -3,6 +3,8 @@ 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 { IInteractionService } from '#/interaction';
import { InteractionService } from '#/interaction/interactionService';
import { IQuestionService } from '#/question';
import { QuestionService } from '#/question/questionService';
@ -13,6 +15,7 @@ describe('QuestionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.set(IInteractionService, new SyncDescriptor(InteractionService));
ix.set(IQuestionService, new SyncDescriptor(QuestionService));
});
afterEach(() => disposables.dispose());

View file

@ -6,6 +6,7 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { IInteractionService, type Interaction, type InteractionKind } from '#/interaction';
import { ISessionActivity } from '#/session-activity/sessionActivity';
import { SessionActivity } from '#/session-activity/sessionActivityService';
import { ITurnService, type Turn } from '#/turn';
@ -60,6 +61,22 @@ function lifecycle(handles: readonly IScopeHandle[]): IAgentLifecycleService {
};
}
function interactions(
pending: { approval?: number; question?: number },
): Partial<IInteractionService> {
const items: Interaction[] = [];
for (let i = 0; i < (pending.approval ?? 0); i++) {
items.push({ id: `a${i}`, kind: 'approval', payload: {}, origin: {} });
}
for (let i = 0; i < (pending.question ?? 0); i++) {
items.push({ id: `q${i}`, kind: 'question', payload: {}, origin: {} });
}
return {
listPending: (kind?: InteractionKind) =>
kind === undefined ? items : items.filter((i) => i.kind === kind),
};
}
describe('SessionActivity', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
@ -68,6 +85,7 @@ describe('SessionActivity', () => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.set(ISessionActivity, new SyncDescriptor(SessionActivity));
ix.stub(IInteractionService, interactions({}));
});
afterEach(() => disposables.dispose());
@ -88,4 +106,40 @@ describe('SessionActivity', () => {
);
expect(ix.get(ISessionActivity).isIdle()).toBe(false);
});
describe('status', () => {
it('idle when nothing is pending and no turn is active', () => {
ix.stub(IAgentLifecycleService, lifecycle([handle('a', false)]));
expect(ix.get(ISessionActivity).status()).toBe('idle');
});
it('running when an agent has an active turn', () => {
ix.stub(IAgentLifecycleService, lifecycle([handle('a', true)]));
expect(ix.get(ISessionActivity).status()).toBe('running');
});
it('awaiting_approval when an approval interaction is pending', () => {
ix.stub(IAgentLifecycleService, lifecycle([handle('a', false)]));
ix.stub(IInteractionService, interactions({ approval: 1 }));
expect(ix.get(ISessionActivity).status()).toBe('awaiting_approval');
});
it('awaiting_question when a question interaction is pending', () => {
ix.stub(IAgentLifecycleService, lifecycle([handle('a', false)]));
ix.stub(IInteractionService, interactions({ question: 1 }));
expect(ix.get(ISessionActivity).status()).toBe('awaiting_question');
});
it('approval takes priority over question', () => {
ix.stub(IAgentLifecycleService, lifecycle([handle('a', false)]));
ix.stub(IInteractionService, interactions({ approval: 1, question: 1 }));
expect(ix.get(ISessionActivity).status()).toBe('awaiting_approval');
});
it('question takes priority over running', () => {
ix.stub(IAgentLifecycleService, lifecycle([handle('a', true)]));
ix.stub(IInteractionService, interactions({ question: 1 }));
expect(ix.get(ISessionActivity).status()).toBe('awaiting_question');
});
});
});

View file

@ -7,6 +7,7 @@ import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { IEventService } from '#/event';
import { ISessionContext } from '#/session-context';
import { ISessionMetaStore } from '#/sessionMetaStore';
import { ISessionActivity } from '#/session-activity/sessionActivity';
import { ISessionService } from '#/session';
@ -25,6 +26,7 @@ describe('SessionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(ISessionContext, { sessionId: 's1', meta: {} as ISessionMetaStore });
ix.stub(ISessionMetaStore, {});
ix.stub(IEventService, {});
ix.set(ISessionService, new SyncDescriptor(SessionService));
@ -44,7 +46,11 @@ describe('SessionService', () => {
list: () => [handle],
remove: () => Promise.resolve(),
});
ix.stub(ISessionActivity, { _serviceBrand: undefined, isIdle: () => idle });
ix.stub(ISessionActivity, {
_serviceBrand: undefined,
isIdle: () => idle,
status: () => (idle ? 'idle' : 'running'),
});
return ix.createInstance(SessionService);
}
@ -56,4 +62,39 @@ describe('SessionService', () => {
it('agents delegates to lifecycle', () => {
expect(make(true).agents()).toEqual([handle]);
});
it('archive persists the flag, removes agents, and publishes the event', async () => {
const writes: Record<string, unknown>[] = [];
const removed: string[] = [];
const published: { type: string; payload: unknown }[] = [];
ix.stub(ISessionMetaStore, {
write: (patch: Record<string, unknown>) => {
writes.push(patch);
return Promise.resolve();
},
});
ix.stub(IAgentLifecycleService, {
_serviceBrand: undefined,
create: () => Promise.resolve(handle),
createMain: () => Promise.resolve(handle),
getHandle: () => handle,
list: () => [handle],
remove: (id: string) => {
removed.push(id);
return Promise.resolve();
},
});
ix.stub(ISessionActivity, { _serviceBrand: undefined, isIdle: () => true });
ix.stub(IEventService, {
publish: (event: { type: string; payload: unknown }) => published.push(event),
});
await ix.createInstance(SessionService).archive();
expect(writes).toEqual([{ archived: true }]);
expect(removed).toEqual(['main']);
expect(published).toEqual([
{ type: 'event.session.archived', payload: { sessionId: 's1' } },
]);
});
});