Files
Artemis-Eapil/apps/showcase_ui/src/app/services/agent.service.spec.ts
T

534 lines
22 KiB
TypeScript

import { signal, computed } from '@angular/core';
import { of } from 'rxjs';
import { AgentService } from './agent.service';
describe('AgentService live LLM retry timeline', () => {
function createServiceWithoutPolling(): AgentService {
const service = Object.create(AgentService.prototype) as AgentService;
service.sessionLogs = signal<any[]>([]);
service.isSessionContentLoading = signal(false);
service.startupProgressBySession = signal({});
(service as any).pendingStartupProgress = signal<any[]>([]);
(service as any).sessionLoadGeneration = 0;
(service as any).sessionSnapshotRequestId = 0;
(service as any).sessionSnapshotAppliedId = 0;
(service as any).pendingSnapshotRequests = new Set<number>();
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 = {
trace_id: 'retry-1',
step_id: 'step-1',
timestamp: 100,
error: '503 high demand',
delay: 1.5,
provider: 'google',
source: 'provider_sdk',
recoverable: true,
request_id: 'request-1',
scheduled_at: 100
};
(service as any).appendLiveLLMRetryTrace(event, 'session-1');
const [log] = service.sessionLogs();
expect(log.type).toBe('trace_recorded');
expect(log.data).toEqual(jasmine.objectContaining({
trace_id: 'retry-1',
session_id: 'session-1',
step_id: 'step-1',
type: 'llm_call',
name: 'llm_retry',
status: 'retrying'
}));
expect(log.data.payload).toEqual(jasmine.objectContaining({
error: '503 high demand',
delay: 1.5,
provider: 'google',
source: 'provider_sdk',
request_id: 'request-1'
}));
});
it('upserts duplicate live retry events instead of adding another row', () => {
const service = createServiceWithoutPolling();
const event = {
trace_id: 'retry-1',
timestamp: 100,
delay: 1.5,
provider: 'google',
source: 'provider_sdk',
request_id: 'request-1',
scheduled_at: 100
};
(service as any).appendLiveLLMRetryTrace(event, 'session-1');
(service as any).appendLiveLLMRetryTrace({ ...event, error: 'updated 503' }, 'session-1');
expect(service.sessionLogs().length).toBe(1);
expect(service.sessionLogs()[0].data.payload.error).toBe('updated 503');
});
it('replaces an old history snapshot while preserving live retry events', () => {
const service = createServiceWithoutPolling();
(service as any).currentSessionId = signal<string | null>('session-1');
(service as any).http = {
get: () => of([{
step_id: 'step-1',
timestamp: 100,
generic_tools: [{ trace_id: 'persisted-retry', name: 'llm_retry' }]
}])
};
service.sessionLogs.set([
{ type: 'step_updated', history_snapshot: true, data: { step_id: 'old-step' } },
{ type: 'trace_recorded', data: { trace_id: 'live-retry', name: 'llm_retry' } }
]);
service.isSessionContentLoading.set(true);
(service as any).backfillSessionSteps('session-1');
const logs = service.sessionLogs();
expect(logs.length).toBe(2);
expect(logs[0].history_snapshot).toBeTrue();
expect(logs[0].data.step_id).toBe('step-1');
expect(logs[1].data.trace_id).toBe('live-retry');
expect(service.isSessionContentLoading()).toBeFalse();
});
it('attaches the persisted checker transcript to the attempt it belongs to', () => {
const service = createServiceWithoutPolling();
(service as any).currentSessionId = signal<string | null>('session-1');
(service as any).http = {
get: () => of({
records: [
{ attempt_id: 'abc#1', checkpoint_id: 'abc', subgoal_text: 'Create the alarm', trace_id: 't-1', item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen', ts: 10 },
{ attempt_id: 'final#1', checkpoint_id: 'final', trace_id: 't-2', item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen', ts: 20 }
],
streams: [
{
attempt_id: 'abc#1',
trace_id: 't-1',
ts: 11,
segments: [
{ execution_id: 'e-1', role: 'thought', when: 8, text: 'Looking' },
{ execution_id: 'e-1', role: 'answer', when: 9, text: 'Seen it' }
]
}
],
run_outcome: null
})
};
service.sessionLogs.set([{ type: 'checker_event', checks_snapshot: true, data: { attempt_id: 'stale' } }]);
service.fetchChecks('session-1');
const logs = service.sessionLogs();
expect(logs.map((l) => l.data.attempt_id)).toEqual(['abc#1', 'final#1']);
expect(logs[0].data.stream_segments).toEqual([
{ execution_id: 'e-1', stream_type: 'thinking', text: 'Looking', timestamp: new Date(8000).toISOString(), isCompleted: true },
{ execution_id: 'e-1', stream_type: 'text', text: 'Seen it', timestamp: new Date(9000).toISOString(), isCompleted: true }
]);
expect('stream_segments' in logs[1].data).toBeFalse();
});
it('follows a just-started task even when status polling saw it first', () => {
const service = createServiceWithoutPolling();
(service as any).http = {
post: () => of({ tasks: [{ session_id: 'new-session' }] })
};
service.agentStatus = signal('running');
service.runningSessionId = signal<string | null>('new-session');
service.userPinnedSessionId = signal<string | null>(null);
(service as any).sessions = signal<any[]>([{
session_id: 'new-session',
status: 'running'
}]);
const selectSpy = spyOn(service, 'selectSession');
service.runTask('test goal').subscribe();
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', () => {
function createVideoService(response: any): AgentService {
const service = Object.create(AgentService.prototype) as AgentService;
(service as any).http = { get: () => of(response) };
(service as any).rawSessions = signal<any[]>([
{ session_id: 'session-1', status: 'completed', recording_status: 'recording' }
]);
(service as any).activeVideoSessionId = 'session-1';
(service as any).videoRequestGeneration = 1;
(service as any).videoRetryTimer = null;
(service as any).videoWaitStartedAt = Date.now();
service.activeVideoUrl = signal<string | null>(null);
service.activeVideoSegments = signal<any[]>([]);
service.recordingSources = signal([]);
service.selectedRecordingSource = signal('');
service.usesSessionTimeline = signal(false);
service.recordingSessionDuration = signal(0);
service.activeVideoTitle = signal('');
service.isVideoWindowOpen = signal(false);
service.isVideoMinimized = signal(false);
service.videoSeekRequest = signal(null);
service.currentSessionId = signal<string | null>('session-1');
service.sessions = computed(() => (service as any).rawSessions());
(service as any).videoSeekRequestId = 0;
service.isVideoLoading = signal(false);
service.recordingPlaybackStatus = signal('idle');
service.recordingPlaybackMessage = signal('');
service.shouldAutoplayVideo = signal(true);
service.playerMode = signal<'video' | 'steps'>('video');
(service as any).hasCurrentSessionStepFrames = computed(() => false);
(service as any).currentSessionStepFrames = computed(() => []);
return service;
}
it('keeps unfinished media out of the video element and schedules another readiness check', () => {
const service = createVideoService({
session_id: 'session-1',
status: 'processing',
has_video: false,
video_url: null,
retry_after_ms: 750
});
const retrySpy = spyOn<any>(service, 'scheduleVideoRetry');
(service as any).requestSessionVideo('session-1', 1);
expect(service.recordingPlaybackStatus()).toBe('processing');
expect(service.isVideoLoading()).toBeTrue();
expect(service.activeVideoUrl()).toBeNull();
expect(retrySpy).toHaveBeenCalledWith('session-1', 1, 750);
});
it('publishes finalized media and preserves the automatic replay request', () => {
const service = createVideoService({
session_id: 'session-1',
status: 'ready',
has_video: true,
video_url: '/videos/recording.mp4?v=1',
video_segments: [
{ url: '/videos/recording.mp4?v=1', start: 0, duration: 5, width: 1080, height: 1920 }
]
});
(service as any).requestSessionVideo('session-1', 1);
expect(service.recordingPlaybackStatus()).toBe('ready');
expect(service.isVideoLoading()).toBeFalse();
expect(service.activeVideoUrl()).toBe('/videos/recording.mp4?v=1');
expect(service.activeVideoSegments().length).toBe(1);
expect(service.shouldAutoplayVideo()).toBeTrue();
});
it('stops polling and exposes a terminal recording failure', () => {
const service = createVideoService({
session_id: 'session-1',
status: 'failed',
has_video: false,
video_url: null,
message: 'ffmpeg failed'
});
(service as any).requestSessionVideo('session-1', 1);
expect(service.recordingPlaybackStatus()).toBe('failed');
expect(service.isVideoLoading()).toBeFalse();
expect(service.recordingPlaybackMessage()).toBe('ffmpeg failed');
});
it('publishes task-time sources and selects a computer recording by its target alias', () => {
const android = {
id: 'android', label: '安卓', kind: 'android', target_ids: ['android'], status: 'ready',
segments: [{ url: '/videos/android.mp4', start: 0, duration: 10, width: 720, height: 1280 }]
};
const computer = {
id: 'screen-1', label: '电脑', kind: 'computer', target_ids: ['window-1', 'window-2'], status: 'ready',
message: '已使用兼容编码',
segments: [{ url: '/videos/computer.mp4', start: 12, duration: 10, width: 1280, height: 720 }]
};
const service = createVideoService({
session_id: 'session-1', status: 'ready', has_video: true,
video_url: android.segments[0].url, video_segments: android.segments,
sources: [android, computer], session_timeline: true, session_duration: 25
});
(service as any).requestSessionVideo('session-1', 1);
expect(service.recordingSources()).toEqual([android, computer]);
expect(service.selectedRecordingSource()).toBe('android');
expect(service.usesSessionTimeline()).toBeTrue();
expect(service.recordingSessionDuration()).toBe(25);
expect(service.selectRecordingSource('window-2')).toBeTrue();
expect(service.selectedRecordingSource()).toBe('screen-1');
expect(service.activeVideoUrl()).toBe('/videos/computer.mp4');
expect(service.activeVideoSegments()).toBe(computer.segments);
expect(service.recordingPlaybackMessage()).toBe('已使用兼容编码');
expect(service.selectRecordingSource('screen-1')).toBeTrue();
expect(service.activeVideoSegments()).toBe(computer.segments);
expect(service.selectRecordingSource('missing-source')).toBeFalse();
expect(service.selectedRecordingSource()).toBe('screen-1');
expect(service.activeVideoUrl()).toBe('/videos/computer.mp4');
});
it('keeps a failed source explicit without retaining another source video', () => {
const service = createVideoService({});
service.activeVideoUrl.set('/videos/android.mp4');
service.recordingSources.set([{
id: 'screen-1', label: '电脑', kind: 'computer', target_ids: ['window-1'], status: 'failed',
segments: [], message: '录制进程未产生画面'
}]);
expect(service.selectRecordingSource('window-1')).toBeTrue();
expect(service.activeVideoUrl()).toBeNull();
expect(service.activeVideoSegments()).toEqual([]);
expect(service.recordingPlaybackMessage()).toBe('录制进程未产生画面');
});
it('clears the previous task source and seek command when opening another task normally', () => {
const service = createVideoService({});
const load = spyOn<any>(service, 'requestSessionVideo');
service.recordingSources.set([{
id: 'screen-old', label: '电脑', kind: 'computer', target_ids: ['window-old'], status: 'ready',
segments: [{ url: '/videos/old.mp4', start: 0, duration: 10, width: 1280, height: 720 }]
}]);
service.selectRecordingSource('screen-old');
service.usesSessionTimeline.set(true);
service.recordingSessionDuration.set(10);
service.requestVideoSeek(6, 'window-old');
service.openVideoPlayer('session-2');
expect(service.videoSeekRequest()).toBeNull();
expect(service.recordingSources()).toEqual([]);
expect(service.selectedRecordingSource()).toBe('');
expect(service.usesSessionTimeline()).toBeFalse();
expect(service.recordingSessionDuration()).toBe(0);
expect(service.activeVideoSegments()).toEqual([]);
expect(service.activeVideoUrl()).toBeNull();
expect(load).toHaveBeenCalledOnceWith('session-2', 2);
});
it('replaces the previous task seek with the new source-specific command', () => {
const service = createVideoService({});
spyOn<any>(service, 'requestSessionVideo');
service.requestVideoSeek(6, 'window-old');
const oldRequestId = service.videoSeekRequest()!.requestId;
service.openVideoPlayer('session-2', undefined, undefined, 14, undefined, 'window-new');
expect(service.videoSeekRequest()).toEqual({ seconds: 14, sourceId: 'window-new', requestId: oldRequestId + 1 });
});
});
describe('AgentService video analysis seeking', () => {
it('publishes repeatable, clamped seek requests for the floating player', () => {
const service = Object.create(AgentService.prototype) as AgentService;
service.videoSeekRequest = signal<{ seconds: number; requestId: number } | null>(null);
(service as any).videoSeekRequestId = 0;
service.requestVideoSeek(-5);
const first = service.videoSeekRequest();
service.requestVideoSeek(0);
const second = service.videoSeekRequest();
expect(first?.seconds).toBe(0);
expect(second?.seconds).toBe(0);
expect(second?.requestId).toBeGreaterThan(first?.requestId || 0);
});
});
describe('AgentService task cancellation and active session tracking', () => {
it('computes isCurrentSessionRunning true only when viewing an active running/paused session', () => {
const service = Object.create(AgentService.prototype) as any;
service.currentSessionId = signal<string | null>(null);
service.rawSessions = signal<any[]>([]);
service.activeTasks = signal<any[]>([]);
service.pendingQueue = signal<any[]>([]);
service.agentStatus = signal('idle');
service.runningSessionId = signal<string | null>(null);
service.runningGoal = signal<string | null>(null);
service.activeSessionTracking = new Map();
service.currentSession = computed(() => {
const curId = service.currentSessionId();
if (!curId) return null;
return service.sessions().find((s: any) => s.session_id === curId) || null;
});
service.sessions = computed(() => {
return service.rawSessions();
});
service.isCurrentSessionRunning = computed(() => {
const curId = service.currentSessionId();
if (!curId) return false;
const session = service.currentSession();
if (session) {
return session.status === 'running' || session.status === 'paused';
}
const isActiveStatus = service.agentStatus() === 'running' || service.agentStatus() === 'paused';
if (isActiveStatus && service.runningSessionId() === curId) {
return true;
}
if (service.activeTasks().some((at: any) => at.session_id === curId)) {
return true;
}
return false;
});
// 1. No session selected -> false
expect(service.isCurrentSessionRunning()).toBeFalse();
// 2. Completed session selected -> false
service.rawSessions.set([
{ session_id: 'sess-completed', status: 'completed', initial_goal: '完成', start_time: 100 },
{ session_id: 'sess-running', status: 'running', initial_goal: '执行中', start_time: 101 }
]);
service.currentSessionId.set('sess-completed');
expect(service.isCurrentSessionRunning()).toBeFalse();
// 3. Active running session selected -> true
service.currentSessionId.set('sess-running');
expect(service.isCurrentSessionRunning()).toBeTrue();
// 4. Paused session selected -> true
service.rawSessions.set([
{ session_id: 'sess-paused', status: 'paused', initial_goal: '已暂停', start_time: 102 }
]);
service.currentSessionId.set('sess-paused');
expect(service.isCurrentSessionRunning()).toBeTrue();
});
it('stops a specific session by passing its session_id', () => {
const service = Object.create(AgentService.prototype) as AgentService;
service.currentSessionId = signal<string | null>('sess-2');
service.runningSessionId = signal<string | null>('sess-1');
service.runningGoal = signal<string | null>('Goal 1');
service.agentStatus = signal('running');
service.isPaused = signal(false);
service.pausedError = signal<string | null>(null);
service.isRetrying = signal(false);
service.sessionLogs = signal<any[]>([]);
service.activeTasks = signal<any[]>([{ session_id: 'sess-1' }, { session_id: 'sess-2' }]);
(service as any).pendingQueue = signal<any[]>([]);
(service as any).rawSessions = signal<any[]>([
{ session_id: 'sess-1', status: 'running', initial_goal: 'Goal 1' },
{ session_id: 'sess-2', status: 'running', initial_goal: 'Goal 2' }
]);
service.sessions = computed(() => (service as any).rawSessions());
let postedUrl = '';
let postedPayload: any = null;
(service as any).http = {
post: (url: string, payload: any) => {
postedUrl = url;
postedPayload = payload;
return of({});
}
};
(service as any).setSessionStatus = (sid: string, st: string) => {};
(service as any).fetchStatus = () => {};
(service as any).fetchSessions = () => {};
// Stop sess-2 specifically
service.stopTask('sess-2', false);
expect(postedUrl).toContain('session_id=sess-2');
expect(postedPayload?.session_id).toBe('sess-2');
// Because sess-1 is still running, agentStatus should NOT be reset to idle
expect(service.agentStatus()).toBe('running');
// activeTasks should have filtered out sess-2
expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']);
});
});
describe('Exploration queue grouping',()=>{
const service=Object.create(AgentService.prototype) as AgentService;
it('keeps validations independent and groups only exploration executions',()=>{
service.batchFilter=signal('__exploration__');
const old:any={session_id:'old',exploration_id:'e',exploration_kind:'exploration',exploration_latest_run_id:'new'};
const next:any={...old,session_id:'new',exploration_status:'paused'};
const validation:any={...old,session_id:'validation',exploration_kind:'validation'};
expect(service.isQueueRepresentative(old)).toBeFalse();expect(service.isQueueRepresentative(next)).toBeTrue();expect(service.isQueueRepresentative(validation)).toBeTrue();
expect(service.matchesBatchFilter(next)).toBeTrue();expect(service.matchesBatchFilter(validation)).toBeFalse();expect(service.explorationQueueStatus(next)).toBe('paused');
service.batchFilter.set('__single__');expect(service.matchesBatchFilter(validation)).toBeTrue();expect(service.matchesBatchFilter(next)).toBeFalse();
});
});
describe('Queued exploration identity',()=>{
it('preserves exploration mode before the session row exists',()=>{
const service=Object.create(AgentService.prototype) as AgentService;
const fields=service.explorationSessionFields({exploration_id:'conversation',exploration_kind:'exploration',exploration_model:'gpt-6.1-sol',exploration_latest_run_id:'new',exploration_status:'queued'});
expect(fields.exploration_id).toBe('conversation');expect(fields.model_info?.architecture).toBe('explore');expect(fields.exploration_latest_run_id).toBe('new');
});
});