feat: unify history tools and improve execution reliability

This commit is contained in:
somew1nd committed 2026-09-03 16:05:47 -07:00
1 parent d23a4f6472
commit 2fea9865ee
116 files changed
+8291 -2503

No files matched your search

+1 -1
View File
@@ -224,7 +224,7 @@
},
"node_modules/@angular/cdk": {
"version": "22.1.5",
"resolved": "http://airlock-proxy.uplink.goog:999/npm/artifact-foundry-prod/ah-3p-staging-npm/@angular/cdk/-/cdk-22.1.5.tgz",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-22.1.5.tgz",
"integrity": "sha512-1tYAeI5KKIqNGNvyDuMM0Drs2FU3M4B25Q1BlAnwtZe3wvrpVETAGXgHaBX3Hs/pvWfgMNwgbcHkxlKVUBNXMA==",
"license": "MIT",
"dependencies": {
@@ -387,17 +387,95 @@ limitations under the License.
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
<div class="stream-floating-toolbar">
<!-- Active Architecture Info Chip (Informational Metadata, Not a Button) -->
@if (agentService.activeModel(); as model) {
<div
<!-- Active Architecture Chip: click opens the run info popover -->
@if (agentService.viewedModel(); as model) {
<button
type="button"
#runInfoOrigin="cdkOverlayOrigin"
cdkOverlayOrigin
class="model-info-chip"
[ngClass]="getModelClass(model.name)"
[class.active]="isRunInfoOpen()"
[title]="getArchitectureTooltip(model)"
aria-haspopup="dialog"
[attr.aria-expanded]="isRunInfoOpen()"
(click)="toggleRunInfo($event)"
>
<span class="material-symbols-outlined model-icon">{{ getModelIcon(model.name) }}</span>
<span class="model-label">Architecture:</span>
<span class="model-name">{{ getModelDisplayName(model.name) }}</span>
</div>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="runInfoOrigin"
[cdkConnectedOverlayOpen]="isRunInfoOpen()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
(backdropClick)="isRunInfoOpen.set(false)"
[cdkConnectedOverlayPositions]="dropdownPositions"
>
<div class="vscode-dropdown-menu run-info-dropdown" role="dialog" aria-label="Run info">
<div class="run-info-header">
<span class="material-symbols-outlined" [class.is-pro]="runInfoIsPro()">{{ getModelIcon(model.name) }}</span>
<span class="run-info-title">{{ getModelDisplayName(model.name) }}</span>
@if (model.id) {
<span class="run-info-model" [title]="model.id">{{ model.id }}</span>
}
</div>
<div class="run-info-row">
<span class="run-info-label">Elapsed</span>
<span class="run-info-value">{{ runInfoElapsed() }}</span>
</div>
<div class="run-info-row">
<span class="run-info-label">Tokens</span>
@if (runUsage(); as usage) {
<span class="run-info-value">
{{ formatRunTokens(usage.total_tokens) }}
<span class="run-info-sub">{{ formatRunTokens(usage.prompt_tokens) }} in · {{ formatRunTokens(usage.completion_tokens) }} out</span>
</span>
} @else {
<span class="run-info-value is-muted">—</span>
}
</div>
<div class="run-info-row is-stacked">
<div class="run-info-line">
<span class="run-info-label">Context</span>
@if (runInfoContextPercent() !== null) {
<span class="run-info-value">
{{ runInfoContextPercent() }}%
<span class="run-info-sub-inline">of {{ formatRunTokens(runUsage()?.operator_context_window_tokens) }}</span>
</span>
} @else {
<span class="run-info-value is-muted">—</span>
}
</div>
<div
class="run-info-meter"
[class.is-high]="(runInfoContextPercent() ?? 0) >= 70"
[class.is-critical]="(runInfoContextPercent() ?? 0) >= 90"
>
<div class="run-info-meter-fill" [style.width.%]="runInfoContextPercent() ?? 0"></div>
</div>
</div>
@if (runInfoIsPro()) {
<div class="run-info-row">
<span class="run-info-label">Result check</span>
<span class="run-info-value">{{ runTuningLabel('verify', runInfoTuning()?.verification_level) }}</span>
</div>
<div class="run-info-row">
<span class="run-info-label">Screen reading</span>
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
</div>
} @else {
<div class="run-info-note">No extra settings for Flash.</div>
}
</div>
</ng-template>
}
<!-- Screen Recording Action Button (Interactive Button) -->
<button
@@ -224,21 +224,44 @@ $stream-row-height: 25px;
top: 72px;
}
/* Model Info Chip: Informational Tag / Metadata (Not clickable) */
/* Model Info Chip: click opens the run info popover */
.model-info-chip {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 11px;
margin: 0;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 15px;
cursor: default;
cursor: pointer;
user-select: none;
appearance: none;
-webkit-appearance: none;
&:hover {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(203, 213, 225, 0.95);
}
&.active {
background: #eff6ff;
border-color: #bfdbfe;
.model-icon,
.model-name {
color: #1d4ed8;
}
}
&:focus-visible {
outline: 2px solid #93c5fd;
outline-offset: 1px;
}
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.9);
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -3044,6 +3067,137 @@ $stream-row-height: 25px;
}
}
/* Run info popover: elapsed, tokens, context share, Pro tuning */
&.run-info-dropdown {
width: 288px;
gap: 0;
padding: 12px 14px 10px;
.run-info-header {
display: flex;
align-items: center;
gap: 7px;
padding-bottom: 9px;
border-bottom: 1px solid #e2e8f0;
.material-symbols-outlined {
font-size: 18px;
color: #64748b;
&.is-pro {
color: #2563eb;
}
}
.run-info-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.run-info-model {
margin-left: auto;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
font-size: 10.5px;
color: #94a3b8;
}
}
.run-info-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&.is-stacked {
flex-direction: column;
align-items: stretch;
gap: 0;
}
}
.run-info-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.run-info-label {
font-size: 12px;
font-weight: 500;
color: #64748b;
white-space: nowrap;
}
.run-info-value {
font-size: 12.5px;
font-weight: 600;
color: #0f172a;
text-align: right;
font-variant-numeric: tabular-nums;
&.is-muted {
color: #cbd5e1;
font-weight: 500;
}
}
.run-info-sub {
display: block;
margin-top: 2px;
font-size: 11px;
font-weight: 400;
color: #94a3b8;
}
.run-info-sub-inline {
margin-left: 3px;
font-size: 11px;
font-weight: 400;
color: #94a3b8;
}
.run-info-meter {
height: 4px;
margin-top: 7px;
border-radius: 2px;
background: #e2e8f0;
overflow: hidden;
.run-info-meter-fill {
height: 100%;
border-radius: 2px;
background: #2563eb;
transition: width 0.3s ease;
}
&.is-high .run-info-meter-fill {
background: #f59e0b;
}
&.is-critical .run-info-meter-fill {
background: #ef4444;
}
}
.run-info-note {
padding-top: 9px;
font-size: 11.5px;
color: #94a3b8;
}
}
&.notes-dropdown {
width: 380px;
max-height: 520px;
@@ -20,10 +20,18 @@ import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo } from '../../core/models/session.model';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
import {
contextPercent,
formatCompactTokens,
formatElapsed,
parseRunTuning,
sessionElapsedSeconds,
tuningLabel
} from '../../utils/run-info.util';
export const PLANNING_LOADER_PHRASES: string[] = [
'Planning next step...',
@@ -235,6 +243,34 @@ export class AgentStreamComponent implements AfterViewInit {
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with.
public isRunInfoOpen = signal<boolean>(false);
public runUsage = signal<SessionUsage | null>(null);
private runInfoClock = signal<number>(Date.now());
public runInfoIsPro = computed(() => this.getModelClass(this.agentService.viewedModel()?.name) === 'is-pro');
public runInfoElapsed = computed(() => {
const session = this.agentService.currentSession();
const running = this.agentService.isCurrentSessionRunning();
return formatElapsed(sessionElapsedSeconds(session, running, this.runInfoClock()));
});
public runInfoTuning = computed(() => {
const usage = this.runUsage();
if (usage?.run_tuning && (usage.run_tuning.verification_level || usage.run_tuning.explorer_mode)) {
return usage.run_tuning;
}
return parseRunTuning(this.agentService.currentSession()?.device_info);
});
public runInfoContextPercent = computed(() => {
const usage = this.runUsage();
if (!usage) return null;
return contextPercent(usage.operator_context_tokens, usage.operator_context_window_tokens);
});
// CDK Connected Overlay Positioning Strategy
public readonly dropdownPositions: ConnectedPosition[] = [
{
@@ -476,6 +512,28 @@ export class AgentStreamComponent implements AfterViewInit {
}
constructor() {
// Run info popover: fetch usage when opened or when the viewed session
// changes, and keep refreshing while that session is still running.
effect((onCleanup) => {
const open = this.isRunInfoOpen();
const sessionId = this.agentService.currentSessionId();
const running = this.agentService.isCurrentSessionRunning();
if (!open || !sessionId) return;
if (untracked(() => this.runUsage())?.session_id !== sessionId) {
this.runUsage.set(null);
}
this.loadRunUsage(sessionId);
if (!running) return;
const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000);
onCleanup(() => clearInterval(usageInterval));
});
effect((onCleanup) => {
if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return;
this.runInfoClock.set(Date.now());
const clockInterval = setInterval(() => this.runInfoClock.set(Date.now()), 1000);
onCleanup(() => clearInterval(clockInterval));
});
// The retry clock only needs to tick while something on screen is timing
// (startup progress, LLM retry countdowns). An idle page stays quiet.
this.zone.runOutsideAngular(() => {
@@ -758,6 +816,38 @@ export class AgentStreamComponent implements AfterViewInit {
public closeAllDropdowns(): void {
this.isTaskDropdownOpen.set(false);
this.isNotesDropdownOpen.set(false);
this.isRunInfoOpen.set(false);
}
public toggleRunInfo(event: Event): void {
event.stopPropagation();
this.isRunInfoOpen.update((v) => !v);
if (this.isRunInfoOpen()) {
this.isTaskDropdownOpen.set(false);
this.isNotesDropdownOpen.set(false);
}
}
private loadRunUsage(sessionId: string): void {
this.agentService.getSessionUsage(sessionId).subscribe({
next: (usage) => {
if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsage.set(usage);
},
error: (err) => console.error('Failed to load session usage:', err)
});
}
public formatRunElapsed(seconds: number): string {
return formatElapsed(seconds);
}
public formatRunTokens(tokens: number | null | undefined): string {
return formatCompactTokens(tokens);
}
public runTuningLabel(kind: 'verify' | 'explore', id: string | null | undefined): string {
return tuningLabel(kind, id);
}
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
@@ -33,6 +33,7 @@ import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
import { getActionIcon } from '../../utils/action-formatter.util';
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
@Component({
selector: 'app-floating-video-player',
@@ -179,7 +180,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
if (video && video.readyState >= 1) {
const target = this.pendingAbsoluteSeek;
this.pendingAbsoluteSeek = null;
this.seek(target, true);
this.seekToSessionTime(target, true);
}
});
@@ -302,7 +303,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
if (this.pendingAbsoluteSeek !== null) {
const target = this.pendingAbsoluteSeek;
this.pendingAbsoluteSeek = null;
this.seek(target, true);
this.seekToSessionTime(target, true);
// A seek into another finalized segment changes the video source. Let
// that segment's metadata event apply pendingLocalTime.
if (this.pendingLocalTime !== null) return;
@@ -382,19 +383,30 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.seek(0, true);
}
/**
* Seek to a session-relative time (seconds since the task session started,
* the axis step timestamps and analyzer ranges use). Manifest v2 segments
* carry real session offsets, so the time lands in the right segment even
* across scrcpy restart gaps; legacy playlists fall back to the timeline axis.
*/
public seekToSessionTime(sessionSeconds: number, autoplay = false): void {
const segments = this.agentService.activeVideoSegments();
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
}
/** Seek on the back-to-back playlist axis used by the scrubber. */
public seek(seconds: number, autoplay = false): void {
const v = this.videoRef?.nativeElement;
if (!v) return;
const target = Math.max(0, Math.min(this.duration(), seconds));
const segments = this.agentService.activeVideoSegments();
if (!segments.length) {
const location = locateTimelineTime(segments, target);
if (!location) {
v.currentTime = target;
if (autoplay) v.play().catch(() => {});
return;
}
let index = segments.findIndex(segment => target < segment.start + segment.duration);
if (index < 0) index = segments.length - 1;
const localTime = Math.max(0, target - segments[index].start);
const { index, localTime } = location;
if (index === this.activeSegmentIndex()) {
v.currentTime = localTime;
if (autoplay) v.play().catch(() => {});
@@ -57,6 +57,28 @@ export interface AgentStatusResponse {
paused_error?: string | null;
}
/** Per-run Pro tuning persisted with the session (`device_info.run_tuning`). */
export interface SessionRunTuning {
verification_level?: string | null;
explorer_mode?: string | null;
}
/** `GET /api/sessions/{id}/usage`: session-wide LLM usage and the live executor context. */
export interface SessionUsage {
session_id: string;
llm_calls: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
cached_tokens: number;
/** Prompt size of the latest Operator / Flash runner call; null before the first call. */
operator_context_tokens: number | null;
operator_context_window_tokens: number;
operator_context_updated_at?: number | null;
profile?: string | null;
run_tuning?: SessionRunTuning | null;
}
export type AgentStatus = 'idle' | 'running' | 'completed' | 'offline' | string;
export type TaskStatus = 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
@@ -137,7 +137,9 @@ export interface CheckerCheckItem {
/**
* One streamed LLM turn of a multi-turn agent (the Checker's tool loop):
* the text of one execution id with the time its first chunk arrived, so the
* timeline can interleave it with the tool calls that followed.
* timeline can interleave it with the tool calls that followed. Live sessions
* build these from `llm_stream` chunks; historical sessions rebuild them from
* the persisted transcript (`PersistedCheckerStream`).
*/
export interface StreamSegment {
execution_id: string;
@@ -149,6 +151,32 @@ export interface StreamSegment {
resetMessage?: string;
}
/** One persisted segment of a Checker attempt's streamed reasoning (backend `check_streams.jsonl`). */
export interface PersistedStreamSegment {
execution_id: string;
/** `thought` is the model's reasoning stream, `answer` its visible text. */
role: 'thought' | 'answer' | string;
/** Unix seconds of the segment's first chunk. */
when: number;
text: string;
}
/**
* One Checker attempt's persisted transcript as served by
* `GET /api/sessions/{id}/checks` (`streams[]`). Text is bounded server-side;
* `truncated` says the middle was cut out.
*/
export interface PersistedCheckerStream {
attempt_id: string;
checkpoint_id?: string;
phase?: string;
trace_id?: string | null;
ts?: number;
truncated?: boolean;
dropped_chars?: number;
segments: PersistedStreamSegment[];
}
/**
* Data of a `checker` timeline block: one Checker attempt (a midway checkpoint
* of a completed subgoal or the exit final review), or the run outcome
@@ -182,7 +210,7 @@ export interface CheckerBlockData {
duration?: number;
isCompleted?: boolean;
generic_tools?: any[];
/** Per-turn stream segments (live sessions only); the flat fields below hold the joined text. */
/** Per-turn stream segments (live chunks or the persisted transcript); the flat fields below hold the joined text. */
stream_segments?: StreamSegment[];
operator_native_thinking?: string;
operator_raw_thinking?: string;
@@ -125,6 +125,42 @@ describe('AgentService live LLM retry timeline', () => {
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 = {
@@ -18,20 +18,25 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angul
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse } from '../core/models/session.model';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../core/models/stream.model';
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util';
import { persistedStreamToSegments } from '../utils/stream-aggregator.util';
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice };
const SESSION_CACHE_KEY = 'artemis.sessions.v1';
export interface VideoSegment {
url: string;
/** Back-to-back playlist timeline start (seconds); gaps between segments are not represented. */
start: number;
duration: number;
width: number;
height: number;
/** Session-relative first-frame offset in milliseconds (manifest v2). */
offset_ms?: number;
duration_ms?: number;
}
export type RecordingPlaybackStatus =
@@ -202,6 +207,7 @@ export class AgentService {
public pausedError = signal<string | null>(null);
public isRetrying = signal<boolean>(false);
public retryMessage = signal<string | null>(null);
/** Architecture / LLM of the task the status poll reports as running. */
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[]>>({});
@@ -300,6 +306,19 @@ export class AgentService {
return this.sessions().find(s => s.session_id === curId) || null;
});
/**
* Architecture / LLM of the session being viewed.
*
* Each session row carries its own `model_info` (resolved by the backend),
* so a Pro session stays "Pro" while a Flash task runs elsewhere. Only a
* session that has not reached the list yet falls back to the running model.
*/
public viewedModel = computed<ModelInfo | null>(() => {
const current = this.currentSession();
if (current?.model_info) return current.model_info;
return this.activeModel();
});
/**
* Computed whether the currently viewed session is actively running or paused.
* Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
@@ -388,6 +407,11 @@ export class AgentService {
return this.http.get<ProTuningDefaults>('/api/run/defaults');
}
/** Session-wide token totals, live executor context size and the run's tuning. */
public getSessionUsage(sessionId: string): Observable<SessionUsage> {
return this.http.get<SessionUsage>(`/api/sessions/${encodeURIComponent(sessionId)}/usage`);
}
/**
* Run a new task by submitting to backend queue
*/
@@ -585,7 +609,6 @@ export class AgentService {
next: (data) => {
this.rawSessions.set(data);
this.persistSessionsCache(data);
this.updateModelForCurrentView(data);
// On initial load, if nothing is selected, not pinned, not running, and sessions exist, select latest
if (!this.currentSessionId() && !this.userPinnedSessionId() && this.agentStatus() !== 'running' && data.length > 0) {
this.selectSession(data[0].session_id, false);
@@ -720,7 +743,6 @@ export class AgentService {
this.pausedError.set(null);
this.fetchNotes(sessionId);
this.fetchChecks(sessionId);
this.updateModelForCurrentView();
// If video window is currently open, dynamically sync/refresh video for new session
if (this.isVideoWindowOpen()) {
@@ -876,7 +898,6 @@ export class AgentService {
this.beginRecordingFinalization(endedId);
}
}
this.updateModelForCurrentView();
return;
}
@@ -1576,22 +1597,6 @@ export class AgentService {
});
}
/**
* Update active model badge according to currently viewed session
*/
private updateModelForCurrentView(sessionsList?: Session[]): void {
const curId = this.currentSessionId();
const allSessions = sessionsList || this.sessions();
if (curId) {
const selected = allSessions.find(s => s.session_id === curId);
if (selected && selected.model_info) {
this.activeModel.set(selected.model_info);
return;
}
}
}
/**
* Clean up event source connection and timers on service destruction
*/
@@ -1623,10 +1628,12 @@ export class AgentService {
*/
public fetchChecks(sessionId: string): void {
if (!sessionId) return;
this.http.get<{ records?: any[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
this.http.get<{ records?: any[]; streams?: PersistedCheckerStream[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
next: (res) => {
if (this.currentSessionId() !== sessionId) return;
const snapshot = this.buildCheckerSnapshotLogs(sessionId, res?.records || [], res?.run_outcome || null);
const snapshot = this.buildCheckerSnapshotLogs(
sessionId, res?.records || [], res?.run_outcome || null, res?.streams || []
);
this.sessionLogs.update((logs) => [
...logs.filter((log) => !log.checks_snapshot),
...snapshot
@@ -1638,8 +1645,26 @@ export class AgentService {
});
}
private buildCheckerSnapshotLogs(sessionId: string, records: any[], runOutcome: any): any[] {
/**
* `streams` are the attempts' persisted transcripts (what the Checker
* streamed while reaching its verdict): they become the attempt's
* `stream_segments`, the same shape the live `llm_stream` chunks build, so
* a reopened session interleaves Thought/Work text with tool rows exactly
* like the live view did.
*/
private buildCheckerSnapshotLogs(
sessionId: string,
records: any[],
runOutcome: any,
streams: PersistedCheckerStream[] = []
): any[] {
const byAttempt = new Map<string, any>();
const segmentsByAttempt = new Map<string, StreamSegment[]>();
for (const stream of streams || []) {
if (!stream || !stream.attempt_id) continue;
const segments = persistedStreamToSegments(stream);
if (segments.length > 0) segmentsByAttempt.set(String(stream.attempt_id), segments);
}
for (const rec of records) {
if (!rec || !rec.attempt_id) continue;
const attemptId = String(rec.attempt_id);
@@ -1689,6 +1714,8 @@ export class AgentService {
}
const ts = attempt.ts ?? Date.now() / 1000;
attempt.timestamp = ts;
const segments = segmentsByAttempt.get(attempt.attempt_id);
if (segments) attempt.stream_segments = segments;
logs.push({
type: 'checker_event',
session_id: sessionId,
@@ -0,0 +1,107 @@
import {
hasSessionOffsets,
locateSessionTime,
locateTimelineTime,
sessionTimeToTimelineTime,
TimelineSegment
} from './recording-timeline.util';
describe('recording-timeline.util', () => {
// Recording started 1.2s after the session began. Segment 0 ran 10s, then
// scrcpy restarted for rotation (1.5s gap), segment 1 ran 5s, another 0.8s
// gap, and segment 2 ran 4s.
const v2Segments: TimelineSegment[] = [
{ start: 0, duration: 10, offset_ms: 1200, duration_ms: 10000 },
{ start: 10, duration: 5, offset_ms: 12700, duration_ms: 5000 },
{ start: 15, duration: 4, offset_ms: 18500, duration_ms: 4000 }
];
const legacySegments: TimelineSegment[] = [
{ start: 0, duration: 10 },
{ start: 10, duration: 5 }
];
describe('hasSessionOffsets', () => {
it('detects manifest v2 segments and rejects legacy or mixed lists', () => {
expect(hasSessionOffsets(v2Segments)).toBeTrue();
expect(hasSessionOffsets(legacySegments)).toBeFalse();
expect(hasSessionOffsets([v2Segments[0], legacySegments[0]])).toBeFalse();
expect(hasSessionOffsets([])).toBeFalse();
});
});
describe('locateTimelineTime', () => {
it('maps back-to-back playlist times into segments', () => {
expect(locateTimelineTime(legacySegments, 3)).toEqual({ index: 0, localTime: 3, timelineTime: 3 });
expect(locateTimelineTime(legacySegments, 10)).toEqual({ index: 1, localTime: 0, timelineTime: 10 });
expect(locateTimelineTime(legacySegments, 12.5)).toEqual({ index: 1, localTime: 2.5, timelineTime: 12.5 });
});
it('clamps out-of-range times into the first or last segment', () => {
expect(locateTimelineTime(legacySegments, -4)).toEqual({ index: 0, localTime: 0, timelineTime: 0 });
expect(locateTimelineTime(legacySegments, 99)).toEqual({ index: 1, localTime: 5, timelineTime: 15 });
expect(locateTimelineTime([], 3)).toBeNull();
});
});
describe('locateSessionTime', () => {
it('maps a session time inside a segment using its real offset', () => {
// T+4.2s is 3.0s into segment 0 (which started at T+1.2s).
const inFirst = locateSessionTime(v2Segments, 4.2)!;
expect(inFirst.index).toBe(0);
expect(inFirst.localTime).toBeCloseTo(3.0, 6);
expect(inFirst.timelineTime).toBeCloseTo(3.0, 6);
// T+14.7s is 2.0s into segment 1 (started at T+12.7s), which sits at
// timeline 10s, so it must NOT drift to timeline 14.7s.
const inSecond = locateSessionTime(v2Segments, 14.7)!;
expect(inSecond.index).toBe(1);
expect(inSecond.localTime).toBeCloseTo(2.0, 6);
expect(inSecond.timelineTime).toBeCloseTo(12.0, 6);
const inThird = locateSessionTime(v2Segments, 20.5)!;
expect(inThird.index).toBe(2);
expect(inThird.localTime).toBeCloseTo(2.0, 6);
expect(inThird.timelineTime).toBeCloseTo(17.0, 6);
});
it('snaps a time inside a restart gap to the start of the next segment', () => {
// Segment 0 ends at T+11.2s, segment 1 starts at T+12.7s.
expect(locateSessionTime(v2Segments, 11.9)).toEqual({ index: 1, localTime: 0, timelineTime: 10 });
// Segment 1 ends at T+17.7s, segment 2 starts at T+18.5s.
expect(locateSessionTime(v2Segments, 18.0)).toEqual({ index: 2, localTime: 0, timelineTime: 15 });
});
it('treats segment boundaries as inclusive of the end frame', () => {
const atEnd = locateSessionTime(v2Segments, 11.2)!;
expect(atEnd.index).toBe(0);
expect(atEnd.localTime).toBeCloseTo(10, 6);
expect(locateSessionTime(v2Segments, 12.7)).toEqual({ index: 1, localTime: 0, timelineTime: 10 });
});
it('snaps times before the first frame and after the last frame', () => {
expect(locateSessionTime(v2Segments, 0)).toEqual({ index: 0, localTime: 0, timelineTime: 0 });
expect(locateSessionTime(v2Segments, 0.5)).toEqual({ index: 0, localTime: 0, timelineTime: 0 });
expect(locateSessionTime(v2Segments, 60)).toEqual({ index: 2, localTime: 4, timelineTime: 19 });
expect(locateSessionTime(v2Segments, Number.NaN)).toEqual({ index: 0, localTime: 0, timelineTime: 0 });
});
it('falls back to back-to-back timeline behaviour for legacy manifests', () => {
expect(locateSessionTime(legacySegments, 12.5)).toEqual({ index: 1, localTime: 2.5, timelineTime: 12.5 });
expect(locateSessionTime([], 5)).toBeNull();
});
});
describe('sessionTimeToTimelineTime', () => {
it('returns the playlist position for a session time', () => {
expect(sessionTimeToTimelineTime(v2Segments, 14.7)).toBeCloseTo(12.0, 6);
expect(sessionTimeToTimelineTime(v2Segments, 11.9)).toBe(10);
expect(sessionTimeToTimelineTime(legacySegments, 7)).toBe(7);
});
it('passes the time through when no playlist is available', () => {
expect(sessionTimeToTimelineTime([], 7)).toBe(7);
expect(sessionTimeToTimelineTime([], -2)).toBe(0);
});
});
});
@@ -0,0 +1,123 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Pure helpers that map times onto the multi-segment screen recording.
*
* A recording is split into segments whenever scrcpy restarts (device
* rotation, crash recovery). The player plays them back to back on a
* "timeline" axis (`start`/`duration`, no gaps), while step timestamps live
* on the "session" axis (seconds since the DataEngine session start). Manifest
* v2 segments carry `offset_ms`/`duration_ms` on the session axis, which lets
* a session time land in the right segment even after each restart gap.
*/
export interface TimelineSegment {
/** Back-to-back playlist start in seconds. */
start: number;
/** Segment length in seconds. */
duration: number;
/** Session-relative first-frame offset in milliseconds (manifest v2). */
offset_ms?: number;
/** Segment length in milliseconds (manifest v2). */
duration_ms?: number;
}
export interface SegmentLocation {
/** Index into the segment list. */
index: number;
/** Seconds into that segment's own media. */
localTime: number;
/** Equivalent position on the back-to-back playlist axis, in seconds. */
timelineTime: number;
}
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
/** True when every segment carries the session-relative v2 fields. */
export function hasSessionOffsets(segments: readonly TimelineSegment[]): boolean {
return segments.length > 0 && segments.every(
(segment) => Number.isFinite(segment.offset_ms) && Number.isFinite(segment.duration_ms)
);
}
/**
* Locate a position on the back-to-back playlist axis (what the scrubber uses).
* Times past the end clamp into the last segment.
*/
export function locateTimelineTime(
segments: readonly TimelineSegment[],
timelineSeconds: number
): SegmentLocation | null {
if (!segments.length) return null;
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.duration), 0);
const target = clamp(Number.isFinite(timelineSeconds) ? timelineSeconds : 0, 0, total);
let index = segments.findIndex((segment) => target < segment.start + segment.duration);
if (index < 0) index = segments.length - 1;
const segment = segments[index];
const localTime = clamp(target - segment.start, 0, Math.max(0, segment.duration));
return { index, localTime, timelineTime: segment.start + localTime };
}
/**
* Locate a session-relative time (seconds since session start, the axis step
* timestamps use). A time inside `[offset, offset + duration]` of a segment maps
* into that segment; a time in the gap between two segments (scrcpy restart)
* snaps to the start of the next segment; a time before the first frame snaps
* to the first frame; a time after the last frame clamps to the last frame.
*
* Segments without session offsets (legacy manifests) are treated as if the
* session axis were the back-to-back timeline axis.
*/
export function locateSessionTime(
segments: readonly TimelineSegment[],
sessionSeconds: number
): SegmentLocation | null {
if (!segments.length) return null;
if (!hasSessionOffsets(segments)) {
return locateTimelineTime(segments, sessionSeconds);
}
const targetMs = Number.isFinite(sessionSeconds) ? sessionSeconds * 1000 : 0;
for (let index = 0; index < segments.length; index++) {
const segment = segments[index];
const offsetMs = segment.offset_ms as number;
const endMs = offsetMs + (segment.duration_ms as number);
if (targetMs < offsetMs) {
// Before this segment's first frame: either before the recording began
// or inside the restart gap after the previous segment. Snap forward.
return { index, localTime: 0, timelineTime: segment.start };
}
if (targetMs <= endMs) {
const localTime = clamp((targetMs - offsetMs) / 1000, 0, Math.max(0, segment.duration));
return { index, localTime, timelineTime: segment.start + localTime };
}
}
const lastIndex = segments.length - 1;
const last = segments[lastIndex];
const localTime = Math.max(0, last.duration);
return { index: lastIndex, localTime, timelineTime: last.start + localTime };
}
/**
* Convert a session-relative time into the back-to-back playlist time the
* player scrubber understands. Returns 0 when there are no segments.
*/
export function sessionTimeToTimelineTime(
segments: readonly TimelineSegment[],
sessionSeconds: number
): number {
return locateSessionTime(segments, sessionSeconds)?.timelineTime ?? Math.max(0, sessionSeconds || 0);
}
@@ -0,0 +1,92 @@
import {
contextPercent,
formatCompactTokens,
formatElapsed,
parseRunTuning,
sessionElapsedSeconds,
tuningLabel
} from './run-info.util';
describe('run-info.util', () => {
describe('formatElapsed', () => {
it('formats seconds, minutes and hours with zero padding', () => {
expect(formatElapsed(0)).toBe('0s');
expect(formatElapsed(42)).toBe('42s');
expect(formatElapsed(65)).toBe('1m 05s');
expect(formatElapsed(3725)).toBe('1h 02m 05s');
});
it('treats invalid or negative input as zero', () => {
expect(formatElapsed(-5)).toBe('0s');
expect(formatElapsed(undefined)).toBe('0s');
expect(formatElapsed(Number.NaN)).toBe('0s');
});
});
describe('formatCompactTokens', () => {
it('uses plain, k and M units', () => {
expect(formatCompactTokens(842)).toBe('842');
expect(formatCompactTokens(76_400)).toBe('76.4k');
expect(formatCompactTokens(1_000)).toBe('1k');
expect(formatCompactTokens(1_250_000)).toBe('1.25M');
expect(formatCompactTokens(null)).toBe('0');
});
});
describe('contextPercent', () => {
it('reports one-decimal share of the window and clamps at 100', () => {
expect(contextPercent(150_000, 1_000_000)).toBe(15);
expect(contextPercent(123_456, 1_000_000)).toBe(12.3);
expect(contextPercent(2_000_000, 1_000_000)).toBe(100);
});
it('falls back to the 1M window and is null when usage is unknown', () => {
expect(contextPercent(250_000, undefined)).toBe(25);
expect(contextPercent(null, 1_000_000)).toBeNull();
expect(contextPercent(undefined, 1_000_000)).toBeNull();
});
});
describe('sessionElapsedSeconds', () => {
it('counts to now while running and to end_time once finished', () => {
const session = { start_time: 1_000, end_time: 1_090 };
expect(sessionElapsedSeconds(session, true, 1_200_000)).toBe(200);
expect(sessionElapsedSeconds(session, false, 1_200_000)).toBe(90);
});
it('is zero without a start time or session', () => {
expect(sessionElapsedSeconds(null, true, 5_000)).toBe(0);
expect(sessionElapsedSeconds({ start_time: 0 }, true, 5_000)).toBe(0);
});
});
describe('tuningLabel', () => {
it('maps ids to the launcher labels and defaults unknown ids', () => {
expect(tuningLabel('verify', 'checkpoints')).toBe('Every step');
expect(tuningLabel('verify', 'nonsense')).toBe('At the end');
expect(tuningLabel('explore', 'ultra')).toBe('Close-up');
expect(tuningLabel('explore', null)).toBe('Quick glance');
});
});
describe('parseRunTuning', () => {
it('reads run_tuning from a JSON string or an object', () => {
const asString = JSON.stringify({
profile: 'pro',
run_tuning: { verification_level: 'strict', explorer_mode: 'pro' }
});
expect(parseRunTuning(asString)).toEqual({ verification_level: 'strict', explorer_mode: 'pro' });
expect(parseRunTuning({ run_tuning: { verification_level: 'off' } })).toEqual({
verification_level: 'off',
explorer_mode: null
});
});
it('is null for flash sessions, malformed JSON and empty tuning', () => {
expect(parseRunTuning(JSON.stringify({ profile: 'flash' }))).toBeNull();
expect(parseRunTuning('{not json')).toBeNull();
expect(parseRunTuning({ run_tuning: {} })).toBeNull();
expect(parseRunTuning(null)).toBeNull();
});
});
});
@@ -0,0 +1,116 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Pure helpers behind the run info popover (the Flash / Pro chip in the
* task stream): elapsed time, compact token counts, context-window share
* and the plain-language labels of the Pro tuning a run was started with.
*/
import {
EXPLORER_MODES,
VERIFICATION_LEVELS,
levelIndex,
DEFAULT_EXPLORER_MODE,
DEFAULT_VERIFICATION_LEVEL
} from '../core/models/pro-tuning.model';
import { SessionRunTuning } from '../core/models/session.model';
/** Denominator for "context used" when the backend does not report one. */
export const OPERATOR_CONTEXT_WINDOW_TOKENS = 1_000_000;
/** `42s`, `12m 05s`, `1h 02m 05s`. Negative or invalid input reads as `0s`. */
export function formatElapsed(totalSeconds: number | null | undefined): string {
const secs = Math.max(0, Math.floor(Number(totalSeconds) || 0));
const hours = Math.floor(secs / 3600);
const minutes = Math.floor((secs % 3600) / 60);
const seconds = secs % 60;
const pad = (n: number) => String(n).padStart(2, '0');
if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`;
if (minutes > 0) return `${minutes}m ${pad(seconds)}s`;
return `${seconds}s`;
}
/** `842`, `76.4k`, `1.2M` (no unit suffix; the caller labels the row). */
export function formatCompactTokens(tokens: number | null | undefined): string {
const n = Math.max(0, Math.floor(Number(tokens) || 0));
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
return `${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`;
}
/** Share of the context window in percent (one decimal), or null when unknown. */
export function contextPercent(
usedTokens: number | null | undefined,
windowTokens: number | null | undefined
): number | null {
const used = Number(usedTokens);
const window = Number(windowTokens) || OPERATOR_CONTEXT_WINDOW_TOKENS;
if (!Number.isFinite(used) || used < 0 || usedTokens === null || usedTokens === undefined) {
return null;
}
const pct = (used / window) * 100;
return Math.min(100, Math.round(pct * 10) / 10);
}
/**
* Seconds a session has been (or was) running.
*
* Live sessions count from `start_time` to `nowMs`; finished sessions use the
* recorded `end_time`. A session with no `start_time` reads as 0.
*/
export function sessionElapsedSeconds(
session: { start_time?: number; end_time?: number | null } | null | undefined,
isRunning: boolean,
nowMs: number
): number {
if (!session?.start_time) return 0;
const startMs = session.start_time * 1000;
const endMs = !isRunning && session.end_time ? session.end_time * 1000 : nowMs;
return Math.max(0, Math.round((endMs - startMs) / 1000));
}
/** Plain-language label for a tuning id, falling back to the ladder default. */
export function tuningLabel(kind: 'verify' | 'explore', id: string | null | undefined): string {
if (kind === 'verify') {
return VERIFICATION_LEVELS[levelIndex(VERIFICATION_LEVELS, id, DEFAULT_VERIFICATION_LEVEL)].label;
}
return EXPLORER_MODES[levelIndex(EXPLORER_MODES, id, DEFAULT_EXPLORER_MODE)].label;
}
/**
* Reads `run_tuning` out of a session's `device_info` (which the sessions API
* returns either as a JSON string or an already-parsed object).
*/
export function parseRunTuning(deviceInfo: unknown): SessionRunTuning | null {
let info: unknown = deviceInfo;
if (typeof info === 'string') {
try {
info = JSON.parse(info);
} catch {
return null;
}
}
if (!info || typeof info !== 'object') return null;
const tuning = (info as { run_tuning?: unknown }).run_tuning;
if (!tuning || typeof tuning !== 'object') return null;
const t = tuning as SessionRunTuning;
if (!t.verification_level && !t.explorer_mode) return null;
return {
verification_level: t.verification_level ?? null,
explorer_mode: t.explorer_mode ?? null
};
}
@@ -1,4 +1,4 @@
import { consolidateLogsToBlocks, getSortedStepEvents } from './stream-aggregator.util';
import { consolidateLogsToBlocks, getSortedStepEvents, persistedStreamToSegments } from './stream-aggregator.util';
describe('stream aggregator timeline ordering', () => {
it('interleaves text, tools, and actions by timestamp', () => {
@@ -203,7 +203,7 @@ describe('stream aggregator checker lane', () => {
parent_trace_id: 'trace-checker-1',
agent_name: 'checker',
type: 'tool',
name: 'get_step_detail',
name: 'replay_steps',
status: 'success',
timestamp: '2026-08-25T20:00:07.000Z'
}
@@ -223,6 +223,114 @@ describe('stream aggregator checker lane', () => {
expect(events[2].data.text).toBe('The alarm is there.');
});
it('rebuilds the persisted transcript into the same interleaved segments as the live stream', () => {
const transcript = {
attempt_id: 'abc#1',
checkpoint_id: 'abc',
phase: 'checkpoint',
trace_id: 'trace-checker-1',
ts: 1787688009,
truncated: false,
segments: [
// 1787688006 = 2026-08-25T20:00:06Z
{ execution_id: 'exec-1', role: 'thought', when: 1787688006, text: 'Inspecting the alarm list' },
{ execution_id: 'exec-1', role: 'answer', when: 1787688006.5, text: 'Let me look at step 2.' },
{ execution_id: 'exec-2', role: 'answer', when: 1787688008, text: 'The alarm is there.' },
{ execution_id: 'exec-3', role: 'answer', when: 1787688008.5, text: '' }
]
};
const segments = persistedStreamToSegments(transcript);
expect(segments).toEqual([
{ execution_id: 'exec-1', stream_type: 'thinking', text: 'Inspecting the alarm list', timestamp: '2026-08-25T20:00:06.000Z', isCompleted: true },
{ execution_id: 'exec-1', stream_type: 'text', text: 'Let me look at step 2.', timestamp: '2026-08-25T20:00:06.500Z', isCompleted: true },
{ execution_id: 'exec-2', stream_type: 'text', text: 'The alarm is there.', timestamp: '2026-08-25T20:00:08.000Z', isCompleted: true }
]);
expect(persistedStreamToSegments(null)).toEqual([]);
// Historical session: only the ledger backfill (attempt_finished carrying
// the rebuilt segments) and the relocated tool trace exist.
const tool = {
type: 'trace_recorded',
timestamp: '2026-08-25T20:00:07.000Z',
data: {
trace_id: 'tool-detail',
parent_trace_id: 'trace-checker-1',
agent_name: 'checker',
type: 'tool',
name: 'replay_steps',
status: 'success',
timestamp: '2026-08-25T20:00:07.000Z'
}
};
const historical = consolidateLogsToBlocks([
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
checks_snapshot: true,
data: {
event: 'attempt_finished',
phase: 'checkpoint',
attempt_id: 'abc#1',
checkpoint_id: 'abc',
trace_id: 'trace-checker-1',
status: 'done',
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
findings: [],
ts: 1756152009,
stream_segments: segments
}
},
tool
]);
const block = historical.find((b) => b.id === 'checker-abc#1')!;
expect(block.data.stream_segments).toEqual(segments);
expect(block.data.operator_native_thinking).toBe('Inspecting the alarm list');
expect(block.data.operator_raw_thinking).toBe('Let me look at step 2.\n\nThe alarm is there.');
expect(block.data.operator_native_thinking_timestamp).toBe('2026-08-25T20:00:06.000Z');
expect(block.data.operator_raw_thinking_timestamp).toBe('2026-08-25T20:00:06.500Z');
const events = getSortedStepEvents(block.data);
expect(events.map((e) => e.type)).toEqual(['thinking', 'text', 'tool', 'text']);
expect(events.map((e) => e.data.text ?? e.data.name)).toEqual([
'Inspecting the alarm list',
'Let me look at step 2.',
'replay_steps',
'The alarm is there.'
]);
// Live session: the same turns arrived as llm_stream chunks; a later
// ledger backfill must neither duplicate nor reorder them.
const chunk = (execId: string, streamType: string, timestamp: string, text: string) => ({
type: 'llm_stream',
timestamp,
data: { execution_id: execId, parent_trace_id: 'trace-checker-1', stream_type: streamType, text, isCompleted: true }
});
const live = consolidateLogsToBlocks([
started,
chunk('exec-1', 'thinking', '2026-08-25T20:00:06.000Z', 'Inspecting the alarm list'),
chunk('exec-1', 'text', '2026-08-25T20:00:06.500Z', 'Let me look at step 2.'),
tool,
chunk('exec-2', 'text', '2026-08-25T20:00:08.000Z', 'The alarm is there.'),
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
data: { event: 'attempt_finished', attempt_id: 'abc#1', status: 'done', verdicts: [], findings: [], ts: 1756152009 }
},
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
checks_snapshot: true,
data: { event: 'attempt_finished', attempt_id: 'abc#1', status: 'done', ts: 1756152009, stream_segments: segments }
}
]);
const liveBlock = live.find((b) => b.id === 'checker-abc#1')!;
expect(liveBlock.data.stream_segments.map((s: any) => [s.execution_id, s.stream_type, s.text])).toEqual(
segments.map((s) => [s.execution_id, s.stream_type, s.text])
);
expect(getSortedStepEvents(liveBlock.data).map((e) => e.data.text ?? e.data.name)).toEqual(
events.map((e) => e.data.text ?? e.data.name)
);
});
it('builds the run outcome block once and keeps it idempotent', () => {
const outcome = {
type: 'checker_event',
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { StepBlock, PhaseBlock, StepEvent, StreamSegment, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../core/models/stream.model';
import { StepBlock, PhaseBlock, StepEvent, StreamSegment, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream } from '../core/models/stream.model';
import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue, getActionObject } from './action-formatter.util';
import { getUniqueGenericTools, isInternalPlumbingTool } from './tool-formatter.util';
@@ -52,6 +52,76 @@ function findRunningCheckerBlock(blocks: StepBlock[]): number {
return -1;
}
/**
* Rebuild the live `StreamSegment` list of a Checker attempt from its
* persisted transcript, so a reopened session renders the same interleaved
* Thought/Work segments as the live stream did.
*/
export function persistedStreamToSegments(record: PersistedCheckerStream | null | undefined): StreamSegment[] {
if (!record || !Array.isArray(record.segments)) return [];
const segments: StreamSegment[] = [];
for (const seg of record.segments) {
if (!seg || typeof seg.text !== 'string' || !seg.text) continue;
const when = typeof seg.when === 'number' && seg.when > 0 ? seg.when : (record.ts || 0);
segments.push({
execution_id: String(seg.execution_id || ''),
stream_type: seg.role === 'thought' ? 'thinking' : 'text',
text: seg.text,
timestamp: new Date(when * 1000).toISOString(),
isCompleted: true
});
}
return segments;
}
function segmentKey(segment: StreamSegment): string {
return `${segment.execution_id}|${segment.stream_type}`;
}
/**
* Merge persisted segments into the ones a block already holds. Live chunks
* (complete by the time the transcript exists) win over the backfill copy of
* the same turn; turns only the transcript knows are added, then everything
* is kept in first-chunk order.
*/
function mergeStreamSegments(existing: any, incoming: any): StreamSegment[] | undefined {
const base: StreamSegment[] = Array.isArray(existing) ? [...existing] : [];
const extra: StreamSegment[] = Array.isArray(incoming) ? incoming : [];
if (extra.length === 0) return base.length > 0 ? base : undefined;
const seen = new Set(base.map(segmentKey));
let added = false;
for (const segment of extra) {
const key = segmentKey(segment);
if (seen.has(key)) continue;
seen.add(key);
base.push(segment);
added = true;
}
if (added) {
base.sort((a, b) => getItemTimestamp(a.timestamp, 0) - getItemTimestamp(b.timestamp, 0));
}
return base;
}
/**
* Keep the flat `operator_*_thinking` fields (joined text + first-seen time)
* in step with the segment list for consumers that only know steps.
*/
function applyFlatStreamFields(data: any, segments: StreamSegment[]): void {
const of = (type: string) => segments.filter((s) => s.stream_type === type && s.text.trim());
const joined = (type: string) => of(type).map((s) => s.text).join('\n\n');
data.operator_native_thinking = joined('thinking');
data.operator_raw_thinking = joined('text');
const firstThinking = of('thinking')[0];
const firstText = of('text')[0];
if (firstThinking && !data.operator_native_thinking_timestamp) {
data.operator_native_thinking_timestamp = firstThinking.timestamp;
}
if (firstText && !data.operator_raw_thinking_timestamp) {
data.operator_raw_thinking_timestamp = firstText.timestamp;
}
}
/**
* Apply one `checker_event` (attempt_started / attempt_finished / run_outcome)
* to the block list. Attempts are keyed by attempt id so live events and the
@@ -106,7 +176,10 @@ function applyCheckerLog(blocks: StepBlock[], log: any): void {
const items = existing?.data?.items?.length
? existing.data.items
: verdicts.map((v: any) => ({ kind: v.kind, text: v.item_text, when: v.when }));
const data = {
// The ledger backfill carries the attempt's persisted transcript as
// ready-made segments; a live block keeps the ones it streamed.
const streamSegments = mergeStreamSegments(existing?.data?.stream_segments, d.stream_segments);
const data: any = {
...(existing?.data || {}),
...d,
status: d.status || 'done',
@@ -114,11 +187,13 @@ function applyCheckerLog(blocks: StepBlock[], log: any): void {
items,
findings: Array.isArray(d.findings) ? d.findings : (existing?.data?.findings || []),
generic_tools: existing?.data?.generic_tools || [],
stream_segments: streamSegments,
started_at: startedAt,
finished_at: ts,
duration: startedAt !== undefined && ts !== undefined ? Math.max(0, ts - startedAt) : undefined,
isCompleted: true
};
if (streamSegments) applyFlatStreamFields(data, streamSegments);
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
if (existing) blocks[idx] = block; else blocks.push(block);
}
@@ -224,12 +299,6 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment);
checkerData.stream_segments = segments;
// Flat fields stay as the joined text for consumers that only know steps.
const joined = (type: string) => segments
.filter((s) => s.stream_type === type && s.text.trim())
.map((s) => s.text)
.join('\n\n');
checkerData.operator_native_thinking = joined('thinking');
checkerData.operator_raw_thinking = joined('text');
if (streamType === 'thinking') {
checkerData.operator_native_thinking_timestamp =
checkerData.operator_native_thinking_timestamp || log.timestamp;
@@ -237,6 +306,7 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
checkerData.operator_raw_thinking_timestamp =
checkerData.operator_raw_thinking_timestamp || log.timestamp;
}
applyFlatStreamFields(checkerData, segments);
if (execId && !checkerData.execution_id) {
checkerData.execution_id = execId;
}
@@ -662,24 +662,34 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
const q = args.specific_query || args.query || '';
return q ? `Analyzing audio track: "${q}"` : 'Analyzing recording audio track';
}
case 'get_step_detail':
case 'get_step_details': {
const n = args.step_no ?? args.step_number ?? args.step;
case 'search_history': {
const q = args.query || '';
const range = Array.isArray(args.step_range) && args.step_range.length
? ` in steps ${args.step_range[0]}–${args.step_range[args.step_range.length - 1]}`
: '';
return q ? `Searching execution history for "${q}"${range}` : `Searching execution history${range}`;
}
case 'replay_steps': {
const n = args.start_step;
const end = args.end_step;
if (n !== undefined && n !== '' && end !== undefined && end !== null && end !== '' && String(end) !== String(n)) {
return `Reviewing steps ${n}–${end}`;
}
return n !== undefined && n !== '' ? `Reviewing step ${n}` : 'Reviewing step details';
}
case 'get_step_screenshot': {
const n = args.step_no ?? args.step_number ?? args.step;
const which = String(args.which || '').toLowerCase() === 'post' ? 'after' : 'before';
const n = args.step_number;
const variant = String(args.which || '').toLowerCase();
if (variant === 'overlay') {
return n !== undefined && n !== '' ? `Looking at where step ${n}'s action landed` : 'Looking at where an action landed';
}
const which = variant === 'post' ? 'after' : 'before';
return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : 'Looking at a step screenshot';
}
case 'probe_device': {
const kind = args.kind ? String(args.kind).replace(/_/g, ' ') : '';
return kind ? `Reading ${kind} from the device` : 'Reading device state';
}
case 'search_history_for_text': {
const q = args.text || args.query || '';
return q ? `Searching execution trace for "${q}"` : 'Searching execution trace';
}
case 'outputter':
case 'output_synthesis':
return 'Synthesizing output report';
@@ -751,15 +761,14 @@ export function getToolIcon(tool: any): string {
return 'smart_toy';
case 'analyze_audio_only':
return 'graphic_eq';
case 'get_step_detail':
case 'get_step_details':
case 'search_history':
return 'find_in_page';
case 'replay_steps':
return 'manage_search';
case 'get_step_screenshot':
return 'image_search';
case 'probe_device':
return 'sensors';
case 'search_history_for_text':
return 'find_in_page';
case 'outputter':
case 'output_synthesis':
return 'assignment_turned_in';