mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
99bbd80925
commit
402ebee779
17 changed files with 389 additions and 60 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
8
packages/agent-core-v2/src/interaction/index.ts
Normal file
8
packages/agent-core-v2/src/interaction/index.ts
Normal 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';
|
||||
46
packages/agent-core-v2/src/interaction/interaction.ts
Normal file
46
packages/agent-core-v2/src/interaction/interaction.ts
Normal 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');
|
||||
74
packages/agent-core-v2/src/interaction/interactionService.ts
Normal file
74
packages/agent-core-v2/src/interaction/interactionService.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
65
packages/agent-core-v2/test/interaction/interaction.test.ts
Normal file
65
packages/agent-core-v2/test/interaction/interaction.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue