feat: add global device queue and readiness tracking
This commit is contained in:
1 parent
a62ac4aef1
commit
42b8988661
57 files changed
+3606
-716
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { StartupProgressEvent } from '../../services/agent.service';
|
||||
import { buildStartupWorkItems } from './agent-stream.component';
|
||||
|
||||
describe('startup Work block', () => {
|
||||
it('shows only the three device preparation operations', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'submitting', message: 'Submitting the task', timestamp: 100 },
|
||||
{ stage: 'queued', message: 'Task received and queued', timestamp: 100.9 },
|
||||
{ stage: 'launching', message: 'Starting the execution process', timestamp: 101 },
|
||||
{ stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 },
|
||||
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 },
|
||||
{ stage: 'device_ready', message: 'Android device connected', timestamp: 123 },
|
||||
{ stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 },
|
||||
{ stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 },
|
||||
{ stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 },
|
||||
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 128 },
|
||||
{ stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 }
|
||||
];
|
||||
|
||||
const items = buildStartupWorkItems(events, 131, false, true);
|
||||
|
||||
expect(items.map((item) => item.stage)).toEqual([
|
||||
'device_ready',
|
||||
'uiautomator_ready',
|
||||
'environment_ready'
|
||||
]);
|
||||
expect(items.map((item) => item.elapsed)).toEqual(['20s', '4.0s', '3.0s']);
|
||||
expect(items.every((item) => !item.isActive)).toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the current preparation operation live without exposing first-action text', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 10 }
|
||||
];
|
||||
|
||||
const [item] = buildStartupWorkItems(events, 11.4, false, true);
|
||||
|
||||
expect(item.message).toBe('Checking the Android device');
|
||||
expect(item.elapsed).toBe('1.4s');
|
||||
expect(item.isActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('uses old first_response events only as a hidden completion boundary', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 20 },
|
||||
{ stage: 'first_response', message: 'Generating the first action', timestamp: 23 }
|
||||
];
|
||||
|
||||
const [item] = buildStartupWorkItems(events, 24, false, true);
|
||||
|
||||
expect(item.message).toBe('Device environment is ready');
|
||||
expect(item.elapsed).toBe('3.0s');
|
||||
expect(item.isActive).toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -290,40 +290,43 @@
|
||||
|
||||
<!-- Event Logs Section -->
|
||||
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
|
||||
<!-- Keep recovery available even when a persisted failure trace is absent. -->
|
||||
<div class="paused-task-banner" *ngIf="isViewingPausedTask()" role="status">
|
||||
<div class="paused-task-message">
|
||||
<span class="material-symbols-outlined paused-task-icon">pause_circle</span>
|
||||
<div>
|
||||
<strong>Task paused</strong>
|
||||
<span>The AI request failed. Resume the task to try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-resume-task"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Resume the paused task"
|
||||
aria-label="Resume the paused task"
|
||||
>
|
||||
<span class="material-symbols-outlined">play_arrow</span>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
|
||||
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0" class="empty-state">
|
||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a session from the Task List on the right to view its real-time log payloads.</p>
|
||||
</div>
|
||||
|
||||
<!-- The phase-based session logs list -->
|
||||
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader()">
|
||||
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0">
|
||||
<div class="phase-container startup-phase" *ngIf="startupWorkItems().length > 0" aria-live="polite">
|
||||
<div class="phase-header first-phase">
|
||||
<span class="phase-worked-time">Worked for {{ startupPreparationDuration() }}s</span>
|
||||
</div>
|
||||
<div class="phase-body" aria-label="Task startup progress">
|
||||
<div class="block-item type-stream">
|
||||
<div class="step-body">
|
||||
<div class="step-actions-container">
|
||||
<div class="llm-retry-notes">
|
||||
<div class="llm-retry-note" *ngFor="let item of startupWorkItems()">
|
||||
<span class="stream-status-dot" [class.streaming]="item.isActive"></span>
|
||||
<div class="llm-retry-note-text">
|
||||
<span class="llm-retry-status-line">
|
||||
{{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase-container" *ngFor="let phase of phases(); let first = first; trackBy: trackPhase">
|
||||
<div
|
||||
class="phase-header"
|
||||
*ngIf="hasVisibleBlocks(phase)"
|
||||
[class.first-phase]="first"
|
||||
[class.first-phase]="first && startupWorkItems().length === 0"
|
||||
>
|
||||
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
|
||||
<span class="phase-token-usage" *ngIf="phase.tokens" [title]="getTokenTooltip(phase)">
|
||||
@@ -558,11 +561,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
|
||||
<div class="step-android-action-card tool-action-card is-failed"
|
||||
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
|
||||
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
|
||||
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)"
|
||||
*ngIf="isDisplayableLLMFailure(item.data)">
|
||||
<div class="llm-failure-stack" *ngIf="isDisplayableLLMFailure(item.data)">
|
||||
<div class="step-android-action-card tool-action-card is-failed"
|
||||
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
|
||||
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
|
||||
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)">
|
||||
<div class="action-card-header llm-failure-header">
|
||||
<span class="material-symbols-outlined action-icon is-failed">error</span>
|
||||
<span class="action-title">LLM Request Failed</span>
|
||||
@@ -576,24 +579,17 @@
|
||||
Failed
|
||||
</span>
|
||||
</ng-template>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-resume-llm"
|
||||
*ngIf="isTerminalLLMFailure(item.data)"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Resume the paused task"
|
||||
aria-label="Resume the paused task"
|
||||
>
|
||||
<span class="material-symbols-outlined">play_arrow</span>
|
||||
Resume
|
||||
</button>
|
||||
<span class="action-expand-icon material-symbols-outlined" *ngIf="hasLLMFailureRetryEntries(item.data)">
|
||||
{{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="action-card-body">
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Reason:</span>
|
||||
<span class="detail-label">What happened:</span>
|
||||
<span class="detail-value">The AI request could not be completed after automatic retries.</span>
|
||||
</div>
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Failure reason:</span>
|
||||
<span class="detail-value">{{ getLLMErrorText(item.data) }}</span>
|
||||
</div>
|
||||
<div class="action-detail-row" *ngIf="getLLMFailureWaited(item.data) as waited">
|
||||
@@ -611,6 +607,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-resume-row" *ngIf="isTerminalLLMFailure(item.data)">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-continue-task"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Continue the paused task"
|
||||
aria-label="Continue the paused task"
|
||||
>
|
||||
<span class="material-symbols-outlined continue-icon">play_arrow</span>
|
||||
<span>Continue task</span>
|
||||
<span class="material-symbols-outlined continue-arrow">arrow_forward</span>
|
||||
</button>
|
||||
<span class="llm-resume-hint">Task paused · no steps are running</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
|
||||
@@ -842,7 +854,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Planning Loader -->
|
||||
<div class="planning-loader-container" *ngIf="showPlanningLoader()">
|
||||
<div class="planning-loader-container" *ngIf="showPlanningLoader() && (startupWorkItems().length === 0 || startupPreparationIsComplete() || consolidatedBlocks().length > 0)">
|
||||
<div class="planning-loader">
|
||||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||||
<span class="loader-text">{{ currentPlanningText() }}</span>
|
||||
|
||||
@@ -1060,99 +1060,6 @@ $uniform-gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.paused-task-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 10px;
|
||||
color: #9a3412;
|
||||
box-shadow: 0 1px 3px rgba(154, 52, 18, 0.08);
|
||||
|
||||
.paused-task-message {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.paused-task-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 21px;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
span:not(.paused-task-icon) {
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-resume-task {
|
||||
flex: 0 0 auto;
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 8px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.2);
|
||||
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
.btn-resume-task {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1203,8 +1110,11 @@ $uniform-gap: 8px;
|
||||
.phase-header {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
min-height: 20px;
|
||||
box-sizing: border-box;
|
||||
color: #71717a;
|
||||
margin-top: 14px;
|
||||
margin-top: $uniform-gap * 2;
|
||||
margin-bottom: 8px;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
@@ -2230,6 +2140,111 @@ $uniform-gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-failure-stack {
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
margin-left: -19px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.step-android-action-card {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.llm-resume-row {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-continue-task {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px 7px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #1d4ed8;
|
||||
border-radius: 9px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 7px rgba(37, 99, 235, 0.22);
|
||||
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
.continue-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: -2px 0 -2px -2px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.continue-arrow {
|
||||
font-size: 15px;
|
||||
opacity: 0.72;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
border-color: #1e40af;
|
||||
box-shadow: 0 4px 11px rgba(37, 99, 235, 0.28);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.continue-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 4px rgba(37, 99, 235, 0.2);
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(59, 130, 246, 0.25);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-resume-hint {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.llm-resume-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-continue-task {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.llm-resume-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-android-action-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -2488,51 +2503,6 @@ $uniform-gap: 8px;
|
||||
|
||||
}
|
||||
|
||||
&.llm-failure-header {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn-resume-llm {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 3px 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: #2563eb;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-expand-icon {
|
||||
font-size: 18px;
|
||||
color: #94a3b8;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model';
|
||||
@@ -43,6 +43,83 @@ export const PLANNING_LOADER_PHRASES: string[] = [
|
||||
'Strategizing tactical moves...'
|
||||
];
|
||||
|
||||
export interface StartupWorkItem extends StartupProgressEvent {
|
||||
isActive: boolean;
|
||||
elapsed: string;
|
||||
}
|
||||
|
||||
interface StartupWorkStage {
|
||||
started: string;
|
||||
completed: string;
|
||||
completedMessage: string;
|
||||
}
|
||||
|
||||
const STARTUP_WORK_STAGES: StartupWorkStage[] = [
|
||||
{
|
||||
started: 'device_check',
|
||||
completed: 'device_ready',
|
||||
completedMessage: 'Android device connected'
|
||||
},
|
||||
{
|
||||
started: 'uiautomator',
|
||||
completed: 'uiautomator_ready',
|
||||
completedMessage: 'UI Automator is ready'
|
||||
},
|
||||
{
|
||||
started: 'environment',
|
||||
completed: 'environment_ready',
|
||||
completedMessage: 'Device environment is ready'
|
||||
}
|
||||
];
|
||||
|
||||
function formatStartupElapsed(seconds: number): string {
|
||||
const safeSeconds = Math.max(0, seconds);
|
||||
return safeSeconds < 10
|
||||
? `${safeSeconds.toFixed(1)}s`
|
||||
: `${Math.round(safeSeconds)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse noisy process-level startup events into the three device preparation
|
||||
* operations that are useful to someone watching a run.
|
||||
*/
|
||||
export function buildStartupWorkItems(
|
||||
events: StartupProgressEvent[],
|
||||
nowSeconds: number,
|
||||
executionHasOutput: boolean,
|
||||
agentIsActive: boolean
|
||||
): StartupWorkItem[] {
|
||||
const byStage = new Map(events.map((event) => [event.stage, event]));
|
||||
const firstResponse = byStage.get('first_response');
|
||||
|
||||
return STARTUP_WORK_STAGES.flatMap((stage, stageIndex) => {
|
||||
const started = byStage.get(stage.started);
|
||||
const explicitlyCompleted = byStage.get(stage.completed);
|
||||
if (!started && !explicitlyCompleted) return [];
|
||||
|
||||
const nextStageStarted = STARTUP_WORK_STAGES
|
||||
.slice(stageIndex + 1)
|
||||
.map((nextStage) => byStage.get(nextStage.started) || byStage.get(nextStage.completed))
|
||||
.find((event): event is StartupProgressEvent => Boolean(event));
|
||||
const inferredCompletion = stage.started === 'environment'
|
||||
? firstResponse
|
||||
: nextStageStarted;
|
||||
const completed = explicitlyCompleted || inferredCompletion;
|
||||
const isActive = !completed && !executionHasOutput && agentIsActive;
|
||||
const startTimestamp = started?.timestamp || explicitlyCompleted?.timestamp || nowSeconds;
|
||||
const endTimestamp = completed?.timestamp
|
||||
|| (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp);
|
||||
|
||||
return [{
|
||||
...(explicitlyCompleted || started!),
|
||||
message: explicitlyCompleted?.message
|
||||
|| (completed ? stage.completedMessage : started!.message),
|
||||
isActive,
|
||||
elapsed: formatStartupElapsed(endTimestamp - startTimestamp)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
import {
|
||||
parseNote,
|
||||
parseNoteLines,
|
||||
@@ -232,7 +309,10 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
});
|
||||
|
||||
public isViewingPausedTask = computed(() => {
|
||||
if (!this.agentService.isPaused()) return false;
|
||||
// A live task_paused event and the polled runner status must agree before
|
||||
// recovery controls are shown. This prevents a stale failure trace from
|
||||
// offering Resume while the active task is still reported as running.
|
||||
if (!this.agentService.isPaused() || this.agentService.agentStatus() !== 'paused') return false;
|
||||
|
||||
const currentSessionId = this.agentService.currentSessionId();
|
||||
const pausedSessionId = this.agentService.runningSessionId();
|
||||
@@ -253,8 +333,44 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||
});
|
||||
|
||||
public startupWorkItems = computed(() => {
|
||||
this.retryClock();
|
||||
const events = this.agentService.currentStartupProgress();
|
||||
const agentIsActive = ['running', 'paused'].includes(this.agentService.agentStatus());
|
||||
return buildStartupWorkItems(
|
||||
events,
|
||||
Date.now() / 1000,
|
||||
this.consolidatedBlocks().length > 0,
|
||||
agentIsActive
|
||||
);
|
||||
});
|
||||
|
||||
public startupPreparationIsRunning = computed(() => {
|
||||
return this.startupWorkItems().some((item) => item.isActive);
|
||||
});
|
||||
|
||||
public startupPreparationIsComplete = computed(() => {
|
||||
const items = this.startupWorkItems();
|
||||
return items.length > 0 && !items.some((item) => item.isActive);
|
||||
});
|
||||
|
||||
public startupPreparationDuration = computed(() => {
|
||||
const items = this.startupWorkItems();
|
||||
if (items.length === 0) return 0;
|
||||
|
||||
const events = this.agentService.currentStartupProgress();
|
||||
const startedAt = events[0]?.timestamp || items[0].timestamp;
|
||||
const completedAt = events.find((event) => event.stage === 'environment_ready')?.timestamp
|
||||
|| events.find((event) => event.stage === 'first_response')?.timestamp;
|
||||
const endedAt = this.startupPreparationIsRunning()
|
||||
? Date.now() / 1000
|
||||
: completedAt || Math.max(...items.map((item) => item.timestamp));
|
||||
|
||||
return Math.max(1, Math.round(endedAt - startedAt));
|
||||
});
|
||||
|
||||
private retryablePauseTrace = computed<any | null>(() => {
|
||||
if (!this.agentService.isPaused()) return null;
|
||||
if (!this.isViewingPausedTask()) return null;
|
||||
|
||||
const blocks = this.consolidatedBlocks();
|
||||
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
|
||||
@@ -263,11 +379,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
|
||||
const tool = tools[toolIndex];
|
||||
if (
|
||||
tool?.type === 'llm_call'
|
||||
&& tool?.status === 'failed'
|
||||
&& tool?.payload?.pause === true
|
||||
) {
|
||||
// Older persisted traces used the llm_pause name without adding the
|
||||
// newer payload.pause marker. Treat both contracts consistently.
|
||||
if (this.isDisplayableLLMFailure(tool)) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
@@ -873,10 +987,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getLLMErrorText(tool: any): string {
|
||||
if (!tool) return 'Unknown error';
|
||||
const noDetails = 'The AI provider did not return error details after the request failed.';
|
||||
if (!tool) return noDetails;
|
||||
const rawError = tool.payload?.error || tool.error;
|
||||
if (!rawError) return 'Unknown error';
|
||||
return this.cleanErrorMessage(rawError);
|
||||
if (!rawError) return noDetails;
|
||||
const cleaned = this.cleanErrorMessage(rawError);
|
||||
return cleaned === 'Unknown error' ? noDetails : cleaned;
|
||||
}
|
||||
|
||||
public isLLMRetry(tool: any): boolean {
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
@@ -34,7 +33,6 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
})
|
||||
export class ChatInterfaceComponent {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
public taskInput: string = '';
|
||||
public isSubmitting: boolean = false;
|
||||
@@ -79,23 +77,10 @@ export class ChatInterfaceComponent {
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
this.agentService.clearUserPinnedSession();
|
||||
this.http.post<any>('/api/run', { goal }).subscribe({
|
||||
this.agentService.runTask(goal).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
this.isSubmitting = false;
|
||||
if (res && res.tasks && res.tasks.length > 0) {
|
||||
const firstTask = res.tasks[0];
|
||||
if (firstTask && firstTask.session_id) {
|
||||
// Only auto-select immediately if no task is currently executing/running
|
||||
const isCurrentlyRunning =
|
||||
this.agentService.agentStatus() === 'running' ||
|
||||
this.agentService.sessions().some((s) => s.status === 'running');
|
||||
if (!isCurrentlyRunning) {
|
||||
this.agentService.selectSession(firstTask.session_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch status to refresh sessions list and select new session
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface DeviceInfo {
|
||||
product: string | null;
|
||||
android_version: string | null;
|
||||
screen_resolution: string | null;
|
||||
is_locked: boolean | null;
|
||||
is_emulator: boolean;
|
||||
installed_packages?: string[];
|
||||
}
|
||||
|
||||
@@ -937,7 +937,7 @@
|
||||
}
|
||||
|
||||
<!-- STATE B-3: NO DEVICE DETECTED / READY TO CONNECT GUIDE -->
|
||||
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
|
||||
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
|
||||
<div class="foolproof-guide-container">
|
||||
<!-- Method Selection Nav Cards -->
|
||||
<div class="connection-methods-nav">
|
||||
@@ -1357,6 +1357,27 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STATE C: CONNECTED DEVICE IS LOCKED / LOCK STATE UNVERIFIED -->
|
||||
@if (adbProbe()?.status === 'warn' && (adbProbe()?.summary === 'Device Locked' || adbProbe()?.summary === 'Lock State Unknown')) {
|
||||
<div class="device-locked-panel">
|
||||
<div class="device-locked-card">
|
||||
<span class="material-symbols-outlined alert-icon">lock</span>
|
||||
<div class="locked-details">
|
||||
<div class="locked-title">
|
||||
{{ adbProbe()?.summary === 'Device Locked' ? 'Device Is Locked' : 'Unable to Verify Lock State' }}
|
||||
</div>
|
||||
<div class="locked-desc">
|
||||
{{ adbProbe()?.description }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-check-unlock" [disabled]="isRefreshingDiagnostics()" (click)="refreshReadiness()">
|
||||
<span class="material-symbols-outlined" [class.spinning]="isRefreshingDiagnostics()">refresh</span>
|
||||
<span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STATE C: UNAUTHORIZED DEVICE -->
|
||||
@if (adbProbe()?.status === 'warn' && adbProbe()?.summary === 'Device Unauthorized') {
|
||||
<div class="unauthorized-panel">
|
||||
@@ -1478,8 +1499,12 @@
|
||||
<!-- Prerequisites Drop Alert if any blocker probe is not passing -->
|
||||
@if (!isReady()) {
|
||||
<div class="launcher-warning-bar" (click)="setTab('diagnostics')">
|
||||
<span class="material-symbols-outlined warn-icon">warning</span>
|
||||
<span>Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.</span>
|
||||
<span class="material-symbols-outlined warn-icon">{{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
|
||||
<span>
|
||||
{{ adbProbe()?.summary === 'Device Locked'
|
||||
? 'Android device is locked. Unlock it and enter the home screen before running a task.'
|
||||
: 'Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.' }}
|
||||
</span>
|
||||
<span class="material-symbols-outlined arrow">arrow_forward</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1584,7 +1609,8 @@
|
||||
type="button"
|
||||
class="btn-run-composer"
|
||||
[class.btn-run-pro]="selectedProfile() === 'pro'"
|
||||
[disabled]="!taskGoal().trim() || isSubmitting()"
|
||||
[disabled]="!taskGoal().trim() || isSubmitting() || !isReady()"
|
||||
[title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? 'Unlock the Android device before running' : 'Complete System Setup before running') : 'Run task'"
|
||||
(click)="runTask()"
|
||||
>
|
||||
@if (isSubmitting()) {
|
||||
|
||||
@@ -3089,9 +3089,11 @@ $text-light: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* State C: Unauthorized Panel */
|
||||
.unauthorized-panel {
|
||||
.unauthorized-card {
|
||||
/* State C: Locked / Unauthorized Device Panels */
|
||||
.unauthorized-panel,
|
||||
.device-locked-panel {
|
||||
.unauthorized-card,
|
||||
.device-locked-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -3106,25 +3108,29 @@ $text-light: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unauth-details {
|
||||
.unauth-details,
|
||||
.locked-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.unauth-title {
|
||||
.unauth-title,
|
||||
.locked-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $amber-dark;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.unauth-desc {
|
||||
.unauth-desc,
|
||||
.locked-desc {
|
||||
font-size: 11.5px;
|
||||
color: #78350f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-retry-auth {
|
||||
.btn-retry-auth,
|
||||
.btn-check-unlock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -3144,7 +3150,7 @@ $text-light: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(253, 230, 138, 0.95);
|
||||
border-color: $amber;
|
||||
transform: translateY(-0.5px);
|
||||
@@ -3154,6 +3160,11 @@ $text-light: #94a3b8;
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
@@ -38,7 +37,6 @@ import { AgentService } from '../../services/agent.service';
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// Default right panel width to 1/3 of the screen (or 450px as fallback)
|
||||
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
|
||||
@@ -197,25 +195,13 @@ export class WorkspaceComponent implements OnInit {
|
||||
}
|
||||
this.isInputFocused.set(false);
|
||||
|
||||
this.agentService.clearUserPinnedSession();
|
||||
this.http.post<any>('/api/run', { goal, profile: this.selectedProfile() }).subscribe({
|
||||
this.agentService.runTask(goal, this.selectedProfile()).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.style.height = 'auto';
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
if (res && res.tasks && res.tasks.length > 0) {
|
||||
const firstTask = res.tasks[0];
|
||||
if (firstTask && firstTask.session_id) {
|
||||
const isCurrentlyRunning =
|
||||
this.agentService.agentStatus() === 'running' ||
|
||||
this.agentService.sessions().some((s) => s.status === 'running');
|
||||
if (!isCurrentlyRunning) {
|
||||
this.agentService.selectSession(firstTask.session_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
|
||||
@@ -7,9 +7,38 @@ describe('AgentService live LLM retry timeline', () => {
|
||||
function createServiceWithoutPolling(): AgentService {
|
||||
const service = Object.create(AgentService.prototype) as AgentService;
|
||||
service.sessionLogs = signal<any[]>([]);
|
||||
service.startupProgressBySession = signal({});
|
||||
(service as any).pendingStartupProgress = signal<any[]>([]);
|
||||
return service;
|
||||
}
|
||||
|
||||
it('orders startup milestones and replaces duplicate stages', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
|
||||
(service as any).appendStartupProgress({
|
||||
session_id: 'session-1',
|
||||
stage: 'device',
|
||||
message: 'Checking device',
|
||||
timestamp: 102
|
||||
}, 'session-1');
|
||||
(service as any).appendStartupProgress({
|
||||
session_id: 'session-1',
|
||||
stage: 'queued',
|
||||
message: 'Task queued',
|
||||
timestamp: 100
|
||||
}, 'session-1');
|
||||
(service as any).appendStartupProgress({
|
||||
session_id: 'session-1',
|
||||
stage: 'device',
|
||||
message: 'Device connected',
|
||||
timestamp: 103
|
||||
}, 'session-1');
|
||||
|
||||
const events = service.startupProgressBySession()['session-1'];
|
||||
expect(events.map((event) => event.stage)).toEqual(['queued', 'device']);
|
||||
expect(events[1].message).toBe('Device connected');
|
||||
});
|
||||
|
||||
it('normalizes a live retry event into the historical trace contract', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
const event = {
|
||||
@@ -107,6 +136,42 @@ describe('AgentService live LLM retry timeline', () => {
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith('new-session', false);
|
||||
});
|
||||
|
||||
it('keeps the paused state when the backend says there is nothing to resume', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
(service as any).http = { post: () => of({ status: 'not_paused' }) };
|
||||
(service as any).rawSessions = signal<any[]>([{ session_id: 'session-1', status: 'paused' }]);
|
||||
(service as any).pendingQueue = signal<any[]>([]);
|
||||
service.agentStatus = signal('paused');
|
||||
service.runningSessionId = signal<string | null>('session-1');
|
||||
service.isPaused = signal(true);
|
||||
service.pausedError = signal<string | null>('503 unavailable');
|
||||
const statusSpy = spyOn(service, 'fetchStatus');
|
||||
|
||||
service.resumeTask();
|
||||
|
||||
expect(service.agentStatus()).toBe('paused');
|
||||
expect(service.isPaused()).toBeTrue();
|
||||
expect(statusSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the active session to running only after resume succeeds', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
(service as any).http = { post: () => of({ status: 'resumed' }) };
|
||||
(service as any).rawSessions = signal<any[]>([{ session_id: 'session-1', status: 'paused' }]);
|
||||
(service as any).pendingQueue = signal<any[]>([]);
|
||||
service.agentStatus = signal('paused');
|
||||
service.runningSessionId = signal<string | null>('session-1');
|
||||
service.isPaused = signal(true);
|
||||
service.pausedError = signal<string | null>('503 unavailable');
|
||||
spyOn(service, 'fetchStatus');
|
||||
|
||||
service.resumeTask();
|
||||
|
||||
expect(service.agentStatus()).toBe('running');
|
||||
expect(service.isPaused()).toBeFalse();
|
||||
expect((service as any).rawSessions()[0].status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentService recording finalization lifecycle', () => {
|
||||
|
||||
@@ -38,6 +38,13 @@ export type RecordingPlaybackStatus =
|
||||
| 'failed'
|
||||
| 'unavailable';
|
||||
|
||||
export interface StartupProgressEvent {
|
||||
session_id?: string;
|
||||
stage: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface SessionVideoResponse {
|
||||
session_id: string;
|
||||
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||
@@ -90,7 +97,7 @@ export class AgentService {
|
||||
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
|
||||
sessionMap.set(p.session_id, {
|
||||
...p,
|
||||
status: isCurrentRunning ? status : 'pending'
|
||||
status: isCurrentRunning ? status : (p.status || 'pending')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -120,6 +127,17 @@ export class AgentService {
|
||||
public retryMessage = signal<string | null>(null);
|
||||
public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
|
||||
public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
|
||||
public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
|
||||
private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
|
||||
|
||||
public currentStartupProgress = computed(() => {
|
||||
const sessionId = this.currentSessionId();
|
||||
if (sessionId) {
|
||||
const sessionEvents = this.startupProgressBySession()[sessionId];
|
||||
if (sessionEvents?.length) return sessionEvents;
|
||||
}
|
||||
return this.pendingStartupProgress();
|
||||
});
|
||||
|
||||
// Notes and Tab States
|
||||
public currentNotes = signal<Record<string, string>>({});
|
||||
@@ -210,6 +228,12 @@ export class AgentService {
|
||||
enableOutputter?: boolean
|
||||
): Observable<any> {
|
||||
return new Observable((obs) => {
|
||||
const submittedEvent: StartupProgressEvent = {
|
||||
stage: 'submitting',
|
||||
message: 'Submitting the task',
|
||||
timestamp: Date.now() / 1000
|
||||
};
|
||||
this.pendingStartupProgress.set([submittedEvent]);
|
||||
const payload: any = { goal, profile };
|
||||
if (expectedOutput && expectedOutput.trim()) {
|
||||
payload.expected_output = expectedOutput.trim();
|
||||
@@ -223,6 +247,11 @@ export class AgentService {
|
||||
if (res && res.tasks && res.tasks.length > 0) {
|
||||
const newSessionId = res.tasks[0].session_id;
|
||||
if (newSessionId) {
|
||||
this.appendStartupProgress(
|
||||
{ ...submittedEvent, session_id: newSessionId },
|
||||
newSessionId
|
||||
);
|
||||
this.pendingStartupProgress.set([]);
|
||||
const isCurrentlyRunning =
|
||||
this.agentStatus() === 'running' ||
|
||||
this.sessions().some((s) => s.status === 'running');
|
||||
@@ -236,10 +265,12 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pendingStartupProgress.set([]);
|
||||
obs.next(res);
|
||||
obs.complete();
|
||||
},
|
||||
error: (err) => {
|
||||
this.pendingStartupProgress.set([]);
|
||||
obs.error(err);
|
||||
}
|
||||
});
|
||||
@@ -250,6 +281,16 @@ export class AgentService {
|
||||
* Stop the currently running task (and optionally clear queue)
|
||||
*/
|
||||
public stopTask(stopAll: boolean = false): void {
|
||||
const stoppingSessionId = this.runningSessionId()
|
||||
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id
|
||||
|| null;
|
||||
|
||||
// Keep the task's terminal state stable while the stop request and the
|
||||
// persisted session update cross the network. Clearing the global runner
|
||||
// state first used to make the session merger infer "completed" from the
|
||||
// still-stale DB row, causing a brief completed -> cancelled flicker.
|
||||
this.setSessionStatus(stoppingSessionId, 'cancelled');
|
||||
|
||||
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty
|
||||
if (stopAll || this.pendingQueue().length === 0) {
|
||||
this.agentStatus.set('idle');
|
||||
@@ -290,10 +331,19 @@ export class AgentService {
|
||||
* Resume paused task
|
||||
*/
|
||||
public resumeTask(): void {
|
||||
this.http.post('/api/resume', {}).subscribe({
|
||||
next: () => {
|
||||
this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
|
||||
next: (response) => {
|
||||
if (response?.status !== 'resumed') {
|
||||
// A stale recovery control must not optimistically change the task
|
||||
// back to running when the backend says there is nothing to resume.
|
||||
this.fetchStatus();
|
||||
return;
|
||||
}
|
||||
const resumedSessionId = this.runningSessionId();
|
||||
this.isPaused.set(false);
|
||||
this.pausedError.set(null);
|
||||
this.agentStatus.set('running');
|
||||
this.setSessionStatus(resumedSessionId, 'running');
|
||||
this.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -473,6 +523,7 @@ export class AgentService {
|
||||
'task_paused',
|
||||
'task_resumed',
|
||||
'llm_retrying',
|
||||
'startup_progress',
|
||||
'session_started',
|
||||
'session_ended',
|
||||
'recording_ready',
|
||||
@@ -523,6 +574,7 @@ export class AgentService {
|
||||
this.selectSession(parsedData.session_id, false);
|
||||
}
|
||||
} else if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(evtSessionId, parsedData);
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
this.updateModelForCurrentView();
|
||||
@@ -548,6 +600,7 @@ export class AgentService {
|
||||
}
|
||||
|
||||
if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(sessionId, parsedData);
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
@@ -566,6 +619,11 @@ export class AgentService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === 'startup_progress') {
|
||||
this.appendStartupProgress(parsedData, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === 'llm_retrying') {
|
||||
this.isRetrying.set(true);
|
||||
const attempt = Number(parsedData.attempt || 0);
|
||||
@@ -581,6 +639,9 @@ export class AgentService {
|
||||
if (eventType === 'task_paused') {
|
||||
this.isPaused.set(true);
|
||||
this.isRetrying.set(false);
|
||||
this.agentStatus.set('paused');
|
||||
this.runningSessionId.set(sessionId);
|
||||
this.setSessionStatus(sessionId, 'paused');
|
||||
const pauseError = parsedData.error || 'AI call failed';
|
||||
this.pausedError.set(pauseError);
|
||||
this.appendPausedErrorCard(
|
||||
@@ -597,6 +658,10 @@ export class AgentService {
|
||||
this.isPaused.set(false);
|
||||
this.isRetrying.set(false);
|
||||
this.pausedError.set(null);
|
||||
if (this.runningSessionId() === sessionId) {
|
||||
this.agentStatus.set('running');
|
||||
this.setSessionStatus(sessionId, 'running');
|
||||
}
|
||||
this.activePauseCardKey = null;
|
||||
return;
|
||||
}
|
||||
@@ -888,6 +953,34 @@ export class AgentService {
|
||||
|
||||
private pollCounter: number = 0;
|
||||
|
||||
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
|
||||
if (!sessionId) return;
|
||||
|
||||
this.rawSessions.update((sessions) => sessions.map((session) =>
|
||||
session.session_id === sessionId ? { ...session, status } : session
|
||||
));
|
||||
this.pendingQueue.update((sessions) => sessions.map((session) =>
|
||||
session.session_id === sessionId ? { ...session, status } : session
|
||||
));
|
||||
}
|
||||
|
||||
private applySessionEndedStatus(sessionId: unknown, data: any): void {
|
||||
if (!sessionId) return;
|
||||
|
||||
const reportedStatus = String(data?.status || '').toLowerCase();
|
||||
const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
|
||||
? 'cancelled'
|
||||
: reportedStatus === 'failed'
|
||||
? 'failed'
|
||||
: reportedStatus === 'completed' || reportedStatus === 'success'
|
||||
? 'completed'
|
||||
: null;
|
||||
|
||||
if (status) {
|
||||
this.setSessionStatus(String(sessionId), status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic status polling from the backend
|
||||
*/
|
||||
@@ -1114,6 +1207,32 @@ export class AgentService {
|
||||
this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
|
||||
}
|
||||
|
||||
private appendStartupProgress(data: any, sessionId: string): void {
|
||||
if (!data?.stage || !data?.message) return;
|
||||
|
||||
const timestamp = typeof data.timestamp === 'number'
|
||||
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
|
||||
: Date.now() / 1000;
|
||||
const event: StartupProgressEvent = {
|
||||
session_id: String(data.session_id || sessionId),
|
||||
stage: String(data.stage),
|
||||
message: String(data.message),
|
||||
timestamp
|
||||
};
|
||||
|
||||
this.startupProgressBySession.update((allEvents) => {
|
||||
const current = [...(allEvents[sessionId] || [])];
|
||||
const existingIndex = current.findIndex((item) => item.stage === event.stage);
|
||||
if (existingIndex >= 0) {
|
||||
current[existingIndex] = event;
|
||||
} else {
|
||||
current.push(event);
|
||||
}
|
||||
current.sort((a, b) => a.timestamp - b.timestamp);
|
||||
return { ...allEvents, [sessionId]: current };
|
||||
});
|
||||
}
|
||||
|
||||
private beginRecordingFinalization(sessionId: string): void {
|
||||
if (this.activeVideoSessionId !== sessionId) return;
|
||||
this.cancelVideoRetry();
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { getUniqueGenericTools } from './tool-formatter.util';
|
||||
import { cleanErrorMessage, getUniqueGenericTools } from './tool-formatter.util';
|
||||
|
||||
describe('cleanErrorMessage', () => {
|
||||
it('removes repeated LLM wrapper labels while preserving the provider reason', () => {
|
||||
expect(cleanErrorMessage('LLM Error: LLM Request Error: 503 model overloaded'))
|
||||
.toBe('503 model overloaded');
|
||||
});
|
||||
|
||||
it('does not present an empty LLM wrapper label as an error reason', () => {
|
||||
expect(cleanErrorMessage('LLM Error:')).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUniqueGenericTools retry aggregation', () => {
|
||||
it('groups recoverable LLM retries while preserving every attempt', () => {
|
||||
|
||||
@@ -1022,7 +1022,11 @@ export function cleanErrorMessage(rawError: any): string {
|
||||
.replace(/^Pixel-level validation failed:\s*/i, '')
|
||||
.replace(/^Execution error:\s*/i, '')
|
||||
.replace(/^ServerError:\s*/i, '')
|
||||
// LLM wrappers may add this prefix more than once. It is context, not the
|
||||
// actual provider reason, and an empty prefix must not be shown as though
|
||||
// it were a useful error message.
|
||||
.replace(/^(?:LLM\s+(?:Request\s+)?Error\s*:\s*)+/i, '')
|
||||
.trim();
|
||||
|
||||
return fallback || errorStr;
|
||||
return fallback || 'Unknown error';
|
||||
}
|
||||
Reference in new issue
Block a user