/** * session-events.ts — Event types and stream parser for deriving granular session events. * * Monitors an AgentSession via its subscribe() callback, compares message snapshots * to detect deltas, and emits higher-level events (token, tool_start, tool_end, etc.) * that the live TUI components consume. */ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { AgentRecord } from "./types.js"; // ---- Event Types ---- export type AgentSessionEvent = | { type: "token"; text: string } | { type: "tool_start"; toolName: string; toolUseId: string; args: unknown; timestamp: number } | { type: "tool_end"; toolName: string; toolUseId: string; output: string; timestamp: number } | { type: "turn_end"; turnNumber: number } | { type: "status"; status: string } | { type: "usage"; input: number; output: number; total: number } | { type: "error"; error: Error } | { type: "message"; message: unknown }; // ---- SessionEventStream ---- export class SessionEventStream { private sessionUnsubscribe: (() => void) | null = null; private callbacks = new Set<(event: AgentSessionEvent) => void>(); private prevMessages: unknown[] = []; private prevStatus: string | undefined; private toolCallTracker = new Map(); private nextCallId = 1; constructor( private session: AgentSession, private record: AgentRecord, ) { this.prevStatus = record.status; } /** * Register a callback for session events. * Returns an unsubscribe function. */ subscribe(callback: (event: AgentSessionEvent) => void): () => void { this.callbacks.add(callback); // Start listening to session if this is the first subscriber if (!this.sessionUnsubscribe) { this.sessionUnsubscribe = this.session.subscribe(() => { this.pollChanges(); }); } return () => { this.callbacks.delete(callback); if (this.callbacks.size === 0) this.close(); }; } /** * Broadcast an event to all subscribers. */ private emit(event: AgentSessionEvent): void { for (const cb of this.callbacks) { try { cb(event); } catch { // Swallow subscriber errors } } } /** * Poll the session for message changes and emit derived events. * Called on each session.subscribe() tick. */ private pollChanges(): void { const messages = (this.session as any).messages; if (!Array.isArray(messages)) return; // Emit message event for the full current state this.emit({ type: "message", message: messages }); // Detect new/changed messages since last poll const prevLen = this.prevMessages.length; const currLen = messages.length; // New messages appended for (let i = prevLen; i < currLen; i++) { const msg = messages[i]; if (!msg) continue; if (msg.role === "assistant") { // Extract text content as token events if (typeof msg.content === "string" && msg.content) { this.emit({ type: "token", text: msg.content }); } else if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block?.type === "text" && block.text) { this.emit({ type: "token", text: block.text }); } if (block?.type === "toolCall" || block?.type === "tool_use") { const toolName = block.name ?? block.toolName ?? "unknown"; const args = block.input ?? block.args ?? {}; const callId = block.id ?? block.toolCallId ?? block.callId ?? `gen_${this.nextCallId++}`; this.emit({ type: "tool_start", toolName, toolUseId: callId, args, timestamp: Date.now(), }); this.toolCallTracker.set(callId, { name: toolName, toolUseId: callId, args, timestamp: Date.now() }); } } } } else if (msg.role === "user") { // A new user message means a new turn this.emit({ type: "turn_end", turnNumber: Math.ceil((i + 1) / 2) }); } else if (msg.role === "toolResult" || (msg as any).role === "tool_result") { const callId = (msg as any).toolCallId ?? (msg as any).id ?? "unknown"; const tracked = this.toolCallTracker.get(callId); if (tracked) { const output = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? ""); this.emit({ type: "tool_end", toolName: tracked.name, toolUseId: tracked.toolUseId, output, timestamp: Date.now(), }); this.toolCallTracker.delete(callId); } } } this.prevMessages = messages; // Emit status changes const status = this.record.status; if (status !== this.prevStatus) { this.prevStatus = status; this.emit({ type: "status", status }); } } /** * Inject a user message into the running session. * Stub — will be wired to session.sendMessage() in Phase 5. * TODO Phase 5: Wire to session.sendMessage() */ inject(_text: string): void { console.warn( "[SessionEventStream] inject() is not yet implemented (Phase 5). " + "Intended to forward text to the session inject API." ); } /** * Interrupt the running agent. * Stub — will be wired to abort controller in Phase 5. * TODO Phase 5: Wire to abort controller */ interrupt(): void { console.warn( "[SessionEventStream] interrupt() is not yet implemented (Phase 5). " + "Intended to trigger the abort controller." ); } /** * Clean up subscriptions and internal state. */ close(): void { if (this.sessionUnsubscribe) { this.sessionUnsubscribe(); this.sessionUnsubscribe = null; } this.callbacks.clear(); this.toolCallTracker.clear(); } }