agent-ecosystem/src/main/utils/sessionStateDetection.ts

151 lines
5.4 KiB
TypeScript

/**
* Session state detection utilities for determining if sessions are ongoing.
*/
import { type ParsedMessage } from '../types';
/** Activity types for tracking session state */
type ActivityType =
| 'text_output'
| 'thinking'
| 'tool_use'
| 'tool_result'
| 'interruption'
| 'exit_plan_mode';
/** Activity entry with type and order index */
interface Activity {
type: ActivityType;
index: number;
}
/** Check if a toolUseResult value indicates a user-rejected tool use */
function isToolUseRejection(toolUseResult: unknown): boolean {
return toolUseResult === 'User rejected tool use';
}
/** Check if a tool_use block is a SendMessage shutdown_response with approve: true */
function isShutdownResponse(block: { name?: string; input?: Record<string, unknown> }): boolean {
return (
block.name === 'SendMessage' &&
block.input?.type === 'shutdown_response' &&
block.input?.approve === true
);
}
/**
* Check if activities indicate an ongoing session.
* Shared logic used by checkMessagesOngoing.
*
* @param activities - Array of tracked activities in order
* @returns boolean - true if ongoing
*/
function isOngoingFromActivities(activities: Activity[]): boolean {
if (activities.length === 0) {
return false;
}
// Find the index of the last "ending" event (text_output, interruption, or exit_plan_mode)
let lastEndingIndex = -1;
for (let i = activities.length - 1; i >= 0; i--) {
const actType = activities[i].type;
if (actType === 'text_output' || actType === 'interruption' || actType === 'exit_plan_mode') {
lastEndingIndex = activities[i].index;
break;
}
}
// If no ending event found, check if there's any AI activity at all
if (lastEndingIndex === -1) {
return activities.some(
(a) => a.type === 'thinking' || a.type === 'tool_use' || a.type === 'tool_result'
);
}
// Check if there are any AI activities AFTER the last ending event
for (const activity of activities) {
if (
activity.index > lastEndingIndex &&
(activity.type === 'thinking' ||
activity.type === 'tool_use' ||
activity.type === 'tool_result')
) {
return true;
}
}
return false;
}
/**
* Check if messages indicate an ongoing session (AI response in progress).
*
* A session is considered "ongoing" if there are AI-related activities
* (thinking, tool_use, tool_result) AFTER the last "ending" event (text output or interruption).
*
* Special case: ExitPlanMode tool_use is treated as an ending event, not a continuation.
* This is because ExitPlanMode signals the end of plan mode and contains the final plan content.
*
* This is the core logic shared between session files and subagent messages.
*
* @param messages - Array of ParsedMessage to check
* @returns boolean - true if ongoing
*/
export function checkMessagesOngoing(messages: ParsedMessage[]): boolean {
const activities: Activity[] = [];
let activityIndex = 0;
// Track tool_use IDs that are shutdown responses so their tool_results are also ending events
const shutdownToolIds = new Set<string>();
for (const msg of messages) {
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
// Process assistant message content blocks
for (const block of msg.content) {
if (block.type === 'thinking' && block.thinking) {
activities.push({ type: 'thinking', index: activityIndex++ });
} else if (block.type === 'tool_use' && block.id) {
// ExitPlanMode is a special ending tool - treat it like an ending event
if (block.name === 'ExitPlanMode') {
activities.push({ type: 'exit_plan_mode', index: activityIndex++ });
} else if (isShutdownResponse(block)) {
// SendMessage shutdown_response = agent is shutting down (ending event)
shutdownToolIds.add(block.id);
activities.push({ type: 'interruption', index: activityIndex++ });
} else {
activities.push({ type: 'tool_use', index: activityIndex++ });
}
} else if (block.type === 'text' && block.text && String(block.text).trim().length > 0) {
activities.push({ type: 'text_output', index: activityIndex++ });
}
}
} else if (msg.type === 'user' && Array.isArray(msg.content)) {
// Check if this is a user-rejected tool use (ending event, not ongoing activity)
const isRejection = isToolUseRejection(msg.toolUseResult);
// Check for tool results and interruptions in internal user messages
for (const block of msg.content) {
if (block.type === 'tool_result' && block.tool_use_id) {
if (shutdownToolIds.has(block.tool_use_id)) {
// Shutdown tool result = ending event
activities.push({ type: 'interruption', index: activityIndex++ });
} else if (isRejection) {
// User rejection = ending event (like interruption)
activities.push({ type: 'interruption', index: activityIndex++ });
} else {
activities.push({ type: 'tool_result', index: activityIndex++ });
}
}
// Check for interruption message - this ends the session
if (
block.type === 'text' &&
typeof block.text === 'string' &&
block.text.startsWith('[Request interrupted by user')
) {
activities.push({ type: 'interruption', index: activityIndex++ });
}
}
}
}
return isOngoingFromActivities(activities);
}