91 lines
3.2 KiB
JavaScript
91 lines
3.2 KiB
JavaScript
// Task progress state helpers. Kept DOM-free so Node tests can cover the
|
|
// "update_step without title" merge behavior used by the Web UI.
|
|
|
|
export function cloneProgressSteps(steps) {
|
|
return Array.isArray(steps) ? steps.map(s => ({ ...s })) : [];
|
|
}
|
|
|
|
export function normalizeProgressStatus(status) {
|
|
return ["pending", "in_progress", "completed"].includes(status) ? status : "pending";
|
|
}
|
|
|
|
export function normalizeProgressStep(step) {
|
|
if (!step || typeof step !== "object") return null;
|
|
const id = String(step.id || "").trim();
|
|
const title = String(step.title || "").trim();
|
|
const status = normalizeProgressStatus(step.status);
|
|
if (!id || !title) return null;
|
|
return { id, title, status };
|
|
}
|
|
|
|
// The checklist is a linear progress bar: work advances top-to-bottom. Models
|
|
// don't always send a `completed` update before moving the in_progress marker
|
|
// on (observed: later steps marked done while earlier ones dangle at
|
|
// in_progress), which renders as "green check below, red dot above". Enforce
|
|
// monotonic completion — any step before the last completed one is completed
|
|
// too — so a missed update self-heals instead of stranding earlier steps.
|
|
export function enforceMonotonicProgress(steps) {
|
|
if (!Array.isArray(steps)) return [];
|
|
let lastCompleted = -1;
|
|
for (let i = 0; i < steps.length; i++) {
|
|
if (steps[i] && steps[i].status === "completed") lastCompleted = i;
|
|
}
|
|
if (lastCompleted <= 0) return steps.map(s => ({ ...s }));
|
|
return steps.map((s, i) => (i < lastCompleted ? { ...s, status: "completed" } : { ...s }));
|
|
}
|
|
|
|
export function applyProgressAction(progress, args) {
|
|
const current = cloneProgressSteps(progress);
|
|
if (!args || typeof args !== "object") return current;
|
|
const action = args.action || "";
|
|
if (action === "clear") return [];
|
|
if (action === "set_plan") {
|
|
const planned = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
|
|
return enforceMonotonicProgress(planned);
|
|
}
|
|
if (action === "update_step") {
|
|
const raw = args.step;
|
|
if (!raw || typeof raw !== "object") return current;
|
|
const id = String(raw.id || "").trim();
|
|
if (!id) return current;
|
|
let found = false;
|
|
const next = current.map((s) => {
|
|
if (s.id !== id) return s;
|
|
found = true;
|
|
return {
|
|
id: s.id,
|
|
title: String(raw.title || s.title || "").trim(),
|
|
status: normalizeProgressStatus(raw.status || s.status),
|
|
};
|
|
}).filter(s => s.title);
|
|
if (!found && raw.title) {
|
|
next.push({
|
|
id,
|
|
title: String(raw.title).trim(),
|
|
status: normalizeProgressStatus(raw.status),
|
|
});
|
|
}
|
|
return enforceMonotonicProgress(next);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function progressActionsFromToolCalls(toolCalls, baseSteps = []) {
|
|
let steps = cloneProgressSteps(baseSteps);
|
|
let sawProgress = false;
|
|
if (!Array.isArray(toolCalls)) return { steps, sawProgress };
|
|
for (const tc of toolCalls) {
|
|
const fn = (tc && tc.function && tc.function.name) || "";
|
|
if (fn !== "task_progress") continue;
|
|
let args = {};
|
|
try {
|
|
args = JSON.parse((tc.function && tc.function.arguments) || "{}");
|
|
} catch (e) {
|
|
args = {};
|
|
}
|
|
steps = applyProgressAction(steps, args);
|
|
sawProgress = true;
|
|
}
|
|
return { steps, sawProgress };
|
|
}
|