feat: unify history tools and improve execution reliability
This commit is contained in:
1 parent
d23a4f6472
commit
2fea9865ee
116 files changed
+8291
-2503
No files matched your search
Generated
+1
-1
@@ -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' {
|
||||
|
||||
+18
-6
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user