Host Agent now persists step-level execution detail locally (via a real TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded in-progress snapshot piggybacked on lease renewal. Cloud persists that snapshot per active assignment and exposes it through the existing task list/detail query path; Cloud Console renders it as a live badge. Host Agent's local console gains authenticated, read-only task list and detail/timeline pages (same-origin, server-rendered) with inlined screenshots. Also fixes a pre-existing gap in the shared Timeline: the actual per-step LLM prompt is now recorded instead of the task goal, benefiting both Runtime and Host Agent consoles. When a host uses the cloud planner transport, each decide call's prompt and resulting tool decision are durably logged in a new planner_decision_log table (with bounded retention) and browsable from Cloud Console; direct-transport hosts explicitly surface a "not reported" state. Includes Alembic migrations 0008 (progress columns on scheduled_tasks) and 0009 (planner_decision_log), bounded Host-Agent-local retention, dual-backend repository parity, and Vitest + pytest coverage. Task 6.5 (manual end-to-end device verification) remains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { formatTaskProgress } from "./taskProgress";
|
|
import type { TaskListItem } from "./types";
|
|
|
|
function makeTask(
|
|
overrides: Partial<TaskListItem> & Pick<TaskListItem, "status">,
|
|
): TaskListItem {
|
|
return {
|
|
id: "task-a",
|
|
goal: null,
|
|
workflow_definition_id: null,
|
|
assigned_device_id: null,
|
|
assigned_host_id: null,
|
|
attempt_count: 0,
|
|
failure_reason: null,
|
|
target_host_id: null,
|
|
target_device_id: null,
|
|
created_at: "2026-07-14T00:00:00+00:00",
|
|
progress_step_index: null,
|
|
progress_step_status: null,
|
|
progress_summary: null,
|
|
progress_updated_at: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("formatTaskProgress", () => {
|
|
it("renders step index, status, and summary for an in-progress dispatched task", () => {
|
|
const task = makeTask({
|
|
status: "dispatched",
|
|
progress_step_index: 2,
|
|
progress_step_status: "running",
|
|
progress_summary: "tapping button",
|
|
});
|
|
expect(formatTaskProgress(task)).toBe("step 2 \u2014 running: tapping button");
|
|
});
|
|
|
|
it("renders step index and status when summary is missing", () => {
|
|
const task = makeTask({
|
|
status: "assigned",
|
|
progress_step_index: 0,
|
|
progress_step_status: "starting",
|
|
progress_summary: null,
|
|
});
|
|
expect(formatTaskProgress(task)).toBe("step 0 \u2014 starting");
|
|
});
|
|
|
|
it("returns null when the task is dispatched but no progress has been reported", () => {
|
|
const task = makeTask({
|
|
status: "dispatched",
|
|
progress_step_index: null,
|
|
progress_step_status: null,
|
|
progress_summary: null,
|
|
});
|
|
expect(formatTaskProgress(task)).toBeNull();
|
|
});
|
|
|
|
it("returns null for a dispatched task with undefined progress fields", () => {
|
|
const task = makeTask({ status: "dispatched" });
|
|
// Fields default to null via makeTask; simulate the undefined case.
|
|
delete (task as Partial<TaskListItem>).progress_step_index;
|
|
delete (task as Partial<TaskListItem>).progress_step_status;
|
|
expect(formatTaskProgress(task)).toBeNull();
|
|
});
|
|
|
|
it("hides stale progress when the task has reached a terminal status (done)", () => {
|
|
const task = makeTask({
|
|
status: "done",
|
|
progress_step_index: 5,
|
|
progress_step_status: "running",
|
|
progress_summary: "stale snapshot before terminal",
|
|
});
|
|
expect(formatTaskProgress(task)).toBeNull();
|
|
});
|
|
|
|
it("hides stale progress when the task has reached a terminal status (failed)", () => {
|
|
const task = makeTask({
|
|
status: "failed",
|
|
progress_step_index: 3,
|
|
progress_step_status: "running",
|
|
progress_summary: "stale snapshot before failure",
|
|
});
|
|
expect(formatTaskProgress(task)).toBeNull();
|
|
});
|
|
|
|
it("hides progress for a queued task that has not started executing", () => {
|
|
const task = makeTask({
|
|
status: "queued",
|
|
progress_step_index: 1,
|
|
progress_step_status: "running",
|
|
progress_summary: "should not show while queued",
|
|
});
|
|
expect(formatTaskProgress(task)).toBeNull();
|
|
});
|
|
|
|
it("ignores a missing step status even when step index is present", () => {
|
|
const task = makeTask({
|
|
status: "dispatched",
|
|
progress_step_index: 1,
|
|
progress_step_status: null,
|
|
progress_summary: "partial snapshot",
|
|
});
|
|
expect(formatTaskProgress(task)).toBeNull();
|
|
});
|
|
|
|
it("truncates a long summary with an ellipsis", () => {
|
|
const longSummary = "x".repeat(200);
|
|
const task = makeTask({
|
|
status: "dispatched",
|
|
progress_step_index: 1,
|
|
progress_step_status: "running",
|
|
progress_summary: longSummary,
|
|
});
|
|
const formatted = formatTaskProgress(task);
|
|
expect(formatted).not.toBeNull();
|
|
const summaryPart = formatted!.split(": ").slice(1).join(": ");
|
|
expect(summaryPart.length).toBeLessThan(longSummary.length);
|
|
expect(summaryPart.endsWith("\u2026")).toBe(true);
|
|
});
|
|
|
|
it("trims surrounding whitespace before truncating or rendering", () => {
|
|
const task = makeTask({
|
|
status: "dispatched",
|
|
progress_step_index: 1,
|
|
progress_step_status: "running",
|
|
progress_summary: " hello world ",
|
|
});
|
|
expect(formatTaskProgress(task)).toBe("step 1 \u2014 running: hello world");
|
|
});
|
|
});
|