Roo-Code/apps/cli/src/agent/events.ts
Chris Estreich 2f4ce367c4
CLI: improve stream recovery and add configurable consecutive mistake limit (#11775)
* cli: improve stream error recovery and add mistake-limit flag

* More progress
2026-02-26 21:09:38 -08:00

372 lines
9.2 KiB
TypeScript

/**
* Event System for Agent State Changes
*
* This module provides a strongly-typed event emitter specifically designed
* for tracking agent state changes. It uses Node.js EventEmitter under the hood
* but provides type safety for all events.
*/
import { EventEmitter } from "events"
import { ClineMessage, ClineAsk } from "@roo-code/types"
import type { AgentStateInfo } from "./agent-state.js"
// =============================================================================
// Event Types
// =============================================================================
/**
* All events that can be emitted by the client.
*
* Design note: We use a string literal union type for event names to ensure
* type safety when subscribing to events. The payload type is determined by
* the event name.
*/
export interface ClientEventMap {
/**
* Emitted whenever the agent state changes.
* This is the primary event for tracking state.
*/
stateChange: AgentStateChangeEvent
/**
* Emitted when a new message is added to the message list.
*/
message: ClineMessage
/**
* Emitted when an existing message is updated (e.g., partial -> complete).
*/
messageUpdated: ClineMessage
/**
* Emitted when the agent starts waiting for user input.
* Convenience event - you can also use stateChange.
*/
waitingForInput: WaitingForInputEvent
/**
* Emitted when the agent stops waiting and resumes running.
*/
resumedRunning: void
/**
* Emitted when the agent starts streaming content.
*/
streamingStarted: void
/**
* Emitted when streaming ends.
*/
streamingEnded: void
/**
* Emitted when a task completes (either successfully or with error).
*/
taskCompleted: TaskCompletedEvent
/**
* Emitted when a task is cleared/cancelled.
*/
taskCleared: void
/**
* Emitted when the current mode changes.
*/
modeChanged: ModeChangedEvent
/**
* Emitted on any error during message processing.
*/
error: Error
}
/**
* Event payload for state changes.
*/
export interface AgentStateChangeEvent {
/** The previous state info */
previousState: AgentStateInfo
/** The new/current state info */
currentState: AgentStateInfo
/** Whether this is a significant state transition (state enum changed) */
isSignificantChange: boolean
}
/**
* Event payload when agent starts waiting for input.
*/
export interface WaitingForInputEvent {
/** The specific ask type */
ask: ClineAsk
/** Full state info for context */
stateInfo: AgentStateInfo
/** The message that triggered this wait */
message: ClineMessage
}
/**
* Event payload when a task completes.
*/
export interface TaskCompletedEvent {
/** Whether the task completed successfully */
success: boolean
/** The final state info */
stateInfo: AgentStateInfo
/** The completion message if available */
message?: ClineMessage
}
/**
* Event payload when mode changes.
*/
export interface ModeChangedEvent {
/** The previous mode (undefined if first mode set) */
previousMode: string | undefined
/** The new/current mode */
currentMode: string
}
// =============================================================================
// Typed Event Emitter
// =============================================================================
/**
* Type-safe event emitter for client events.
*
* Usage:
* ```typescript
* const emitter = new TypedEventEmitter()
*
* // Type-safe subscription
* emitter.on('stateChange', (event) => {
* console.log(event.currentState) // TypeScript knows this is AgentStateChangeEvent
* })
*
* // Type-safe emission
* emitter.emit('stateChange', { previousState, currentState, isSignificantChange })
* ```
*/
export class TypedEventEmitter {
private emitter = new EventEmitter()
/**
* Subscribe to an event.
*
* @param event - The event name
* @param listener - The callback function
* @returns Function to unsubscribe
*/
on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
this.emitter.on(event, listener)
return () => this.emitter.off(event, listener)
}
/**
* Subscribe to an event, but only once.
*
* @param event - The event name
* @param listener - The callback function
*/
once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
this.emitter.once(event, listener)
}
/**
* Unsubscribe from an event.
*
* @param event - The event name
* @param listener - The callback function to remove
*/
off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
this.emitter.off(event, listener)
}
/**
* Emit an event.
*
* @param event - The event name
* @param payload - The event payload
*/
emit<K extends keyof ClientEventMap>(event: K, payload: ClientEventMap[K]): void {
this.emitter.emit(event, payload)
}
/**
* Remove all listeners for an event, or all events.
*
* @param event - Optional event name. If not provided, removes all listeners.
*/
removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
if (event) {
this.emitter.removeAllListeners(event)
} else {
this.emitter.removeAllListeners()
}
}
/**
* Get the number of listeners for an event.
*/
listenerCount<K extends keyof ClientEventMap>(event: K): number {
return this.emitter.listenerCount(event)
}
}
// =============================================================================
// State Change Detector
// =============================================================================
/**
* Helper to determine if a state change is "significant".
*
* A significant change is when the AgentLoopState enum value changes,
* as opposed to just internal state updates within the same state.
*/
export function isSignificantStateChange(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return previous.state !== current.state
}
/**
* Helper to determine if we transitioned to waiting for input.
*/
export function transitionedToWaiting(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return !previous.isWaitingForInput && current.isWaitingForInput
}
/**
* Helper to determine if we transitioned from waiting to running.
*/
export function transitionedToRunning(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return previous.isWaitingForInput && !current.isWaitingForInput && current.isRunning
}
/**
* Helper to determine if streaming started.
*/
export function streamingStarted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return !previous.isStreaming && current.isStreaming
}
/**
* Helper to determine if streaming ended.
*/
export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return previous.isStreaming && !current.isStreaming
}
/**
* Helper to determine if task completed.
*/
export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
const completionAsks = ["completion_result", "resume_completed_task"]
const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
return wasNotComplete && isNowComplete
}
// =============================================================================
// Observable Pattern (Alternative API)
// =============================================================================
/**
* Subscription function type for observable pattern.
*/
export type Observer<T> = (value: T) => void
/**
* Unsubscribe function type.
*/
export type Unsubscribe = () => void
/**
* Simple observable for state.
*
* This provides an alternative to the event emitter pattern
* for those who prefer a more functional approach.
*
* Usage:
* ```typescript
* const stateObservable = new Observable<AgentStateInfo>()
*
* const unsubscribe = stateObservable.subscribe((state) => {
* console.log('New state:', state)
* })
*
* // Later...
* unsubscribe()
* ```
*/
export class Observable<T> {
private observers: Set<Observer<T>> = new Set()
private currentValue: T | undefined
/**
* Create an observable with an optional initial value.
*/
constructor(initialValue?: T) {
this.currentValue = initialValue
}
/**
* Subscribe to value changes.
*
* @param observer - Function called when value changes
* @returns Unsubscribe function
*/
subscribe(observer: Observer<T>): Unsubscribe {
this.observers.add(observer)
// Immediately emit current value if we have one
if (this.currentValue !== undefined) {
observer(this.currentValue)
}
return () => {
this.observers.delete(observer)
}
}
/**
* Update the value and notify all subscribers.
*/
next(value: T): void {
this.currentValue = value
for (const observer of this.observers) {
try {
observer(value)
} catch (error) {
console.error("Error in observer:", error)
}
}
}
/**
* Get the current value without subscribing.
*/
getValue(): T | undefined {
return this.currentValue
}
/**
* Check if there are any subscribers.
*/
hasSubscribers(): boolean {
return this.observers.size > 0
}
/**
* Get the number of subscribers.
*/
getSubscriberCount(): number {
return this.observers.size
}
/**
* Remove all subscribers.
*/
clear(): void {
this.observers.clear()
}
}