agent-ecosystem/src/renderer/utils/lastOutputDetector.ts

150 lines
4.8 KiB
TypeScript

/**
* Last Output Detector - Find the last visible output in an AI Group
*
* Uses a state machine approach to find the last meaningful output
* for display in the chat UI.
*/
import { toDate } from './aiGroupHelpers';
import type { SemanticStep } from '../types/data';
import type { AIGroupLastOutput } from '../types/groups';
/**
* Find the last visible output in the AI Group.
*
* Strategy:
* 1. If isOngoing is true, return 'ongoing' type (session still in progress)
* 2. Check for ExitPlanMode tool_call as special 'plan_exit' type
* 3. Iterate through steps in reverse order
* 4. Find the last 'output' step with outputText
* 5. If no output found, find the last 'tool_result' step
* 6. If no tool_result found, find the last 'interruption' step
* 7. Return null if none exists
*
* Special case: ExitPlanMode
* When the last tool_call is ExitPlanMode, return 'plan_exit' type with the plan content.
* The preamble text (if any) is captured from the preceding output step.
*
* @param steps - Semantic steps from the AI Group
* @param isOngoing - Whether this AI group is still in progress
* @returns The last output or null
*/
export function findLastOutput(
steps: SemanticStep[],
isOngoing: boolean = false
): AIGroupLastOutput | null {
// Check for interruption first - interruption takes precedence over ongoing status
// This ensures user interruptions are always visible even if session appears ongoing
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (step.type === 'interruption') {
return {
type: 'interruption',
timestamp: step.startTime,
};
}
}
// If session is ongoing (and no interruption), return 'ongoing' type
if (isOngoing) {
return {
type: 'ongoing',
timestamp: steps.length > 0 ? toDate(steps[steps.length - 1].startTime) : new Date(),
};
}
// Check for ExitPlanMode as the last significant activity
// ExitPlanMode is a special ending tool that signals plan completion
let lastExitPlanModeStep: SemanticStep | null = null;
let lastOutputBeforeExitPlanMode: SemanticStep | null = null;
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (step.type === 'tool_call' && step.content.toolName === 'ExitPlanMode') {
lastExitPlanModeStep = step;
// Look for the preceding output step (preamble text)
for (let j = i - 1; j >= 0; j--) {
if (steps[j].type === 'output' && steps[j].content.outputText) {
lastOutputBeforeExitPlanMode = steps[j];
break;
}
}
break;
}
}
// If ExitPlanMode is found, check if it's the "last" activity
// (no other output or tool_result comes after it)
if (lastExitPlanModeStep) {
const exitPlanModeIndex = steps.indexOf(lastExitPlanModeStep);
let hasLaterEnding = false;
for (let i = exitPlanModeIndex + 1; i < steps.length; i++) {
const step = steps[i];
if (step.type === 'output' && step.content.outputText) {
hasLaterEnding = true;
break;
}
if (step.type === 'tool_result' && step.content.toolResultContent) {
hasLaterEnding = true;
break;
}
}
if (!hasLaterEnding) {
// ExitPlanMode is the last significant activity - return plan_exit
const toolInput = lastExitPlanModeStep.content.toolInput as
| Record<string, unknown>
| undefined;
const planContent = toolInput?.plan as string | undefined;
return {
type: 'plan_exit',
planContent: planContent ?? '',
planPreamble: lastOutputBeforeExitPlanMode?.content.outputText,
timestamp: lastExitPlanModeStep.startTime,
};
}
}
// First pass: look for last 'output' step with outputText
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (step.type === 'output' && step.content.outputText) {
return {
type: 'text',
text: step.content.outputText,
timestamp: step.startTime,
};
}
}
// Second pass: look for last 'tool_result' step
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (step.type === 'tool_result' && step.content.toolResultContent) {
return {
type: 'tool_result',
toolName: step.content.toolName,
toolResult: step.content.toolResultContent,
isError: step.content.isError ?? false,
timestamp: step.startTime,
};
}
}
// Third pass: look for last 'interruption' step
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (step.type === 'interruption' && step.content.interruptionText) {
return {
type: 'interruption',
interruptionMessage: step.content.interruptionText,
timestamp: step.startTime,
};
}
}
return null;
}