import { Disposable, DisposableStore, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService, type IAgentScopeHandle, } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { defineState } from '#/state/state'; import { IEventBus } from '#/app/event/eventBus'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentLoopService } from '#/agent/loop/loop'; import { TurnStarted, type TurnEndReason } 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 { CompactionCancelled, CompactionCompleted, CompactionStarted, } from '#/agent/fullCompaction/compactionOps'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { INTERACTION_TAG_SESSION_ID, type Interaction, } from '#/human/interaction/interaction'; import { interactions } from '#/human/interaction/facade'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionActivityView, type SessionActivityCause, type SessionActivityChangedEvent, type SessionActivityState, type SessionPendingInteraction, type SessionTurnOutcome, } from './sessionActivity'; interface AgentWorkFold { turnActive: boolean; background: ReadonlySet; compacting: boolean; lastTurnReason?: SessionTurnOutcome; } export const sessionActivityFoldsKey = defineState>( 'sessionActivity.folds', () => new Map(), ); export const sessionActivityCurrentKey = defineState('sessionActivity.current', () => ({ busy: false, mainTurnActive: false, pendingInteraction: 'none', lastTurnReason: undefined, })); export class SessionActivityView extends Disposable implements ISessionActivityView { declare readonly _serviceBrand: undefined; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; private readonly agentSubscriptions = new Map(); constructor( @ISessionStateService private readonly states: ISessionStateService, @IAgentLifecycleService private readonly agents: IAgentLifecycleService, @ISessionContext private readonly ctx: ISessionContext, ) { super(); this.states.contributeState(sessionActivityFoldsKey); this.states.contributeState(sessionActivityCurrentKey); for (const agent of this.agents.list()) { const handle = this.agents.handleOf(agent.agentId); if (handle !== undefined) this.attachAgent(handle); } this.current = this.aggregate(); this._register( this.agents.onDidCreateScope(({ handle }) => { this.attachAgent(handle); this.recompute('agent_lifecycle'); }), ); this._register( this.agents.onDidClose((agent) => { this.agentSubscriptions.get(agent.agentId)?.dispose(); this.agentSubscriptions.delete(agent.agentId); if (this.folds.delete(agent.agentId)) this.recompute('agent_lifecycle'); }), ); this._register( toDisposable( interactions.onDidChangePending(() => this.recompute('interaction')), ), ); this._register( toDisposable(() => { for (const subscription of this.agentSubscriptions.values()) subscription.dispose(); this.agentSubscriptions.clear(); }), ); } private get folds(): Map { return this.states.get(sessionActivityFoldsKey); } private get current(): SessionActivityState { return this.states.get(sessionActivityCurrentKey); } private set current(value: SessionActivityState) { this.states.set(sessionActivityCurrentKey, value); } state(): SessionActivityState { return this.current; } private attachAgent(handle: IAgentScopeHandle): void { if (this.folds.has(handle.id)) return; this.folds.set(handle.id, seedFold(handle)); const bus = handle.accessor.get(IEventBus) as IEventBus | undefined; if (bus === undefined) return; const subscriptions = new DisposableStore(); subscriptions.add( bus.subscribe(TurnStarted, () => this.patchFold(handle.id, (fold) => ({ ...fold, turnActive: true, lastTurnReason: handle.id === MAIN_AGENT_ID ? undefined : fold.lastTurnReason, })), ), ); subscriptions.add( bus.subscribe(TurnEnded, (event) => this.patchFold(handle.id, (fold) => ({ ...fold, turnActive: false, lastTurnReason: handle.id === MAIN_AGENT_ID ? mapTurnReason(event.reason) : fold.lastTurnReason, })), ), ); subscriptions.add( bus.subscribe(TaskStarted, (event) => this.patchFold(handle.id, (fold) => ({ ...fold, background: new Set(fold.background).add(event.info.taskId), })), ), ); subscriptions.add( bus.subscribe(TaskTerminatedNotice, (event) => this.patchFold(handle.id, (fold) => { if (!fold.background.has(event.info.taskId)) return fold; const background = new Set(fold.background); background.delete(event.info.taskId); return { ...fold, background }; }), ), ); subscriptions.add( bus.subscribe(CompactionStarted, () => this.patchFold(handle.id, (fold) => ({ ...fold, compacting: true })), ), ); subscriptions.add( bus.subscribe(CompactionCompleted, () => this.patchFold(handle.id, (fold) => ({ ...fold, compacting: false })), ), ); subscriptions.add( bus.subscribe(CompactionCancelled, () => this.patchFold(handle.id, (fold) => ({ ...fold, compacting: false })), ), ); const dispatcher = handle.accessor.get(IEventDispatcher) as IEventDispatcher | undefined; if (dispatcher !== undefined) { subscriptions.add( dispatcher.hooks.onDidRestore.register('sessionActivity', async (_ctx, next) => { this.folds.set(handle.id, seedFold(handle)); this.recompute('agent_lifecycle'); await next(); }), ); } this.agentSubscriptions.set(handle.id, subscriptions); } private patchFold(agentId: string, patch: (fold: AgentWorkFold) => AgentWorkFold): void { const previous = this.folds.get(agentId); if (previous === undefined) return; const next = patch(previous); this.folds.set(agentId, next); let cause: SessionActivityCause | undefined; if (!previous.turnActive && next.turnActive) cause = 'turn_started'; else if (previous.turnActive && !next.turnActive) cause = 'turn_ended'; else if (previous.background.size !== next.background.size || previous.compacting !== next.compacting) { cause = 'background'; } else if (agentId === MAIN_AGENT_ID && previous.lastTurnReason !== next.lastTurnReason) { cause = 'turn_ended'; } if (cause !== undefined) this.recompute(cause); } private recompute(cause: SessionActivityCause): void { const next = this.aggregate(); if (activityEquals(this.current, next)) return; this.current = next; this._onDidChange.fire({ state: next, cause }); } private aggregate(): SessionActivityState { let busy = false; for (const fold of this.folds.values()) { if (fold.turnActive || fold.background.size > 0 || fold.compacting) { busy = true; break; } } return { busy, mainTurnActive: this.folds.get(MAIN_AGENT_ID)?.turnActive ?? false, pendingInteraction: resolvePendingInteraction( interactions.findAll({ resolved: false, tags: { [INTERACTION_TAG_SESSION_ID]: this.ctx.sessionId }, }), ), lastTurnReason: this.folds.get(MAIN_AGENT_ID)?.lastTurnReason, }; } } function seedFold(handle: IAgentScopeHandle): AgentWorkFold { const loop = handle.accessor.get(IAgentLoopService) as IAgentLoopService | undefined; const tasks = handle.accessor.get(IAgentTaskService) as IAgentTaskService | undefined; const compaction = handle.accessor.get(IAgentFullCompactionService) as | IAgentFullCompactionService | undefined; const states = handle.accessor.get(IAgentStateService) as IAgentStateService | undefined; const lastEnded = handle.id === MAIN_AGENT_ID && states?.has(turnKey) === true ? states.get(turnKey).lastEnded : undefined; return { turnActive: loop?.snapshot().state === 'running', background: new Set(tasks?.list(true).map((task) => task.taskId) ?? []), compacting: (compaction?.compacting ?? null) !== null, lastTurnReason: loop?.snapshot().state === 'running' ? undefined : mapTurnReason(lastEnded?.reason), }; } function mapTurnReason(reason: TurnEndReason | undefined): SessionTurnOutcome | undefined { if (reason === undefined) return undefined; return reason === 'completed' ? 'completed' : reason === 'cancelled' ? 'cancelled' : 'failed'; } function resolvePendingInteraction(pending: readonly Interaction[]): SessionPendingInteraction { if (pending.some((interaction) => interaction.kind === 'approval')) return 'approval'; if (pending.some((interaction) => interaction.kind === 'question')) return 'question'; return 'none'; } function activityEquals(a: SessionActivityState, b: SessionActivityState): boolean { return ( a.busy === b.busy && a.mainTurnActive === b.mainTurnActive && a.pendingInteraction === b.pendingInteraction && a.lastTurnReason === b.lastTurnReason ); } registerScopedService( LifecycleScope.Session, ISessionActivityView, SessionActivityView, ScopeActivation.OnScopeCreated, 'sessionActivity', );