150 lines
4.8 KiB
TypeScript
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;
|
|
}
|