From 402ebee779e38d4e6e0ada43d05af2a5a519666c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 26 Jun 2026 16:51:23 +0800 Subject: [PATCH] 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` --- .../scripts/check-domain-layers.mjs | 1 + .../src/approval/approvalService.ts | 29 ++++---- packages/agent-core-v2/src/index.ts | 1 + .../agent-core-v2/src/interaction/index.ts | 8 ++ .../src/interaction/interaction.ts | 46 ++++++++++++ .../src/interaction/interactionService.ts | 74 +++++++++++++++++++ .../src/question/questionService.ts | 27 ++++--- .../src/session-activity/sessionActivity.ts | 13 +++- .../sessionActivityService.ts | 30 ++++++-- .../src/session-context/sessionContext.ts | 3 +- packages/agent-core-v2/src/session/session.ts | 14 ++-- .../src/session/sessionService.ts | 35 ++++++--- .../test/approval/approval.test.ts | 3 + .../test/interaction/interaction.test.ts | 65 ++++++++++++++++ .../test/question/question.test.ts | 3 + .../session-activity/sessionActivity.test.ts | 54 ++++++++++++++ .../test/session/session.test.ts | 43 ++++++++++- 17 files changed, 389 insertions(+), 60 deletions(-) create mode 100644 packages/agent-core-v2/src/interaction/index.ts create mode 100644 packages/agent-core-v2/src/interaction/interaction.ts create mode 100644 packages/agent-core-v2/src/interaction/interactionService.ts create mode 100644 packages/agent-core-v2/test/interaction/interaction.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 21efb6478..dd36a4508 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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], diff --git a/packages/agent-core-v2/src/approval/approvalService.ts b/packages/agent-core-v2/src/approval/approvalService.ts index 9a75718b3..b1c6acb21 100644 --- a/packages/agent-core-v2/src/approval/approvalService.ts +++ b/packages/agent-core-v2/src/approval/approvalService.ts @@ -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(); + + constructor(@IInteractionService private readonly interaction: IInteractionService) {} request(req: ApprovalRequest): Promise { - return new Promise((resolve) => { - this.pending.set(requestId(req), { req, resolve }); + return this.interaction.request({ + 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); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 1287a348c..71750db62 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/interaction/index.ts b/packages/agent-core-v2/src/interaction/index.ts new file mode 100644 index 000000000..fd38329d3 --- /dev/null +++ b/packages/agent-core-v2/src/interaction/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/interaction/interaction.ts b/packages/agent-core-v2/src/interaction/interaction.ts new file mode 100644 index 000000000..5a62748fb --- /dev/null +++ b/packages/agent-core-v2/src/interaction/interaction.ts @@ -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 { + readonly id?: string; + readonly kind: InteractionKind; + readonly payload: TPayload; + readonly origin?: InteractionOrigin; +} + +export interface Interaction { + readonly id: string; + readonly kind: InteractionKind; + readonly payload: TPayload; + readonly origin: InteractionOrigin; +} + +export interface IInteractionService { + readonly _serviceBrand: undefined; + request(req: InteractionRequest): Promise; + respond(id: string, response: unknown): void; + listPending(kind?: InteractionKind): readonly Interaction[]; + readonly onDidChange: Event; +} + +export const IInteractionService: ServiceIdentifier = + createDecorator('interactionService'); diff --git a/packages/agent-core-v2/src/interaction/interactionService.ts b/packages/agent-core-v2/src/interaction/interactionService.ts new file mode 100644 index 000000000..0e6de88ea --- /dev/null +++ b/packages/agent-core-v2/src/interaction/interactionService.ts @@ -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(); + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + private nextId = 0; + + request(req: InteractionRequest): Promise { + const id = req.id ?? this.generateId(); + const origin: InteractionOrigin = req.origin ?? {}; + return new Promise((resolve) => { + const interaction: Interaction = { + 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', +); diff --git a/packages/agent-core-v2/src/question/questionService.ts b/packages/agent-core-v2/src/question/questionService.ts index f80e669d9..b5a8b1b80 100644 --- a/packages/agent-core-v2/src/question/questionService.ts +++ b/packages/agent-core-v2/src/question/questionService.ts @@ -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(); + + constructor(@IInteractionService private readonly interaction: IInteractionService) {} request(req: QuestionRequest): Promise { - return new Promise((resolve) => { - this.pending.set(req.id, { req, resolve }); + return this.interaction.request({ + 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); } } diff --git a/packages/agent-core-v2/src/session-activity/sessionActivity.ts b/packages/agent-core-v2/src/session-activity/sessionActivity.ts index 62c43f8e4..09b2de0f1 100644 --- a/packages/agent-core-v2/src/session-activity/sessionActivity.ts +++ b/packages/agent-core-v2/src/session-activity/sessionActivity.ts @@ -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; } diff --git a/packages/agent-core-v2/src/session-activity/sessionActivityService.ts b/packages/agent-core-v2/src/session-activity/sessionActivityService.ts index f59f698c5..b1858ce4b 100644 --- a/packages/agent-core-v2/src/session-activity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session-activity/sessionActivityService.ts @@ -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; } } diff --git a/packages/agent-core-v2/src/session-context/sessionContext.ts b/packages/agent-core-v2/src/session-context/sessionContext.ts index eca3406cc..c983c07b0 100644 --- a/packages/agent-core-v2/src/session-context/sessionContext.ts +++ b/packages/agent-core-v2/src/session-context/sessionContext.ts @@ -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, - { sessionId, meta } satisfies ISessionContext, + { _serviceBrand: undefined, sessionId, meta } satisfies ISessionContext, ], ]; } diff --git a/packages/agent-core-v2/src/session/session.ts b/packages/agent-core-v2/src/session/session.ts index d315a3cef..aca785b9c 100644 --- a/packages/agent-core-v2/src/session/session.ts +++ b/packages/agent-core-v2/src/session/session.ts @@ -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; diff --git a/packages/agent-core-v2/src/session/sessionService.ts b/packages/agent-core-v2/src/session/sessionService.ts index 2b51fad78..b952cc979 100644 --- a/packages/agent-core-v2/src/session/sessionService.ts +++ b/packages/agent-core-v2/src/session/sessionService.ts @@ -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 { - throw new Error('TODO: SessionService.fork'); + throw new NotImplementedError('SessionService.fork'); } listChildren(): readonly IScopeHandle[] { return []; } compact(): Promise { - throw new Error('TODO: SessionService.compact'); + throw new NotImplementedError('SessionService.compact'); } undo(): Promise { - throw new Error('TODO: SessionService.undo'); + throw new NotImplementedError('SessionService.undo'); } - archive(): Promise { - throw new Error('TODO: SessionService.archive'); + async archive(): Promise { + 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 }, + }); } } diff --git a/packages/agent-core-v2/test/approval/approval.test.ts b/packages/agent-core-v2/test/approval/approval.test.ts index fc7bea7d4..e20d6d405 100644 --- a/packages/agent-core-v2/test/approval/approval.test.ts +++ b/packages/agent-core-v2/test/approval/approval.test.ts @@ -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()); diff --git a/packages/agent-core-v2/test/interaction/interaction.test.ts b/packages/agent-core-v2/test/interaction/interaction.test.ts new file mode 100644 index 000000000..be100ebc7 --- /dev/null +++ b/packages/agent-core-v2/test/interaction/interaction.test.ts @@ -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(); + }); +}); diff --git a/packages/agent-core-v2/test/question/question.test.ts b/packages/agent-core-v2/test/question/question.test.ts index e45b12d8e..e0b0fe126 100644 --- a/packages/agent-core-v2/test/question/question.test.ts +++ b/packages/agent-core-v2/test/question/question.test.ts @@ -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()); diff --git a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts index 8022ae65b..57080bc9d 100644 --- a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts +++ b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts @@ -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 { + 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'); + }); + }); }); diff --git a/packages/agent-core-v2/test/session/session.test.ts b/packages/agent-core-v2/test/session/session.test.ts index 02fe6e259..c64a394ed 100644 --- a/packages/agent-core-v2/test/session/session.test.ts +++ b/packages/agent-core-v2/test/session/session.test.ts @@ -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[] = []; + const removed: string[] = []; + const published: { type: string; payload: unknown }[] = []; + ix.stub(ISessionMetaStore, { + write: (patch: Record) => { + 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' } }, + ]); + }); });