kimi-code / packages /agent-core-v2 /test /session /sessionActivity /sessionActivityService.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f0634fb verified
Raw History Blame Contribute Delete
14.9 kB
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import {
_clearScopedRegistryForTests,
ScopeActivation,
registerScopedService,
type IAgentScopeHandle,
type Scope,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test';
import { Emitter } from '#/_base/event';
import { IEventBus } from '#/app/event/eventBus';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { OrderedHookSlot } from '#/hooks';
import type { Event2, Event2Class } from '#/app/event/event2';
import type { AgentContext } from '#/agent/agentContext/agentContext';
import { IAgentLoopService } from '#/agent/loop/loop';
import { TurnStarted } from '#/agent/loop/turnEvents';
import { TurnEnded, turnKey } from '#/agent/loop/turnOps';
import { IAgentTaskService } from '#/agent/task/task';
import { TaskStarted, TaskTerminatedNotice } from '#/agent/task/taskOps';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
import { IAgentStateService } from '#/agent/state/agentState';
import { AgentStateService } from '#/agent/state/agentStateService';
import { interactions } from '#/human/interaction/facade';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import {
ISessionActivityView,
type SessionActivityChangedEvent,
} from '#/session/sessionActivity/sessionActivity';
import { SessionActivityView } from '#/session/sessionActivity/sessionActivityService';
import { ISessionStateService } from '#/session/state/sessionState';
import { SessionStateService } from '#/session/state/sessionStateService';
import { IWorkspaceStateService } from '#/workspace/state/workspaceState';
import { WorkspaceStateService } from '#/workspace/state/workspaceStateService';
import { stubAgentContext } from '../../agent/agentContext/stubs';
const SESSION_ID = 'session-a';
class FakeBus implements IEventBus {
declare readonly _serviceBrand: undefined;
private readonly handlers = new Set<{ type?: string; fn: (event: Event2) => void }>();
publish(event: Event2): void {
for (const h of [...this.handlers]) {
if (h.type === undefined || h.type === event.type) h.fn(event);
}
}
subscribe(arg1: unknown, arg2?: unknown): IDisposable {
const entry =
typeof arg1 === 'string'
? { type: arg1, fn: arg2 as (event: Event2) => void }
: typeof arg1 === 'function' && 'type' in arg1
? { type: (arg1 as Event2Class).type, fn: arg2 as (event: Event2) => void }
: { fn: arg1 as (event: Event2) => void };
this.handlers.add(entry);
return { dispose: () => this.handlers.delete(entry) };
}
}
class FakeAgentHandle {
readonly kind = LifecycleScope.Agent;
readonly bus = new FakeBus();
readonly state = new AgentStateService();
readonly restoreSlot = new OrderedHookSlot<Record<string, never>>();
loopState: 'idle' | 'running' = 'idle';
activeTasks: string[] = [];
compactingValue: unknown = null;
readonly context: AgentContext;
readonly accessor;
constructor(readonly id: string) {
this.context = stubAgentContext(id, 1);
this.accessor = {
get: (token: unknown) => {
if (token === IEventBus) return this.bus;
if (token === IEventDispatcher) return { hooks: { onDidRestore: this.restoreSlot } };
if (token === IAgentLoopService) {
return { snapshot: () => ({ state: this.loopState }) };
}
if (token === IAgentTaskService) {
return { list: () => this.activeTasks.map((taskId) => ({ taskId })) };
}
if (token === IAgentFullCompactionService) {
return { compacting: this.compactingValue };
}
if (token === IAgentStateService) return this.state;
return undefined;
},
};
}
startTurn(turnId: number): void {
this.loopState = 'running';
this.bus.publish(new TurnStarted({ agentId: this.id, turnId, origin: { kind: 'user' } }));
}
endTurn(turnId: number, reason: 'completed' | 'cancelled' | 'failed' | 'blocked'): void {
this.loopState = 'idle';
this.bus.publish(new TurnEnded({ agentId: this.id, turnId, reason }));
}
startTask(taskId: string): void {
this.activeTasks.push(taskId);
this.bus.publish(
new TaskStarted({
agentId: this.id,
info: {
taskId,
description: taskId,
status: 'running',
startedAt: Date.now(),
endedAt: null,
kind: 'process',
command: taskId,
pid: 0,
exitCode: null,
},
}),
);
}
terminateTask(taskId: string): void {
this.activeTasks = this.activeTasks.filter((id) => id !== taskId);
this.bus.publish(
new TaskTerminatedNotice({
agentId: this.id,
info: {
taskId,
description: taskId,
status: 'completed',
startedAt: Date.now(),
endedAt: Date.now(),
kind: 'process',
command: taskId,
pid: 0,
exitCode: null,
},
}),
);
}
async runRestore(): Promise<void> {
await this.restoreSlot.run({});
}
dispose(): void {}
}
class FakeAgentLifecycle implements IAgentLifecycleService {
declare readonly _serviceBrand: undefined;
private readonly createEmitter = new Emitter<AgentContext>();
private readonly createScopeEmitter = new Emitter<{
readonly context: AgentContext;
readonly handle: IAgentScopeHandle;
}>();
private readonly willCloseEmitter = new Emitter<AgentContext>();
private readonly didCloseEmitter = new Emitter<AgentContext>();
readonly onDidCreate = this.createEmitter.event;
readonly onDidCreateScope = this.createScopeEmitter.event;
readonly onWillClose = this.willCloseEmitter.event;
readonly onDidClose = this.didCloseEmitter.event;
readonly handles: FakeAgentHandle[] = [];
list(): readonly AgentContext[] {
return this.handles.map((handle) => handle.context);
}
get(agentId: string): AgentContext | undefined {
return this.handles.find((h) => h.id === agentId)?.context;
}
handleOf(agentId: string): IAgentScopeHandle | undefined {
return this.handles.find((h) => h.id === agentId) as IAgentScopeHandle | undefined;
}
addAgent(id: string): FakeAgentHandle {
const handle = new FakeAgentHandle(id);
this.handles.push(handle);
const scopeHandle = handle as unknown as IAgentScopeHandle;
this.createEmitter.fire(handle.context);
this.createScopeEmitter.fire({ context: handle.context, handle: scopeHandle });
return handle;
}
removeAgent(id: string): void {
const index = this.handles.findIndex((h) => h.id === id);
if (index < 0) return;
const [handle] = this.handles.splice(index, 1);
this.willCloseEmitter.fire(handle!.context);
this.didCloseEmitter.fire(handle!.context);
}
create(): Promise<AgentContext> {
throw new Error('not implemented');
}
fork(): Promise<AgentContext> {
throw new Error('not implemented');
}
remove(): Promise<void> {
throw new Error('not implemented');
}
broadcastPermissionMode(): void {
throw new Error('not implemented');
}
adopt(): AgentContext {
throw new Error('not implemented');
}
}
describe('ISessionActivityView (Session scope aggregate of agent activity + interactions)', () => {
let disposables: DisposableStore;
let host: ScopedTestHost;
let session: Scope;
let lifecycle: FakeAgentLifecycle;
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state');
registerScopedService(LifecycleScope.Session, IAgentLifecycleService, FakeAgentLifecycle, ScopeActivation.OnDemand, 'agentLifecycle');
registerScopedService(LifecycleScope.Session, ISessionActivityView, SessionActivityView, ScopeActivation.OnScopeCreated, 'sessionActivity');
disposables = new DisposableStore();
host = createScopedTestHost();
session = host.child(LifecycleScope.Session, SESSION_ID, [
stubPair(IWorkspaceStateService, new WorkspaceStateService()),
stubPair(ISessionContext, { sessionId: SESSION_ID } as ISessionContext),
]);
lifecycle = session.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle;
});
afterEach(() => {
disposables.dispose();
host.dispose();
interactions.purgeSession(SESSION_ID);
});
function viewWithChanges(): {
view: ISessionActivityView;
changes: SessionActivityChangedEvent[];
} {
const changes: SessionActivityChangedEvent[] = [];
const view = session.accessor.get(ISessionActivityView);
disposables.add(view.onDidChange((change) => changes.push(change)));
return { view, changes };
}
it('starts idle when no agent has work', () => {
lifecycle.addAgent(MAIN_AGENT_ID);
const { view } = viewWithChanges();
expect(view.state()).toEqual({
busy: false,
mainTurnActive: false,
pendingInteraction: 'none',
lastTurnReason: undefined,
});
});
it('seeds the aggregate from agents already active at construction', () => {
const seededLifecycle = new FakeAgentLifecycle();
const main = seededLifecycle.addAgent(MAIN_AGENT_ID);
main.loopState = 'running';
const seededSession = host.child(LifecycleScope.Session, 'session-seeded', [
stubPair(IAgentLifecycleService, seededLifecycle),
stubPair(IWorkspaceStateService, new WorkspaceStateService()),
stubPair(ISessionContext, { sessionId: 'session-seeded' } as ISessionContext),
]);
const view = seededSession.accessor.get(ISessionActivityView);
expect(view.state().busy).toBe(true);
expect(view.state().mainTurnActive).toBe(true);
});
it('fires turn_started when the main agent begins a turn', () => {
const main = lifecycle.addAgent(MAIN_AGENT_ID);
const { changes } = viewWithChanges();
main.startTurn(1);
expect(changes).toEqual([
{
state: { busy: true, mainTurnActive: true, pendingInteraction: 'none', lastTurnReason: undefined },
cause: 'turn_started',
},
]);
});
it('fires turn_ended with the mapped outcome when the main agent ends a turn', () => {
const main = lifecycle.addAgent(MAIN_AGENT_ID);
const { changes } = viewWithChanges();
main.startTurn(1);
main.endTurn(1, 'completed');
expect(changes.at(-1)).toEqual({
state: { busy: false, mainTurnActive: false, pendingInteraction: 'none', lastTurnReason: 'completed' },
cause: 'turn_ended',
});
main.startTurn(2);
main.endTurn(2, 'blocked');
expect(changes.at(-1)?.state.lastTurnReason).toBe('failed');
});
it('tracks subagent turns in busy without touching the main-agent slices', () => {
const sub = lifecycle.addAgent('agent-0');
const { view, changes } = viewWithChanges();
sub.startTurn(1);
expect(view.state().busy).toBe(true);
expect(view.state().mainTurnActive).toBe(false);
expect(view.state().lastTurnReason).toBeUndefined();
sub.endTurn(1, 'completed');
expect(changes).toHaveLength(2);
expect(view.state().busy).toBe(false);
expect(view.state().lastTurnReason).toBeUndefined();
});
it('fires background when live background work changes without a turn', () => {
const main = lifecycle.addAgent(MAIN_AGENT_ID);
const { view, changes } = viewWithChanges();
main.startTask('t1');
expect(changes).toEqual([
{
state: { busy: true, mainTurnActive: false, pendingInteraction: 'none', lastTurnReason: undefined },
cause: 'background',
},
]);
main.terminateTask('ghost');
expect(changes).toHaveLength(1);
main.terminateTask('t1');
expect(view.state().busy).toBe(false);
});
it('re-seeds from restored agent state when restore completes', async () => {
const main = lifecycle.addAgent(MAIN_AGENT_ID);
const { view, changes } = viewWithChanges();
main.activeTasks.push('t-restored');
main.state.contributeState(turnKey);
main.state.set(turnKey, {
nextTurnId: 1,
cancelledTurnIds: [],
anchorTurnIds: [],
lastEnded: { turnId: 0, reason: 'completed' },
});
await main.runRestore();
expect(view.state()).toEqual({
busy: true,
mainTurnActive: false,
pendingInteraction: 'none',
lastTurnReason: 'completed',
});
expect(changes.at(-1)?.cause).toBe('agent_lifecycle');
});
it('does not fire when the aggregate is unchanged (phase churn inside a turn)', () => {
const main = lifecycle.addAgent(MAIN_AGENT_ID);
const { changes } = viewWithChanges();
main.startTurn(1);
main.startTurn(1);
expect(changes).toHaveLength(1);
});
it('fires interaction when the pending set flips the session slice', () => {
lifecycle.addAgent(MAIN_AGENT_ID);
const { changes } = viewWithChanges();
interactions.enqueue({ id: 'a1', kind: 'approval', payload: {}, tags: { agentId: MAIN_AGENT_ID, sessionId: SESSION_ID } });
expect(changes.at(-1)).toEqual({
state: { busy: false, mainTurnActive: false, pendingInteraction: 'approval', lastTurnReason: undefined },
cause: 'interaction',
});
interactions.enqueue({ id: 'q1', kind: 'question', payload: {}, tags: { agentId: MAIN_AGENT_ID, sessionId: SESSION_ID } });
expect(changes).toHaveLength(1);
interactions.respond('a1', { approved: true });
expect(changes.at(-1)?.state.pendingInteraction).toBe('question');
});
it('treats user_tool pending as none', () => {
lifecycle.addAgent(MAIN_AGENT_ID);
const { changes } = viewWithChanges();
interactions.enqueue({ id: 'u1', kind: 'user_tool', payload: {}, tags: { agentId: MAIN_AGENT_ID, sessionId: SESSION_ID } });
expect(changes).toHaveLength(0);
});
it('drops a disposed agent from the aggregate with agent_lifecycle cause', () => {
const sub = lifecycle.addAgent('agent-0');
const { view, changes } = viewWithChanges();
sub.startTurn(1);
expect(view.state().busy).toBe(true);
lifecycle.removeAgent('agent-0');
expect(changes.at(-1)).toEqual({
state: { busy: false, mainTurnActive: false, pendingInteraction: 'none', lastTurnReason: undefined },
cause: 'agent_lifecycle',
});
});
it('seeds agents created after construction through onDidCreate', () => {
const { view, changes } = viewWithChanges();
const sub = lifecycle.addAgent('agent-0');
sub.startTurn(1);
expect(view.state().busy).toBe(true);
expect(changes.at(-1)?.cause).toBe('turn_started');
});
});