openvibers/openvibe.actor Commits
Actor v2 stage V2-5: the task page shows the plan, the evidence behind each claim, the files and the check (#194)
Changes
nnew file mode 100644@@@ -0,0 +1,69 @@++/* A task's own page (Actor v2, stage V2-5), loaded by that page only so the home stays light: the plan, the evidence++ chips and what they open, the check line, the files. The rest of the page is actor.css. */++.plan-card { margin: 0 0 1.4rem; padding: 14px 18px 12px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++.plan-card[hidden] { display: none; }++.how .plan-card { margin: .2rem 0 1rem; }++.plan-card > h2 { margin: 0 0 .5rem; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.plan { list-style: none; margin: 0; padding: 0; display: grid; }++.ps { position: relative; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; padding: 5px 0; color: var(--text-secondary, #96a7c2); line-height: 1.45; animation: step-in .25s ease-out; }++.ps:not(:last-child)::before { content: ""; position: absolute; left: 9px; top: 26px; bottom: -6px; width: 2px; border-radius: 2px; background: var(--border, #1f2d47); }++.ps.is-done:not(:last-child)::before { background: color-mix(in srgb, var(--success, #22c55e) 55%, var(--border, #1f2d47)); }++.ps-mark { position: relative; width: 20px; height: 20px; margin-top: 1px; border-radius: 50%; box-sizing: border-box; border: 2px solid var(--border-light, #2c3d5c); background: var(--bg-card, #131c2e); }++.ps.is-done { color: var(--text-primary, #e6edf7); }++.ps.is-done .ps-mark { border-color: var(--success, #22c55e); background: var(--success, #22c55e); }++.ps.is-done .ps-mark::after { content: ""; position: absolute; left: 5px; top: 2px; width: 5px; height: 9px; border: solid var(--bg-card, #131c2e); border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); }++.ps.is-active { color: var(--text-primary, #e6edf7); font-weight: 650; }++.ps.is-active .ps-mark { border-color: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); border-top-color: var(--accent, #3b82f6); animation: ps-spin .9s linear infinite; }++.state-succeeded .ps.is-active .ps-mark, .state-failed .ps.is-active .ps-mark, .state-cancelled .ps.is-active .ps-mark { animation: none; border-color: var(--accent, #3b82f6); }++.plan-assume { margin: .4rem 0 0 32px; font-size: .85rem; color: var(--text-muted, #7386a3); }++.plan-assume[hidden] { display: none; }++@keyframes ps-spin { to { transform: rotate(360deg); } }++.tl-plan .tl-k { color: var(--accent-light, #60a5fa); }++++/* An answer's citations: a numbered chip per piece of evidence, opening what that tool returned. */++.evs { white-space: nowrap; }++.ev { display: inline-grid; place-items: center; min-width: 1.45em; height: 1.45em; padding: 0 .32em; margin: 0 .1em; box-sizing: border-box; border-radius: 7px;++ font-size: .7em; font-weight: 800; line-height: 1; vertical-align: .3em; font-variant-numeric: tabular-nums; text-decoration: none;++ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); transition: background .15s; }++.ev:hover, .ev:focus-visible, .ev.is-on { background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); color: var(--text-primary, #e6edf7); }++.checked-line { display: flex; align-items: center; gap: 8px; margin: 1.1rem 0 0; font-size: .9rem; color: var(--success-text, var(--success, #22c55e)); }++.checked-line svg { flex: none; width: 18px; height: 18px; padding: 2px; border-radius: 50%; box-sizing: border-box; fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;++ background: color-mix(in srgb, var(--success, #22c55e) 16%, transparent); }++++/* The files a task made: a card each, a chart drawn small, any other file its type. */++.files h3 { font-size: .8rem; letter-spacing: .5px; text-transform: uppercase; color: var(--text-muted, #7386a3); margin: 1.1rem 0 .5rem; }++.file-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); gap: 10px; }++.file-card { display: grid; grid-template-rows: auto auto; min-width: 0; overflow: hidden; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); }++.fc-preview { display: grid; place-items: center; height: 116px; padding: 8px; box-sizing: border-box; overflow: hidden; background: var(--bg-card, #131c2e); border-bottom: 1px solid var(--border, #1f2d47); }++.fc-preview img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100px; object-fit: contain; border-radius: 6px; }++.fc-ext { display: grid; place-items: center; width: 54px; height: 64px; border-radius: 8px; font-size: .78rem; font-weight: 800; letter-spacing: .05em; color: var(--accent-light, #60a5fa);++ background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }++.fc-meta { display: grid; gap: 1px; padding: 9px 12px 10px; min-width: 0; font-size: .85rem; }++.fc-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }++.fc-name:hover { text-decoration: underline; }++++/* What the tools returned, behind the chips. */++.evidence { margin: 1.1rem 0 0; border-top: 1px solid var(--border, #1f2d47); padding-top: .8rem; }++.evidence > summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; cursor: pointer; font-weight: 700; font-size: .92rem; }++.evidence > summary .muted { font-weight: 400; font-size: .85rem; }++.ev-list { list-style: none; margin: .7rem 0 0; padding: 0; display: grid; gap: 8px; }++.ev-item { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); scroll-margin-top: 80px; transition: border-color .3s, box-shadow .3s; }++.ev-item:target, .ev-item.is-flash { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }++.ev-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; font-size: .85rem; }++.ev-n { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; box-sizing: border-box; border-radius: 7px; font-size: .75rem; font-variant-numeric: tabular-nums;++ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }++.ev-item:not(.is-cited) .ev-n { color: var(--text-muted, #7386a3); background: var(--bg-card, #131c2e); }++.ev-tool { font-weight: 700; }++.ev-input { min-width: 0; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }++.ev-at { margin-left: auto; color: var(--text-muted, #7386a3); font-size: .8rem; font-variant-numeric: tabular-nums; }++.ev-text { margin: .45rem 0 0; font: .8rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-secondary, #96a7c2); white-space: pre-wrap; overflow-wrap: anywhere;++ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6; overflow: hidden; }++.ev-item:target .ev-text, .ev-item.is-flash .ev-text, .ev-item.is-open .ev-text { -webkit-line-clamp: unset; display: block; }++.ev-src { margin: .45rem 0 0; padding-left: 18px; font-size: .82rem; overflow-wrap: anywhere; }++.answer-foot span:nth-child(2) { color: var(--text-muted, #7386a3); }++++/* A table keeps its words whole and scrolls inside itself on a narrow screen; the page never scrolls sideways. */++.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }++.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }++++@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }@@@ -6,8 +6,10 @@* - every call gets an id, and its result (with the time between them, where both carry one) lands on it;* - identical consecutive calls (same tool, same input) fold into one line with a count ("×2");* - a task that has ended settles any call it never answered as done, with "No result was recorded for this step."-- * - the run's real time is the task's own: first event → last event, on the done line.-- * Pure (no DOM), so test/task-events.test.js can prove each rule.++ * - the run's real time is the task's own: first event → last event, on the done line;++ * - a plan the loop states becomes one divider naming the step in hand ("Step 2 of 4 · Read the pages"), so the++ * calls under it read as that step's (Actor v2, stage V2-5); plan(text) reads the plan back for the plan card.++ * Pure (no DOM), so test/task-events.test.js can prove each rule. The server requires it too (server/http/pages.js).*/(function (root, factory) {'use strict';@@@ -69,9 +71,32 @@* time); opts.state is its final state (the done line is ok only when "succeeded"); opts.durationMs overrides the* computed time.*/++ // A plan as the loop states it (server/runtime/loop.js planText): an optional "Assumptions: a; b" line, then one line++ // a step, "\u2713 1. goal" (done), "\u2192 2. goal" (in hand) or "\u00b7 3. goal" (next). → { assumptions, steps } or null.++ var PLAN_LINE = /^([\u2713\u2192\u00b7]) (\d+)\. (.+)$/;++ function plan(text) {++ var out = { assumptions: [], steps: [] };++ String(text || '').split('\n').forEach(function (raw) {++ var line = raw.trim(), m = PLAN_LINE.exec(line);++ if (m) out.steps.push({ goal: m[3], state: m[1] === '\u2713' ? 'done' : m[1] === '\u2192' ? 'active' : 'todo' });++ else if (/^Assumptions: /.test(line)) out.assumptions = line.slice(13).split('; ').filter(Boolean);++ });++ return out.steps.length ? out : null;++ }++ /** The divider a plan draws in the session: the step in hand, or the whole plan done. */++ function planLine(p) {++ if (!p || !p.steps.length) return null;++ var at = -1;++ p.steps.forEach(function (st, k) { if (at < 0 && st.state === 'active') at = k; });++ if (at >= 0) return 'Step ' + (at + 1) + ' of ' + p.steps.length + ' \u00b7 ' + p.steps[at].goal;++ var done = p.steps.every(function (st) { return st.state === 'done'; });++ return done ? 'Every step of the plan is done' : 'A plan of ' + p.steps.length + ' steps';++ }++function lines(events, opts) {opts = opts || {};var out = [];++ var lastPlan = null; // the last plan divider, so a plan restated with the same step in hand draws nothingvar open = {}; // tool name → calls of that tool still waiting for their resultvar last = null; // the last call, for folding an identical next onevar folded = null; // the result of a folded call, which must not become a step of its own@@@ -133,7 +158,12 @@out.push(withAgent(e.is_error ? { t: 'note', level: 'error', text: text } : { t: 'text', text: text }, e));continue;}-- if (e.step === 'plan') { out.push(withAgent({ t: 'step', text: text }, e)); continue; }++ if (e.step === 'plan') {++ var stated = plan(text), divider = stated ? planLine(stated) : text;++ if (divider && divider !== lastPlan) out.push(withAgent({ t: 'step', text: divider }, e));++ lastPlan = divider;++ continue;++ }if (e.step === 'handoff') { out.push(withAgent({ t: 'handoff', from: null, to: e.agent || null, reason: text }, e)); continue; }if (e.step === 'check') { out.push(withAgent({ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text: text }, e)); continue; }if (text) out.push(withAgent({ t: 'text', text: text }, e));@@@ -150,5 +180,5 @@return out;}-- return { lines: lines, firstAt: firstAt, duration: duration, attachments: attachments };++ return { lines: lines, firstAt: firstAt, duration: duration, attachments: attachments, plan: plan, planLine: planLine };});@@@ -2,7 +2,9 @@* call and its result), each agent that tried, its checks and hand-offs, from the events the page carries and, while the* task runs, from its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1). Without it, a running task's steps* are drawn the way the server renders a finished one. When the task ends the page reloads to show the outcome. Without-- * this script the page refreshes itself every few seconds (<noscript>). */++ * this script the page refreshes itself every few seconds (<noscript>).++ * The task page v2 (stage V2-5): the plan card follows the plan as the agent states and revises it, and a citation chip++ * in the answer opens the evidence behind it (without this script the chip is a link to the same place). */(function () {'use strict';var LABEL = { tool_call: 'Used', tool_result: 'Got', text: 'Note' };@@@ -41,7 +43,55 @@return view;}++ // The plan card: drawn again from each plan the agent states (task-events.js reads it back), DOM nodes only.++ var PLAN_STATE = { done: 'done', active: 'working on it', todo: 'next' };++ function paintPlan(text) {++ var card = document.getElementById('plan-card');++ var p = card && window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.plan(text) : null;++ if (!p) return;++ var list = document.getElementById('plan-steps');++ while (list.firstChild) list.removeChild(list.firstChild);++ p.steps.forEach(function (st) {++ var li = el('li', 'ps is-' + (PLAN_STATE[st.state] ? st.state : 'todo'));++ var mark = el('span', 'ps-mark'); mark.setAttribute('aria-hidden', 'true');++ li.appendChild(mark);++ li.appendChild(el('span', 'ps-goal', st.goal));++ li.appendChild(el('span', 'sr-only', ' (' + (PLAN_STATE[st.state] || 'next') + ')'));++ list.appendChild(li);++ });++ var assume = document.getElementById('plan-assume');++ if (assume) { assume.textContent = p.assumptions.length ? 'Assuming ' + p.assumptions.join(' \u00b7 ') : ''; assume.hidden = !p.assumptions.length; }++ card.hidden = false;++ }++++ // A citation chip opens the Evidence fold at its item, scrolls to it and marks it a moment; the URL keeps the item.++ function evidence() {++ if (document.documentElement.dataset.evBound) return;++ document.documentElement.dataset.evBound = '1';++ document.addEventListener('click', function (e) {++ var chip = e.target.closest ? e.target.closest('a.ev[data-ev]') : null;++ if (!chip) return;++ var item = document.getElementById('ev-' + chip.getAttribute('data-ev'));++ var fold = document.getElementById('evidence');++ if (!item || !fold) return;++ e.preventDefault();++ fold.open = true;++ document.querySelectorAll('.ev-item.is-flash').forEach(function (n) { n.classList.remove('is-flash'); });++ document.querySelectorAll('a.ev.is-on').forEach(function (n) { n.classList.remove('is-on'); });++ item.classList.add('is-flash', 'is-open');++ chip.classList.add('is-on');++ try { history.replaceState(null, '', '#ev-' + chip.getAttribute('data-ev')); } catch (err) { /* a sandboxed frame */ }++ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;++ item.scrollIntoView({ behavior: still ? 'auto' : 'smooth', block: 'center' });++ setTimeout(function () { item.classList.remove('is-flash'); chip.classList.remove('is-on'); }, 2400);++ });++ // Opened at an item (a shared link, or the back button): the fold opens there too.++ var at = /^#ev-E\d{1,3}$/.test(location.hash) ? document.getElementById(location.hash.slice(1)) : null;++ if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }++ }++function start() {++ evidence();var art0 = document.querySelector('article.task');var view = art0 ? session(art0) : null;var art = document.querySelector('article.task[data-open="1"]');@@@ -129,6 +179,7 @@function onOutput(e) {onDesk(e);onLook(e);++ if (e.step === 'plan') paintPlan(e.text);if (view) { view.push(attach(e)); return undefined; }if ((e.step === 'plan' || e.step === 'handoff') && e.agent) return attempt((e.text || e.agent).replace(/ takes it.*$/, ''));if (e.step === 'plan') return undefined;@@@ -27,6 +27,7 @@ const looks = require('../agents/looks');const { MODES } = require('../agents/router');const seo = require('openvibe-shared/seo');const { findArtifact, sendArtifact } = require('../runtime/artifacts');++const taskEvents = require('../../public/js/task-events');const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -84,6 +85,57 @@ const templateHref = (t) => `/?${new URLSearchParams({ task: t.text, ...(t.modeconst NEEDS = new Map(TEMPLATES.flatMap((g) => (g.needs ? g.items.map((t) => [t.text, g.needs]) : [])));const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);++++// ── The task page v2 (Actor v2, stage V2-5): the plan, the evidence behind each claim, the files, the check, honestly ──++// What a tool is called on the page; an unknown one reads as its own name with spaces.++const TOOL_LABEL = {++ search_web: 'Web search', read_page: 'Read a page', search_openvibe: 'OpenVibe search', wikipedia: 'Wikipedia',++ weather_now: 'Weather', market_quote: 'Market price', currency_convert: 'Currency', time_in: 'Local time',++ convert_time: 'Time zones', geocode: 'Place lookup', place_info: 'Place', sports_scores: 'Sports scores',++ calculate: 'Calculator', make_file: 'File', make_chart: 'Chart', run_tool: 'OpenVibe tool', find_tools: 'Find a tool',++ codes_task: 'Coding task', robot_steps: 'Robot', list_my_robots: 'Your robots', list_my_computers: 'Your computers',++};++const toolLabel = (name) => TOOL_LABEL[name] || String(name || 'tool').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());++// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".++const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };++function modelName(id) {++ const m = String(id || '').split(':').pop();++ if (!m) return 'another model';++ if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;++ return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');++}++const ago = (ms) => {++ const s = Math.max(0, Math.round(ms / 1000));++ return s < 60 ? `${s} s` : s < 3600 ? `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}` : `${Math.floor(s / 3600)} h ${Math.round((s % 3600) / 60)} min`;++};++/** What a tool was asked, in a few words: its query, address or need, else its input as JSON. */++function inputText(input) {++ if (input == null) return '';++ if (typeof input !== 'object') return String(input).slice(0, 160);++ const v = input.query || input.url || input.need || input.place || input.symbol || input.expression || input.name || input.title;++ if (v != null) return String(v).slice(0, 160);++ // Otherwise its plain fields, as words: "to USD · from EUR · amount 7.6".++ const plain = Object.entries(input).filter(([, x]) => x != null && typeof x !== 'object').map(([k, x]) => `${k.replace(/_/g, ' ')} ${x}`);++ return (plain.length ? plain.join(' · ') : JSON.stringify(input)).slice(0, 160);++}++/**++ * The answer's citations as chips: "[E3]" (or "[E3, E5]") becomes a numbered chip that opens what that tool returned,++ * for the ids the ledger has; nothing inside code changes, and an id the ledger does not have stays as written.++ */++function chipify(htmlText, evidence) {++ const known = new Map((evidence || []).map((e) => [String(e.id), e]));++ if (!known.size) return htmlText;++ const chip = (id) => {++ const e = known.get(id);++ const n = id.slice(1);++ const label = `Evidence ${n}: ${toolLabel(e.tool)}${inputText(e.input) ? `, ${inputText(e.input)}` : ''}`.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);++ return `<a class="ev" href="#ev-${id}" data-ev="${id}" aria-label="${label}" title="${label}">${n}</a>`;++ };++ return String(htmlText).split(/(<pre[\s\S]*?<\/pre>|<code[\s\S]*?<\/code>)/).map((part, i) => (i % 2 ? part : part.replace(/\[(E\d{1,3}(?:\s*,\s*E\d{1,3})*)\]/g, (all, ids) => {++ const list = ids.split(/\s*,\s*/);++ return list.every((id) => known.has(id)) ? `<span class="evs">${list.map(chip).join('')}</span>` : all;++ }))).join('');++}const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);const priceText = (rc) => (rc.input_usd_per_mtok === 0 && rc.output_usd_per_mtok === 0 ? 'Free (OpenVibe hardware)' : `${perM(rc.input_usd_per_mtok)} in · ${perM(rc.output_usd_per_mtok)} out per 1M tokens${rc.per_call_usd ? ` · ${usd(rc.per_call_usd)} a search` : ''}`);@@@ -346,6 +398,9 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;const desk = open ? store.deskFrom(events) : null;const look = looks.get(row.id);++ const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));++ const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)++ || (planEvent ? taskEvents.plan(planEvent.text) : null);page(req, res, {title: row.task.length > 60 ? `${row.task.slice(0, 59)}…` : row.task,crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],@@@ -354,6 +409,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</scripts: ['js/task-events.js', 'js/task.js'],sharedScripts: ['agent-session.js'],styles: [showcase.STYLESHEET],++ css: ['css/task.css'],noReferrer: true,body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">@@@ -362,6 +418,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</<h1 class="task-text">${row.task}</h1></header>${outcome(t, row)}++${open ? planCard(plan) : ''}${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}><p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p><a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a>@@@ -371,6 +428,7 @@ ${open || look ? html`<figure class="last-look" id="last-look"${look ? '' : raw(<figcaption class="muted small">What your agent last saw on the screen${look ? html` · ${time(new Date(look.at).toISOString())}` : ''}</figcaption></figure>` : ''}<section class="how" aria-labelledby="h-how"><h2 id="h-how">How it went</h2>++${!open && plan ? planCard(plan) : ''}<div class="attempts" id="attempts">${attemptsOf(events).map(attemptBlock)}</div><script type="application/json" id="task-events">${raw(JSON.stringify(events.slice(-3000)).replace(/</g, '\\u003c'))}</script>${open ? html`<p class="muted small live-note" id="live-note"><span class="dot-pulse" aria-hidden="true"></span> Updating as the agent works.</p>` : ''}@@@ -392,6 +450,20 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => []))}</details>`;}++ /**++ * The plan the agent works through: each sub-goal done, in hand or next, and what it assumed. A live task's card is++ * there before the first plan (hidden), so js/task.js can fill it as the plan arrives and changes.++ */++ function planCard(plan) {++ const STATE = { done: 'done', active: 'working on it', todo: 'next' };++ const steps = plan ? plan.steps : [];++ return html`<section class="plan-card" id="plan-card" aria-labelledby="h-plan"${steps.length ? '' : raw(' hidden')}>++<h2 id="h-plan">The plan</h2>++<ol class="plan" id="plan-steps">${steps.map((st) => html`<li class="ps is-${STATE[st.state] ? st.state : 'todo'}"><span class="ps-mark" aria-hidden="true"></span><span class="ps-goal">${st.goal}</span><span class="sr-only"> (${STATE[st.state] || 'next'})</span></li>`)}</ol>++<p class="plan-assume" id="plan-assume"${plan && plan.assumptions.length ? '' : raw(' hidden')}>Assuming ${plan ? plan.assumptions.join(' · ') : ''}</p>++</section>`;++ }++/** The outcome card: the first thing under the task. */function outcome(t, row) {if (t.state === 'succeeded') return resultBlock(t.result, t);@@@ -420,11 +492,26 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [const checked = result.checked || {};const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];++ const evidence = Array.isArray(result.evidence) ? result.evidence.filter((e) => e && /^E\d{1,3}$/.test(String(e.id))) : [];++ const cited = new Set(Array.isArray(result.citations) ? result.citations.map(String) : []);++ const took = t.finished_at && t.created_at ? Date.parse(t.finished_at) - Date.parse(t.created_at) : NaN;++ const claims = Number(checked.claims) || 0;++ const grounded = Math.min(claims, Number(checked.grounded) || 0);++ const by = `${modelName(checked.by)}${checked.cross_family ? ', another model family' : ', the same family'}`;++ const ext = (a) => (String(a.name || '').split('.').pop() || a.kind || 'file').slice(0, 5).toUpperCase();return html`<section class="outcome outcome-ok answer" aria-labelledby="h-answer"><h2 id="h-answer" class="sr-only">Answer</h2>--<div class="prose">${raw(markdown(result.answer, { headingOffset: 2 }))}</div>--${artifacts.length ? html`<div class="sources artifacts"><h3>Files</h3><ul>${artifacts.map((a) => html`<li><a href="${a.url}" rel="nofollow noopener">${a.name}</a> <span class="muted">${a.kind} · ${bytes(a.size)}</span>${a.mime === 'image/svg+xml' ? html`<div><img src="${a.url}" alt="${a.name}" loading="lazy" style="max-width:100%;height:auto;margin-top:8px"></div>` : ''}</li>`)}</ul></div>` : ''}++<div class="prose">${raw(chipify(markdown(result.answer, { headingOffset: 2 }), evidence))}</div>++<p class="checked-line"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span>${claims ? `Checked: ${claims} claim${claims === 1 ? '' : 's'}, ${grounded} grounded` : 'Checked'} · by ${by}</span></p>++${artifacts.length ? html`<div class="files"><h3>Files</h3><ul class="file-grid">${artifacts.map((a) => html`<li class="file-card">++<a class="fc-preview" href="${a.url}" rel="nofollow noopener" tabindex="-1" aria-hidden="true">${/^image\/(svg\+xml|png|jpe?g|gif|webp)$/.test(a.mime) ? html`<img src="${a.url}" alt="" loading="lazy">` : html`<span class="fc-ext">${ext(a)}</span>`}</a>++<span class="fc-meta"><a class="fc-name" href="${a.url}" rel="nofollow noopener">${a.name}</a><span class="muted">${a.kind === 'chart' ? 'Chart' : 'File'} · ${bytes(a.size)}</span></span></li>`)}</ul></div>` : ''}${result.sources && result.sources.length ? html`<div class="sources"><h3>Sources</h3><ul>${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u.replace(/^https?:\/\//, '').slice(0, 90)}</a></li>`)}</ul></div>` : ''}--<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · <code>${result.model}</code>` : ''}</span><span>✓ checked by ${checked.by}${checked.cross_family ? ' (another model family)' : ' (same family)'}</span><span>${t.cost ? usd(t.cost.usd) : '$0'}</span></p>++${evidence.length ? html`<details class="evidence" id="evidence"><summary><span>Evidence</span><span class="muted">${evidence.length} item${evidence.length === 1 ? '' : 's'}: what the tools returned${cited.size ? `, ${cited.size} cited` : ''}</span></summary>++<ol class="ev-list">${evidence.map((e) => html`<li class="ev-item${cited.has(String(e.id)) ? ' is-cited' : ''}" id="ev-${e.id}">++<p class="ev-head"><b class="ev-n">${String(e.id).slice(1)}</b><span class="ev-tool">${toolLabel(e.tool)}</span>${inputText(e.input) ? html`<span class="ev-input">${inputText(e.input)}</span>` : ''}${e.at ? html`<time class="ev-at" datetime="${e.at}">${String(e.at).slice(11, 16)} UTC</time>` : ''}</p>++${e.text ? html`<p class="ev-text">${String(e.text).slice(0, 700)}${String(e.text).length > 700 ? '…' : ''}</p>` : ''}++${Array.isArray(e.sources) && e.sources.length ? html`<ul class="ev-src">${e.sources.slice(0, 6).map((src) => html`<li><a href="${src.url}" rel="nofollow ugc noopener" target="_blank">${src.title || String(src.url).replace(/^https?:\/\//, '').slice(0, 80)}</a></li>`)}</ul>` : ''}</li>`)}</ol></details>` : ''}++<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · ${modelName(result.model)}` : ''}</span><span>${t.cost ? usd(t.cost.usd) : '$0'} of ${usd(t.budget.per_task_usd)}</span>${Number.isFinite(took) && took > 0 ? html`<span>took ${ago(took)}</span>` : ''}</p></section>`;}@@@ -439,7 +526,7 @@ ${result.sources && result.sources.length ? html`<div class="sources"><h3>Sourcelet lastCheckFailed = false;for (const e of events) {if (e.kind !== 'output') continue;-- if ((e.step === 'plan' || e.step === 'handoff') && e.agent) {++ if ((e.step === 'plan' || e.step === 'handoff') && e.agent && !(e.step === 'plan' && taskEvents.plan(e.text))) {cur = { agent: e.agent, why: e.text, steps: [], refused: null, check: null, stopped: null };out.push(cur);lastCheckFailed = false;@@@ -484,6 +571,7 @@ ${a.refused ? html`<details class="refused"><summary>See the answer that didn't}function stepItem(e) {++ if (e.step === 'plan') { const p = taskEvents.plan(e.text); return html`<li class="tl-item tl-plan"><span class="tl-k">Plan</span><span class="tl-v">${p ? taskEvents.planLine(p) : e.text}</span></li>`; }if (e.step === 'tool_call') return html`<li class="tl-item tl-tool"><span class="tl-k">Used</span><span class="tl-v"><code>${e.tool}</code> ${e.input || ''}</span></li>`;if (e.step === 'tool_result') return html`<li class="tl-item tl-result${e.is_error ? ' tl-err' : ''}"><span class="tl-k">Got</span><span class="tl-v"><span class="muted">${(e.text || '').slice(0, 220)}</span></span></li>`;if (/^Needs you: /.test(e.text || '')) return html`<li class="tl-item tl-needs"><span class="tl-k">Needs you</span><span class="tl-v">${withDeskLinks(String(e.text).replace(/^Needs you: /, ''))}</span></li>`;@@@ -15,6 +15,7 @@ function slugName(raw) {const base = String(raw || 'chart').replace(/[\\/]+/g, '-').replace(/[^A-Za-z0-9._-]+/g, '-')++ .replace(/-{2,}/g, '-').replace(/^[-.]+|[-.]+$/g, '').replace(/\.[A-Za-z0-9]{1,8}$/, '').slice(0, 100);@@@ -3,7 +3,7 @@// task never answered settles as done instead of spinning forever, identical consecutive calls fold into one line// with a count, and the run's time is the task's own (first event → last event).const assert = require('assert');--const { lines, firstAt, duration } = require('../public/js/task-events');++const { lines, firstAt, duration, plan, planLine } = require('../public/js/task-events');const at = (s) => `2026-10-30T10:00:${s}.000Z`;const call = (input, s, extra) => ({ kind: 'output', step: 'tool_call', tool: 'web_search', input, at: at(s), agent: 'openai-agent', ...extra });@@@ -82,4 +82,19 @@ const end = (state, s) => ({ kind: 'end', state, at: at(s) });assert.strictEqual(duration([], { durationMs: 2500 }), 2500);}++// The plan the loop states (Actor v2, stage V2-5): read back exactly, a divider per step in hand, never a repeat.++{++ const stated = (k) => ['Assumptions: under $10; Europe', ...['Find three offers', 'Read the pages', 'Compare them'].map((g, i) => `${i < k ? '\u2713' : i === k ? '\u2192' : '\u00b7'} ${i + 1}. ${g}`)].join('\n');++ assert.deepStrictEqual(plan(stated(1)), { assumptions: ['under $10', 'Europe'], steps: [++ { goal: 'Find three offers', state: 'done' }, { goal: 'Read the pages', state: 'active' }, { goal: 'Compare them', state: 'todo' }] });++ assert.strictEqual(plan('This is a "research" task.'), null, 'the engine\'s own words are not a plan');++ assert.strictEqual(planLine(plan(stated(1))), 'Step 2 of 3 \u00b7 Read the pages');++ assert.strictEqual(planLine(plan(stated(3))), 'Every step of the plan is done');++ const ev = (t, sec) => ({ kind: 'output', step: 'plan', text: t, at: at(sec), agent: 'openvibe-runtime' });++ const out = lines([ev(stated(0), '01'), call('offers', '02'), ev(stated(0), '03'), ev(stated(1), '04'), call('pages', '05'), ev(stated(3), '06')], { final: false });++ assert.deepStrictEqual(out.filter((l) => l.t === 'step').map((l) => l.text), ['Step 1 of 3 \u00b7 Find three offers', 'Step 2 of 3 \u00b7 Read the pages', 'Every step of the plan is done'],++ 'one divider per step in hand: the same plan stated twice draws nothing new');++ assert.deepStrictEqual(lines([{ kind: 'output', step: 'plan', text: 'The agent starts with a plan, then works through it.', at: at('01') }]).map((l) => l.text), ['The agent starts with a plan, then works through it.'], 'other plan words stay as they are');++}++console.log('task events: all checks passed');nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// The task page v2 (Actor v2, stage V2-5): the answer's citations are chips that open what the tool returned, one calm++// line says how many claims were checked and by whom, the files are cards (a chart drawn small), the plan shows each++// step done, in hand or next (and a live task has the card ready for the plan to arrive), the cost is against the++// budget with the time it took, and the plan updates never split the no-script view into one attempt each.++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ t.config.loop.selfReflections = 0;++ const s = t.ctx.s;++ const { appendEvent } = require('../server/tasks/store');++ const at = (sec) => new Date(Date.now() - sec * 1000).toISOString();++++ t.providers.state.script = [++ { content: 'Charting it.', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'make_chart', arguments: JSON.stringify({ type: 'bar', title: 'Price -- per month', data: [{ label: 'A', value: 4.5 }, { label: 'B', value: 6.7 }] }) } }] },++ { content: 'done' },++ ];++ const created = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Compare two offers and chart them' }, headers: SAME })).json();++ await t.waitFor(created.id);++ const row = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [created.id]);++ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result;++ result.answer = 'A is cheapest at €4.51 [E1], B is €6.74 [E2, E1]. Not a citation: `[E1]` and [E9].';++ result.evidence = [++ { id: 'E1', tool: 'read_page', input: { url: 'https://a.example/pricing' }, text: 'A · €4.51/month', sources: [{ url: 'https://a.example/pricing', title: 'A pricing' }], at: at(60) },++ { id: 'E2', tool: 'search_web', input: { query: 'b price <script>' }, text: 'B · €6.74', sources: [], at: at(50) },++ { id: 'E3', tool: 'calculate', input: { expression: '6.74-4.51' }, text: '2.23', sources: [], at: at(40) },++ ];++ result.citations = ['E1', 'E2'];++ result.checked = { by: 'deepseek:deepseek-flash', cross_family: true, reason: 'ok', claims: 4, grounded: 4 };++ result.plan = { assumptions: ['euro prices'], steps: [{ goal: 'Read both pages', state: 'done' }, { goal: 'Chart them', state: 'done' }] };++ await s.db.query('UPDATE tasks SET result = $2::jsonb, cost_usd = 0.0124, created_at = $3, finished_at = $4 WHERE id = $1', [created.id, JSON.stringify(result), at(90), at(4)]);++ const stated = ['\u2713 1. Read both pages', '\u2192 2. Chart them'].join('\n');++ await appendEvent(s, created.id, { kind: 'output', step: 'plan', agent: result.agent, text: stated, at: at(30) });++ await appendEvent(s, created.id, { kind: 'output', step: 'plan', agent: result.agent, text: stated.replace('\u2192', '\u2713'), at: at(20) });++++ try {++ await check('citations become chips for the evidence the ledger has, and nothing else changes', async () => {++ const r = await t.get(`/tasks/${created.id}`, { as: kim });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('<a class="ev" href="#ev-E1" data-ev="E1"'), 'a chip for E1');++ assert.ok(/<span class="evs"><a class="ev" href="#ev-E2"[^>]*>2<\/a><a class="ev" href="#ev-E1"[^>]*>1<\/a><\/span>/.test(r.text), '[E2, E1] is two chips, in that order');++ assert.ok(r.text.includes('<code>[E1]</code>'), 'a citation inside code stays code');++ assert.ok(r.text.includes('[E9]'), 'an id the ledger does not have stays as written');++ assert.ok(r.text.includes('aria-label="Evidence 2: Web search, b price <script>"'), 'the chip says what it opens, escaped');++ assert.ok(!r.text.includes('<script>"'), 'nothing from a tool input is markup');++ });++++ await check('the evidence behind the chips: each item, what it was asked, what it returned, its sources', async () => {++ const r = await t.get(`/tasks/${created.id}`, { as: kim });++ assert.ok(r.text.includes('<details class="evidence" id="evidence">'), 'one fold of evidence');++ assert.ok(r.text.includes('3 items: what the tools returned, 2 cited'));++ assert.ok(r.text.includes('<li class="ev-item is-cited" id="ev-E1">') && r.text.includes('<li class="ev-item" id="ev-E3">'), 'cited and uncited marked');++ assert.ok(r.text.includes('<span class="ev-tool">Read a page</span>') && r.text.includes('https://a.example/pricing'), 'the tool by name, what it read');++ assert.ok(r.text.includes('>A pricing</a>'), 'its sources');++ });++++ await check('one calm check line, files as cards, the cost against the budget and the time it took', async () => {++ const r = await t.get(`/tasks/${created.id}`, { as: kim });++ assert.ok(r.text.includes('Checked: 4 claims, 4 grounded · by DeepSeek Flash, another model family'));++ assert.ok(/<li class="file-card">[\s\S]*?<img src="[^"]+\/files\/art_[^"]+"/.test(r.text), 'the chart is drawn small on its card');++ assert.ok(r.text.includes('Price-per-month.svg'), 'its name, without the runs of dashes a title leaves');++ assert.ok(r.text.includes('<span>$0.01 of $0.05</span>') && /took 1 min 2\d s/.test(r.text), 'the cost against the budget and the time');++ });++++ await check('the plan: every step and what it assumed, at the head of How it went once the task has ended', async () => {++ const r = await t.get(`/tasks/${created.id}`, { as: kim });++ const how = r.text.slice(r.text.indexOf('id="h-how"'));++ assert.ok(how.includes('<section class="plan-card" id="plan-card"'), 'the plan card is under How it went');++ assert.ok(how.includes('<li class="ps is-done"><span class="ps-mark" aria-hidden="true"></span><span class="ps-goal">Read both pages</span>'));++ assert.ok(how.includes('Assuming euro prices'));++ assert.strictEqual((r.text.match(/<section class="attempt"/g) || []).length, 1, 'plan updates do not split the no-script view into attempts');++ assert.ok(r.text.includes('<li class="tl-item tl-plan"><span class="tl-k">Plan</span><span class="tl-v">Step 2 of 2 · Chart them</span>'), 'a plan update is a step of its attempt');++ });++++ await check('a live task has the plan card ready, and it shows the plan as the agent states it', async () => {++ const live = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Count to three' }, headers: SAME })).json();++ await t.waitFor(live.id);++ await s.db.query("UPDATE tasks SET state = 'running', result = NULL, finished_at = NULL WHERE id = $1", [live.id]);++ let r = await t.get(`/tasks/${live.id}`, { as: kim });++ assert.ok(/<section class="plan-card" id="plan-card" aria-labelledby="h-plan"( hidden)?>/.test(r.text), 'the card is there for js/task.js to fill');++ await appendEvent(s, live.id, { kind: 'output', step: 'plan', agent: 'openvibe-runtime', text: '\u2192 1. Count\n\u00b7 2. Say it', at: at(1) });++ r = await t.get(`/tasks/${live.id}`, { as: kim });++ const head = r.text.slice(0, r.text.indexOf('id="h-how"'));++ assert.ok(head.includes('<li class="ps is-active">') && head.includes('<li class="ps is-todo">'), 'above How it went while it runs: in hand, then next');++ });++++ await check('another person still gets the 404 of a task that does not exist', async () => {++ const r = await t.get(`/tasks/${created.id}`, { as: bo });++ assert.strictEqual(r.status, 404);++ assert.ok(!r.text.includes('ev-item'));++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+/* A task's own page (Actor v2, stage V2-5), loaded by that page only so the home stays light: the plan, the evidence
+ chips and what they open, the check line, the files. The rest of the page is actor.css. */
+.plan-card { margin: 0 0 1.4rem; padding: 14px 18px 12px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+.plan-card[hidden] { display: none; }
+.how .plan-card { margin: .2rem 0 1rem; }
+.plan-card > h2 { margin: 0 0 .5rem; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.plan { list-style: none; margin: 0; padding: 0; display: grid; }
+.ps { position: relative; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; padding: 5px 0; color: var(--text-secondary, #96a7c2); line-height: 1.45; animation: step-in .25s ease-out; }
+.ps:not(:last-child)::before { content: ""; position: absolute; left: 9px; top: 26px; bottom: -6px; width: 2px; border-radius: 2px; background: var(--border, #1f2d47); }
+.ps.is-done:not(:last-child)::before { background: color-mix(in srgb, var(--success, #22c55e) 55%, var(--border, #1f2d47)); }
+.ps-mark { position: relative; width: 20px; height: 20px; margin-top: 1px; border-radius: 50%; box-sizing: border-box; border: 2px solid var(--border-light, #2c3d5c); background: var(--bg-card, #131c2e); }
+.ps.is-done { color: var(--text-primary, #e6edf7); }
+.ps.is-done .ps-mark { border-color: var(--success, #22c55e); background: var(--success, #22c55e); }
+.ps.is-done .ps-mark::after { content: ""; position: absolute; left: 5px; top: 2px; width: 5px; height: 9px; border: solid var(--bg-card, #131c2e); border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); }
+.ps.is-active { color: var(--text-primary, #e6edf7); font-weight: 650; }
+.ps.is-active .ps-mark { border-color: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); border-top-color: var(--accent, #3b82f6); animation: ps-spin .9s linear infinite; }
+.state-succeeded .ps.is-active .ps-mark, .state-failed .ps.is-active .ps-mark, .state-cancelled .ps.is-active .ps-mark { animation: none; border-color: var(--accent, #3b82f6); }
+.plan-assume { margin: .4rem 0 0 32px; font-size: .85rem; color: var(--text-muted, #7386a3); }
+.plan-assume[hidden] { display: none; }
+@keyframes ps-spin { to { transform: rotate(360deg); } }
+.tl-plan .tl-k { color: var(--accent-light, #60a5fa); }
+
+/* An answer's citations: a numbered chip per piece of evidence, opening what that tool returned. */
+.evs { white-space: nowrap; }
+.ev { display: inline-grid; place-items: center; min-width: 1.45em; height: 1.45em; padding: 0 .32em; margin: 0 .1em; box-sizing: border-box; border-radius: 7px;
+ font-size: .7em; font-weight: 800; line-height: 1; vertical-align: .3em; font-variant-numeric: tabular-nums; text-decoration: none;
+ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); transition: background .15s; }
+.ev:hover, .ev:focus-visible, .ev.is-on { background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); color: var(--text-primary, #e6edf7); }
+.checked-line { display: flex; align-items: center; gap: 8px; margin: 1.1rem 0 0; font-size: .9rem; color: var(--success-text, var(--success, #22c55e)); }
+.checked-line svg { flex: none; width: 18px; height: 18px; padding: 2px; border-radius: 50%; box-sizing: border-box; fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
+ background: color-mix(in srgb, var(--success, #22c55e) 16%, transparent); }
+
+/* The files a task made: a card each, a chart drawn small, any other file its type. */
+.files h3 { font-size: .8rem; letter-spacing: .5px; text-transform: uppercase; color: var(--text-muted, #7386a3); margin: 1.1rem 0 .5rem; }
+.file-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); gap: 10px; }
+.file-card { display: grid; grid-template-rows: auto auto; min-width: 0; overflow: hidden; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); }
+.fc-preview { display: grid; place-items: center; height: 116px; padding: 8px; box-sizing: border-box; overflow: hidden; background: var(--bg-card, #131c2e); border-bottom: 1px solid var(--border, #1f2d47); }
+.fc-preview img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100px; object-fit: contain; border-radius: 6px; }
+.fc-ext { display: grid; place-items: center; width: 54px; height: 64px; border-radius: 8px; font-size: .78rem; font-weight: 800; letter-spacing: .05em; color: var(--accent-light, #60a5fa);
+ background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
+.fc-meta { display: grid; gap: 1px; padding: 9px 12px 10px; min-width: 0; font-size: .85rem; }
+.fc-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
+.fc-name:hover { text-decoration: underline; }
+
+/* What the tools returned, behind the chips. */
+.evidence { margin: 1.1rem 0 0; border-top: 1px solid var(--border, #1f2d47); padding-top: .8rem; }
+.evidence > summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; cursor: pointer; font-weight: 700; font-size: .92rem; }
+.evidence > summary .muted { font-weight: 400; font-size: .85rem; }
+.ev-list { list-style: none; margin: .7rem 0 0; padding: 0; display: grid; gap: 8px; }
+.ev-item { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); scroll-margin-top: 80px; transition: border-color .3s, box-shadow .3s; }
+.ev-item:target, .ev-item.is-flash { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
+.ev-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; font-size: .85rem; }
+.ev-n { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; box-sizing: border-box; border-radius: 7px; font-size: .75rem; font-variant-numeric: tabular-nums;
+ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
+.ev-item:not(.is-cited) .ev-n { color: var(--text-muted, #7386a3); background: var(--bg-card, #131c2e); }
+.ev-tool { font-weight: 700; }
+.ev-input { min-width: 0; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
+.ev-at { margin-left: auto; color: var(--text-muted, #7386a3); font-size: .8rem; font-variant-numeric: tabular-nums; }
+.ev-text { margin: .45rem 0 0; font: .8rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-secondary, #96a7c2); white-space: pre-wrap; overflow-wrap: anywhere;
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6; overflow: hidden; }
+.ev-item:target .ev-text, .ev-item.is-flash .ev-text, .ev-item.is-open .ev-text { -webkit-line-clamp: unset; display: block; }
+.ev-src { margin: .45rem 0 0; padding-left: 18px; font-size: .82rem; overflow-wrap: anywhere; }
+.answer-foot span:nth-child(2) { color: var(--text-muted, #7386a3); }
+
+/* A table keeps its words whole and scrolls inside itself on a narrow screen; the page never scrolls sideways. */
+.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }
+.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }
+
+@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }
@@ -6,8 +6,10 @@
@@ -6,8 +6,10 @@
* - every call gets an id, and its result (with the time between them, where both carry one) lands on it;
* - every call gets an id, and its result (with the time between them, where both carry one) lands on it;
* - identical consecutive calls (same tool, same input) fold into one line with a count ("×2");
* - identical consecutive calls (same tool, same input) fold into one line with a count ("×2");
* - a task that has ended settles any call it never answered as done, with "No result was recorded for this step."
* - a task that has ended settles any call it never answered as done, with "No result was recorded for this step."
- * - the run's real time is the task's own: first event → last event, on the done line.
- * Pure (no DOM), so test/task-events.test.js can prove each rule.
+ * - the run's real time is the task's own: first event → last event, on the done line;
+ * - a plan the loop states becomes one divider naming the step in hand ("Step 2 of 4 · Read the pages"), so the
+ * calls under it read as that step's (Actor v2, stage V2-5); plan(text) reads the plan back for the plan card.
+ * Pure (no DOM), so test/task-events.test.js can prove each rule. The server requires it too (server/http/pages.js).
*/
*/
(function (root, factory) {
(function (root, factory) {
'use strict';
'use strict';
@@ -69,9 +71,32 @@
@@ -69,9 +71,32 @@
* time); opts.state is its final state (the done line is ok only when "succeeded"); opts.durationMs overrides the
* time); opts.state is its final state (the done line is ok only when "succeeded"); opts.durationMs overrides the
* computed time.
* computed time.
*/
*/
+ // A plan as the loop states it (server/runtime/loop.js planText): an optional "Assumptions: a; b" line, then one line
+ // a step, "\u2713 1. goal" (done), "\u2192 2. goal" (in hand) or "\u00b7 3. goal" (next). → { assumptions, steps } or null.
+ var PLAN_LINE = /^([\u2713\u2192\u00b7]) (\d+)\. (.+)$/;
+ function plan(text) {
+ var out = { assumptions: [], steps: [] };
+ String(text || '').split('\n').forEach(function (raw) {
+ var line = raw.trim(), m = PLAN_LINE.exec(line);
+ if (m) out.steps.push({ goal: m[3], state: m[1] === '\u2713' ? 'done' : m[1] === '\u2192' ? 'active' : 'todo' });
+ else if (/^Assumptions: /.test(line)) out.assumptions = line.slice(13).split('; ').filter(Boolean);
+ });
+ return out.steps.length ? out : null;
+ }
+ /** The divider a plan draws in the session: the step in hand, or the whole plan done. */
+ function planLine(p) {
+ if (!p || !p.steps.length) return null;
+ var at = -1;
+ p.steps.forEach(function (st, k) { if (at < 0 && st.state === 'active') at = k; });
+ if (at >= 0) return 'Step ' + (at + 1) + ' of ' + p.steps.length + ' \u00b7 ' + p.steps[at].goal;
+ var done = p.steps.every(function (st) { return st.state === 'done'; });
+ return done ? 'Every step of the plan is done' : 'A plan of ' + p.steps.length + ' steps';
+ }
+
function lines(events, opts) {
function lines(events, opts) {
opts = opts || {};
opts = opts || {};
var out = [];
var out = [];
+ var lastPlan = null; // the last plan divider, so a plan restated with the same step in hand draws nothing
var open = {}; // tool name → calls of that tool still waiting for their result
var open = {}; // tool name → calls of that tool still waiting for their result
var last = null; // the last call, for folding an identical next one
var last = null; // the last call, for folding an identical next one
var folded = null; // the result of a folded call, which must not become a step of its own
var folded = null; // the result of a folded call, which must not become a step of its own
@@ -133,7 +158,12 @@
@@ -133,7 +158,12 @@
out.push(withAgent(e.is_error ? { t: 'note', level: 'error', text: text } : { t: 'text', text: text }, e));
out.push(withAgent(e.is_error ? { t: 'note', level: 'error', text: text } : { t: 'text', text: text }, e));
continue;
continue;
}
}
- if (e.step === 'plan') { out.push(withAgent({ t: 'step', text: text }, e)); continue; }
+ if (e.step === 'plan') {
+ var stated = plan(text), divider = stated ? planLine(stated) : text;
+ if (divider && divider !== lastPlan) out.push(withAgent({ t: 'step', text: divider }, e));
+ lastPlan = divider;
+ continue;
+ }
if (e.step === 'handoff') { out.push(withAgent({ t: 'handoff', from: null, to: e.agent || null, reason: text }, e)); continue; }
if (e.step === 'handoff') { out.push(withAgent({ t: 'handoff', from: null, to: e.agent || null, reason: text }, e)); continue; }
if (e.step === 'check') { out.push(withAgent({ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text: text }, e)); continue; }
if (e.step === 'check') { out.push(withAgent({ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text: text }, e)); continue; }
if (text) out.push(withAgent({ t: 'text', text: text }, e));
if (text) out.push(withAgent({ t: 'text', text: text }, e));
@@ -150,5 +180,5 @@
@@ -150,5 +180,5 @@
return out;
return out;
}
}
- return { lines: lines, firstAt: firstAt, duration: duration, attachments: attachments };
+ return { lines: lines, firstAt: firstAt, duration: duration, attachments: attachments, plan: plan, planLine: planLine };
});
});
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
* call and its result), each agent that tried, its checks and hand-offs, from the events the page carries and, while the
* call and its result), each agent that tried, its checks and hand-offs, from the events the page carries and, while the
* task runs, from its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1). Without it, a running task's steps
* task runs, from its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1). Without it, a running task's steps
* are drawn the way the server renders a finished one. When the task ends the page reloads to show the outcome. Without
* are drawn the way the server renders a finished one. When the task ends the page reloads to show the outcome. Without
- * this script the page refreshes itself every few seconds (<noscript>). */
+ * this script the page refreshes itself every few seconds (<noscript>).
+ * The task page v2 (stage V2-5): the plan card follows the plan as the agent states and revises it, and a citation chip
+ * in the answer opens the evidence behind it (without this script the chip is a link to the same place). */
(function () {
(function () {
'use strict';
'use strict';
var LABEL = { tool_call: 'Used', tool_result: 'Got', text: 'Note' };
var LABEL = { tool_call: 'Used', tool_result: 'Got', text: 'Note' };
@@ -41,7 +43,55 @@
@@ -41,7 +43,55 @@
return view;
return view;
}
}
+ // The plan card: drawn again from each plan the agent states (task-events.js reads it back), DOM nodes only.
+ var PLAN_STATE = { done: 'done', active: 'working on it', todo: 'next' };
+ function paintPlan(text) {
+ var card = document.getElementById('plan-card');
+ var p = card && window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.plan(text) : null;
+ if (!p) return;
+ var list = document.getElementById('plan-steps');
+ while (list.firstChild) list.removeChild(list.firstChild);
+ p.steps.forEach(function (st) {
+ var li = el('li', 'ps is-' + (PLAN_STATE[st.state] ? st.state : 'todo'));
+ var mark = el('span', 'ps-mark'); mark.setAttribute('aria-hidden', 'true');
+ li.appendChild(mark);
+ li.appendChild(el('span', 'ps-goal', st.goal));
+ li.appendChild(el('span', 'sr-only', ' (' + (PLAN_STATE[st.state] || 'next') + ')'));
+ list.appendChild(li);
+ });
+ var assume = document.getElementById('plan-assume');
+ if (assume) { assume.textContent = p.assumptions.length ? 'Assuming ' + p.assumptions.join(' \u00b7 ') : ''; assume.hidden = !p.assumptions.length; }
+ card.hidden = false;
+ }
+
+ // A citation chip opens the Evidence fold at its item, scrolls to it and marks it a moment; the URL keeps the item.
+ function evidence() {
+ if (document.documentElement.dataset.evBound) return;
+ document.documentElement.dataset.evBound = '1';
+ document.addEventListener('click', function (e) {
+ var chip = e.target.closest ? e.target.closest('a.ev[data-ev]') : null;
+ if (!chip) return;
+ var item = document.getElementById('ev-' + chip.getAttribute('data-ev'));
+ var fold = document.getElementById('evidence');
+ if (!item || !fold) return;
+ e.preventDefault();
+ fold.open = true;
+ document.querySelectorAll('.ev-item.is-flash').forEach(function (n) { n.classList.remove('is-flash'); });
+ document.querySelectorAll('a.ev.is-on').forEach(function (n) { n.classList.remove('is-on'); });
+ item.classList.add('is-flash', 'is-open');
+ chip.classList.add('is-on');
+ try { history.replaceState(null, '', '#ev-' + chip.getAttribute('data-ev')); } catch (err) { /* a sandboxed frame */ }
+ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ item.scrollIntoView({ behavior: still ? 'auto' : 'smooth', block: 'center' });
+ setTimeout(function () { item.classList.remove('is-flash'); chip.classList.remove('is-on'); }, 2400);
+ });
+ // Opened at an item (a shared link, or the back button): the fold opens there too.
+ var at = /^#ev-E\d{1,3}$/.test(location.hash) ? document.getElementById(location.hash.slice(1)) : null;
+ if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }
+ }
+
function start() {
function start() {
+ evidence();
var art0 = document.querySelector('article.task');
var art0 = document.querySelector('article.task');
var view = art0 ? session(art0) : null;
var view = art0 ? session(art0) : null;
var art = document.querySelector('article.task[data-open="1"]');
var art = document.querySelector('article.task[data-open="1"]');
@@ -129,6 +179,7 @@
@@ -129,6 +179,7 @@
function onOutput(e) {
function onOutput(e) {
onDesk(e);
onDesk(e);
onLook(e);
onLook(e);
+ if (e.step === 'plan') paintPlan(e.text);
if (view) { view.push(attach(e)); return undefined; }
if (view) { view.push(attach(e)); return undefined; }
if ((e.step === 'plan' || e.step === 'handoff') && e.agent) return attempt((e.text || e.agent).replace(/ takes it.*$/, ''));
if ((e.step === 'plan' || e.step === 'handoff') && e.agent) return attempt((e.text || e.agent).replace(/ takes it.*$/, ''));
if (e.step === 'plan') return undefined;
if (e.step === 'plan') return undefined;
@@ -27,6 +27,7 @@ const looks = require('../agents/looks');
@@ -27,6 +27,7 @@ const looks = require('../agents/looks');
const { MODES } = require('../agents/router');
const { MODES } = require('../agents/router');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { findArtifact, sendArtifact } = require('../runtime/artifacts');
const { findArtifact, sendArtifact } = require('../runtime/artifacts');
+const taskEvents = require('../../public/js/task-events');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -84,6 +85,57 @@ const templateHref = (t) => `/?${new URLSearchParams({ task: t.text, ...(t.mode
@@ -84,6 +85,57 @@ const templateHref = (t) => `/?${new URLSearchParams({ task: t.text, ...(t.mode
const NEEDS = new Map(TEMPLATES.flatMap((g) => (g.needs ? g.items.map((t) => [t.text, g.needs]) : [])));
const NEEDS = new Map(TEMPLATES.flatMap((g) => (g.needs ? g.items.map((t) => [t.text, g.needs]) : [])));
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
+
+// ── The task page v2 (Actor v2, stage V2-5): the plan, the evidence behind each claim, the files, the check, honestly ──
+// What a tool is called on the page; an unknown one reads as its own name with spaces.
+const TOOL_LABEL = {
+ search_web: 'Web search', read_page: 'Read a page', search_openvibe: 'OpenVibe search', wikipedia: 'Wikipedia',
+ weather_now: 'Weather', market_quote: 'Market price', currency_convert: 'Currency', time_in: 'Local time',
+ convert_time: 'Time zones', geocode: 'Place lookup', place_info: 'Place', sports_scores: 'Sports scores',
+ calculate: 'Calculator', make_file: 'File', make_chart: 'Chart', run_tool: 'OpenVibe tool', find_tools: 'Find a tool',
+ codes_task: 'Coding task', robot_steps: 'Robot', list_my_robots: 'Your robots', list_my_computers: 'Your computers',
+};
+const toolLabel = (name) => TOOL_LABEL[name] || String(name || 'tool').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
+// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
+const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
+function modelName(id) {
+ const m = String(id || '').split(':').pop();
+ if (!m) return 'another model';
+ if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
+ return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
+}
+const ago = (ms) => {
+ const s = Math.max(0, Math.round(ms / 1000));
+ return s < 60 ? `${s} s` : s < 3600 ? `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}` : `${Math.floor(s / 3600)} h ${Math.round((s % 3600) / 60)} min`;
+};
+/** What a tool was asked, in a few words: its query, address or need, else its input as JSON. */
+function inputText(input) {
+ if (input == null) return '';
+ if (typeof input !== 'object') return String(input).slice(0, 160);
+ const v = input.query || input.url || input.need || input.place || input.symbol || input.expression || input.name || input.title;
+ if (v != null) return String(v).slice(0, 160);
+ // Otherwise its plain fields, as words: "to USD · from EUR · amount 7.6".
+ const plain = Object.entries(input).filter(([, x]) => x != null && typeof x !== 'object').map(([k, x]) => `${k.replace(/_/g, ' ')} ${x}`);
+ return (plain.length ? plain.join(' · ') : JSON.stringify(input)).slice(0, 160);
+}
+/**
+ * The answer's citations as chips: "[E3]" (or "[E3, E5]") becomes a numbered chip that opens what that tool returned,
+ * for the ids the ledger has; nothing inside code changes, and an id the ledger does not have stays as written.
+ */
+function chipify(htmlText, evidence) {
+ const known = new Map((evidence || []).map((e) => [String(e.id), e]));
+ if (!known.size) return htmlText;
+ const chip = (id) => {
+ const e = known.get(id);
+ const n = id.slice(1);
+ const label = `Evidence ${n}: ${toolLabel(e.tool)}${inputText(e.input) ? `, ${inputText(e.input)}` : ''}`.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
+ return `<a class="ev" href="#ev-${id}" data-ev="${id}" aria-label="${label}" title="${label}">${n}</a>`;
+ };
+ return String(htmlText).split(/(<pre[\s\S]*?<\/pre>|<code[\s\S]*?<\/code>)/).map((part, i) => (i % 2 ? part : part.replace(/\[(E\d{1,3}(?:\s*,\s*E\d{1,3})*)\]/g, (all, ids) => {
+ const list = ids.split(/\s*,\s*/);
+ return list.every((id) => known.has(id)) ? `<span class="evs">${list.map(chip).join('')}</span>` : all;
+ }))).join('');
+}
const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);
const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);
const priceText = (rc) => (rc.input_usd_per_mtok === 0 && rc.output_usd_per_mtok === 0 ? 'Free (OpenVibe hardware)' : `${perM(rc.input_usd_per_mtok)} in · ${perM(rc.output_usd_per_mtok)} out per 1M tokens${rc.per_call_usd ? ` · ${usd(rc.per_call_usd)} a search` : ''}`);
const priceText = (rc) => (rc.input_usd_per_mtok === 0 && rc.output_usd_per_mtok === 0 ? 'Free (OpenVibe hardware)' : `${perM(rc.input_usd_per_mtok)} in · ${perM(rc.output_usd_per_mtok)} out per 1M tokens${rc.per_call_usd ? ` · ${usd(rc.per_call_usd)} a search` : ''}`);
@@ -346,6 +398,9 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -346,6 +398,9 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const desk = open ? store.deskFrom(events) : null;
const desk = open ? store.deskFrom(events) : null;
const look = looks.get(row.id);
const look = looks.get(row.id);
+ const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
+ const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)
+ || (planEvent ? taskEvents.plan(planEvent.text) : null);
page(req, res, {
page(req, res, {
title: row.task.length > 60 ? `${row.task.slice(0, 59)}…` : row.task,
title: row.task.length > 60 ? `${row.task.slice(0, 59)}…` : row.task,
crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],
crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],
@@ -354,6 +409,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -354,6 +409,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
scripts: ['js/task-events.js', 'js/task.js'],
scripts: ['js/task-events.js', 'js/task.js'],
sharedScripts: ['agent-session.js'],
sharedScripts: ['agent-session.js'],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
+ css: ['css/task.css'],
noReferrer: true,
noReferrer: true,
body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">
<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">
@@ -362,6 +418,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -362,6 +418,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
<h1 class="task-text">${row.task}</h1>
<h1 class="task-text">${row.task}</h1>
</header>
</header>
${outcome(t, row)}
${outcome(t, row)}
+${open ? planCard(plan) : ''}
${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
<a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a>
<a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a>
@@ -371,6 +428,7 @@ ${open || look ? html`<figure class="last-look" id="last-look"${look ? '' : raw(
@@ -371,6 +428,7 @@ ${open || look ? html`<figure class="last-look" id="last-look"${look ? '' : raw(
<figcaption class="muted small">What your agent last saw on the screen${look ? html` · ${time(new Date(look.at).toISOString())}` : ''}</figcaption>
<figcaption class="muted small">What your agent last saw on the screen${look ? html` · ${time(new Date(look.at).toISOString())}` : ''}</figcaption>
</figure>` : ''}
</figure>` : ''}
<section class="how" aria-labelledby="h-how"><h2 id="h-how">How it went</h2>
<section class="how" aria-labelledby="h-how"><h2 id="h-how">How it went</h2>
+${!open && plan ? planCard(plan) : ''}
<div class="attempts" id="attempts">${attemptsOf(events).map(attemptBlock)}</div>
<div class="attempts" id="attempts">${attemptsOf(events).map(attemptBlock)}</div>
<script type="application/json" id="task-events">${raw(JSON.stringify(events.slice(-3000)).replace(/</g, '\\u003c'))}</script>
<script type="application/json" id="task-events">${raw(JSON.stringify(events.slice(-3000)).replace(/</g, '\\u003c'))}</script>
${open ? html`<p class="muted small live-note" id="live-note"><span class="dot-pulse" aria-hidden="true"></span> Updating as the agent works.</p>` : ''}
${open ? html`<p class="muted small live-note" id="live-note"><span class="dot-pulse" aria-hidden="true"></span> Updating as the agent works.</p>` : ''}
@@ -392,6 +450,20 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
@@ -392,6 +450,20 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
]))}</details>`;
]))}</details>`;
}
}
+ /**
+ * The plan the agent works through: each sub-goal done, in hand or next, and what it assumed. A live task's card is
+ * there before the first plan (hidden), so js/task.js can fill it as the plan arrives and changes.
+ */
+ function planCard(plan) {
+ const STATE = { done: 'done', active: 'working on it', todo: 'next' };
+ const steps = plan ? plan.steps : [];
+ return html`<section class="plan-card" id="plan-card" aria-labelledby="h-plan"${steps.length ? '' : raw(' hidden')}>
+<h2 id="h-plan">The plan</h2>
+<ol class="plan" id="plan-steps">${steps.map((st) => html`<li class="ps is-${STATE[st.state] ? st.state : 'todo'}"><span class="ps-mark" aria-hidden="true"></span><span class="ps-goal">${st.goal}</span><span class="sr-only"> (${STATE[st.state] || 'next'})</span></li>`)}</ol>
+<p class="plan-assume" id="plan-assume"${plan && plan.assumptions.length ? '' : raw(' hidden')}>Assuming ${plan ? plan.assumptions.join(' · ') : ''}</p>
+</section>`;
+ }
+
/** The outcome card: the first thing under the task. */
/** The outcome card: the first thing under the task. */
function outcome(t, row) {
function outcome(t, row) {
if (t.state === 'succeeded') return resultBlock(t.result, t);
if (t.state === 'succeeded') return resultBlock(t.result, t);
@@ -420,11 +492,26 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
@@ -420,11 +492,26 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
const checked = result.checked || {};
const checked = result.checked || {};
const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);
const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
+ const evidence = Array.isArray(result.evidence) ? result.evidence.filter((e) => e && /^E\d{1,3}$/.test(String(e.id))) : [];
+ const cited = new Set(Array.isArray(result.citations) ? result.citations.map(String) : []);
+ const took = t.finished_at && t.created_at ? Date.parse(t.finished_at) - Date.parse(t.created_at) : NaN;
+ const claims = Number(checked.claims) || 0;
+ const grounded = Math.min(claims, Number(checked.grounded) || 0);
+ const by = `${modelName(checked.by)}${checked.cross_family ? ', another model family' : ', the same family'}`;
+ const ext = (a) => (String(a.name || '').split('.').pop() || a.kind || 'file').slice(0, 5).toUpperCase();
return html`<section class="outcome outcome-ok answer" aria-labelledby="h-answer"><h2 id="h-answer" class="sr-only">Answer</h2>
return html`<section class="outcome outcome-ok answer" aria-labelledby="h-answer"><h2 id="h-answer" class="sr-only">Answer</h2>
-<div class="prose">${raw(markdown(result.answer, { headingOffset: 2 }))}</div>
-${artifacts.length ? html`<div class="sources artifacts"><h3>Files</h3><ul>${artifacts.map((a) => html`<li><a href="${a.url}" rel="nofollow noopener">${a.name}</a> <span class="muted">${a.kind} · ${bytes(a.size)}</span>${a.mime === 'image/svg+xml' ? html`<div><img src="${a.url}" alt="${a.name}" loading="lazy" style="max-width:100%;height:auto;margin-top:8px"></div>` : ''}</li>`)}</ul></div>` : ''}
+<div class="prose">${raw(chipify(markdown(result.answer, { headingOffset: 2 }), evidence))}</div>
+<p class="checked-line"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span>${claims ? `Checked: ${claims} claim${claims === 1 ? '' : 's'}, ${grounded} grounded` : 'Checked'} · by ${by}</span></p>
+${artifacts.length ? html`<div class="files"><h3>Files</h3><ul class="file-grid">${artifacts.map((a) => html`<li class="file-card">
+<a class="fc-preview" href="${a.url}" rel="nofollow noopener" tabindex="-1" aria-hidden="true">${/^image\/(svg\+xml|png|jpe?g|gif|webp)$/.test(a.mime) ? html`<img src="${a.url}" alt="" loading="lazy">` : html`<span class="fc-ext">${ext(a)}</span>`}</a>
+<span class="fc-meta"><a class="fc-name" href="${a.url}" rel="nofollow noopener">${a.name}</a><span class="muted">${a.kind === 'chart' ? 'Chart' : 'File'} · ${bytes(a.size)}</span></span></li>`)}</ul></div>` : ''}
${result.sources && result.sources.length ? html`<div class="sources"><h3>Sources</h3><ul>${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u.replace(/^https?:\/\//, '').slice(0, 90)}</a></li>`)}</ul></div>` : ''}
${result.sources && result.sources.length ? html`<div class="sources"><h3>Sources</h3><ul>${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u.replace(/^https?:\/\//, '').slice(0, 90)}</a></li>`)}</ul></div>` : ''}
-<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · <code>${result.model}</code>` : ''}</span><span>✓ checked by ${checked.by}${checked.cross_family ? ' (another model family)' : ' (same family)'}</span><span>${t.cost ? usd(t.cost.usd) : '$0'}</span></p>
+${evidence.length ? html`<details class="evidence" id="evidence"><summary><span>Evidence</span><span class="muted">${evidence.length} item${evidence.length === 1 ? '' : 's'}: what the tools returned${cited.size ? `, ${cited.size} cited` : ''}</span></summary>
+<ol class="ev-list">${evidence.map((e) => html`<li class="ev-item${cited.has(String(e.id)) ? ' is-cited' : ''}" id="ev-${e.id}">
+<p class="ev-head"><b class="ev-n">${String(e.id).slice(1)}</b><span class="ev-tool">${toolLabel(e.tool)}</span>${inputText(e.input) ? html`<span class="ev-input">${inputText(e.input)}</span>` : ''}${e.at ? html`<time class="ev-at" datetime="${e.at}">${String(e.at).slice(11, 16)} UTC</time>` : ''}</p>
+${e.text ? html`<p class="ev-text">${String(e.text).slice(0, 700)}${String(e.text).length > 700 ? '…' : ''}</p>` : ''}
+${Array.isArray(e.sources) && e.sources.length ? html`<ul class="ev-src">${e.sources.slice(0, 6).map((src) => html`<li><a href="${src.url}" rel="nofollow ugc noopener" target="_blank">${src.title || String(src.url).replace(/^https?:\/\//, '').slice(0, 80)}</a></li>`)}</ul>` : ''}</li>`)}</ol></details>` : ''}
+<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · ${modelName(result.model)}` : ''}</span><span>${t.cost ? usd(t.cost.usd) : '$0'} of ${usd(t.budget.per_task_usd)}</span>${Number.isFinite(took) && took > 0 ? html`<span>took ${ago(took)}</span>` : ''}</p>
</section>`;
</section>`;
}
}
@@ -439,7 +526,7 @@ ${result.sources && result.sources.length ? html`<div class="sources"><h3>Source
@@ -439,7 +526,7 @@ ${result.sources && result.sources.length ? html`<div class="sources"><h3>Source
let lastCheckFailed = false;
let lastCheckFailed = false;
for (const e of events) {
for (const e of events) {
if (e.kind !== 'output') continue;
if (e.kind !== 'output') continue;
- if ((e.step === 'plan' || e.step === 'handoff') && e.agent) {
+ if ((e.step === 'plan' || e.step === 'handoff') && e.agent && !(e.step === 'plan' && taskEvents.plan(e.text))) {
cur = { agent: e.agent, why: e.text, steps: [], refused: null, check: null, stopped: null };
cur = { agent: e.agent, why: e.text, steps: [], refused: null, check: null, stopped: null };
out.push(cur);
out.push(cur);
lastCheckFailed = false;
lastCheckFailed = false;
@@ -484,6 +571,7 @@ ${a.refused ? html`<details class="refused"><summary>See the answer that didn't
@@ -484,6 +571,7 @@ ${a.refused ? html`<details class="refused"><summary>See the answer that didn't
}
}
function stepItem(e) {
function stepItem(e) {
+ if (e.step === 'plan') { const p = taskEvents.plan(e.text); return html`<li class="tl-item tl-plan"><span class="tl-k">Plan</span><span class="tl-v">${p ? taskEvents.planLine(p) : e.text}</span></li>`; }
if (e.step === 'tool_call') return html`<li class="tl-item tl-tool"><span class="tl-k">Used</span><span class="tl-v"><code>${e.tool}</code> ${e.input || ''}</span></li>`;
if (e.step === 'tool_call') return html`<li class="tl-item tl-tool"><span class="tl-k">Used</span><span class="tl-v"><code>${e.tool}</code> ${e.input || ''}</span></li>`;
if (e.step === 'tool_result') return html`<li class="tl-item tl-result${e.is_error ? ' tl-err' : ''}"><span class="tl-k">Got</span><span class="tl-v"><span class="muted">${(e.text || '').slice(0, 220)}</span></span></li>`;
if (e.step === 'tool_result') return html`<li class="tl-item tl-result${e.is_error ? ' tl-err' : ''}"><span class="tl-k">Got</span><span class="tl-v"><span class="muted">${(e.text || '').slice(0, 220)}</span></span></li>`;
if (/^Needs you: /.test(e.text || '')) return html`<li class="tl-item tl-needs"><span class="tl-k">Needs you</span><span class="tl-v">${withDeskLinks(String(e.text).replace(/^Needs you: /, ''))}</span></li>`;
if (/^Needs you: /.test(e.text || '')) return html`<li class="tl-item tl-needs"><span class="tl-k">Needs you</span><span class="tl-v">${withDeskLinks(String(e.text).replace(/^Needs you: /, ''))}</span></li>`;
@@ -15,6 +15,7 @@ function slugName(raw) {
@@ -15,6 +15,7 @@ function slugName(raw) {
const base = String(raw || 'chart')
const base = String(raw || 'chart')
.replace(/[\\/]+/g, '-')
.replace(/[\\/]+/g, '-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
+ .replace(/-{2,}/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.replace(/^[-.]+|[-.]+$/g, '')
.replace(/\.[A-Za-z0-9]{1,8}$/, '')
.replace(/\.[A-Za-z0-9]{1,8}$/, '')
.slice(0, 100);
.slice(0, 100);
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// task never answered settles as done instead of spinning forever, identical consecutive calls fold into one line
// task never answered settles as done instead of spinning forever, identical consecutive calls fold into one line
// with a count, and the run's time is the task's own (first event → last event).
// with a count, and the run's time is the task's own (first event → last event).
const assert = require('assert');
const assert = require('assert');
-const { lines, firstAt, duration } = require('../public/js/task-events');
+const { lines, firstAt, duration, plan, planLine } = require('../public/js/task-events');
const at = (s) => `2026-10-30T10:00:${s}.000Z`;
const at = (s) => `2026-10-30T10:00:${s}.000Z`;
const call = (input, s, extra) => ({ kind: 'output', step: 'tool_call', tool: 'web_search', input, at: at(s), agent: 'openai-agent', ...extra });
const call = (input, s, extra) => ({ kind: 'output', step: 'tool_call', tool: 'web_search', input, at: at(s), agent: 'openai-agent', ...extra });
@@ -82,4 +82,19 @@ const end = (state, s) => ({ kind: 'end', state, at: at(s) });
@@ -82,4 +82,19 @@ const end = (state, s) => ({ kind: 'end', state, at: at(s) });
assert.strictEqual(duration([], { durationMs: 2500 }), 2500);
assert.strictEqual(duration([], { durationMs: 2500 }), 2500);
}
}
+// The plan the loop states (Actor v2, stage V2-5): read back exactly, a divider per step in hand, never a repeat.
+{
+ const stated = (k) => ['Assumptions: under $10; Europe', ...['Find three offers', 'Read the pages', 'Compare them'].map((g, i) => `${i < k ? '\u2713' : i === k ? '\u2192' : '\u00b7'} ${i + 1}. ${g}`)].join('\n');
+ assert.deepStrictEqual(plan(stated(1)), { assumptions: ['under $10', 'Europe'], steps: [
+ { goal: 'Find three offers', state: 'done' }, { goal: 'Read the pages', state: 'active' }, { goal: 'Compare them', state: 'todo' }] });
+ assert.strictEqual(plan('This is a "research" task.'), null, 'the engine\'s own words are not a plan');
+ assert.strictEqual(planLine(plan(stated(1))), 'Step 2 of 3 \u00b7 Read the pages');
+ assert.strictEqual(planLine(plan(stated(3))), 'Every step of the plan is done');
+ const ev = (t, sec) => ({ kind: 'output', step: 'plan', text: t, at: at(sec), agent: 'openvibe-runtime' });
+ const out = lines([ev(stated(0), '01'), call('offers', '02'), ev(stated(0), '03'), ev(stated(1), '04'), call('pages', '05'), ev(stated(3), '06')], { final: false });
+ assert.deepStrictEqual(out.filter((l) => l.t === 'step').map((l) => l.text), ['Step 1 of 3 \u00b7 Find three offers', 'Step 2 of 3 \u00b7 Read the pages', 'Every step of the plan is done'],
+ 'one divider per step in hand: the same plan stated twice draws nothing new');
+ assert.deepStrictEqual(lines([{ kind: 'output', step: 'plan', text: 'The agent starts with a plan, then works through it.', at: at('01') }]).map((l) => l.text), ['The agent starts with a plan, then works through it.'], 'other plan words stay as they are');
+}
+
console.log('task events: all checks passed');
console.log('task events: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// The task page v2 (Actor v2, stage V2-5): the answer's citations are chips that open what the tool returned, one calm
+// line says how many claims were checked and by whom, the files are cards (a chart drawn small), the plan shows each
+// step done, in hand or next (and a live task has the card ready for the plan to arrive), the cost is against the
+// budget with the time it took, and the plan updates never split the no-script view into one attempt each.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ t.config.loop.selfReflections = 0;
+ const s = t.ctx.s;
+ const { appendEvent } = require('../server/tasks/store');
+ const at = (sec) => new Date(Date.now() - sec * 1000).toISOString();
+
+ t.providers.state.script = [
+ { content: 'Charting it.', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'make_chart', arguments: JSON.stringify({ type: 'bar', title: 'Price -- per month', data: [{ label: 'A', value: 4.5 }, { label: 'B', value: 6.7 }] }) } }] },
+ { content: 'done' },
+ ];
+ const created = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Compare two offers and chart them' }, headers: SAME })).json();
+ await t.waitFor(created.id);
+ const row = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [created.id]);
+ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result;
+ result.answer = 'A is cheapest at €4.51 [E1], B is €6.74 [E2, E1]. Not a citation: `[E1]` and [E9].';
+ result.evidence = [
+ { id: 'E1', tool: 'read_page', input: { url: 'https://a.example/pricing' }, text: 'A · €4.51/month', sources: [{ url: 'https://a.example/pricing', title: 'A pricing' }], at: at(60) },
+ { id: 'E2', tool: 'search_web', input: { query: 'b price <script>' }, text: 'B · €6.74', sources: [], at: at(50) },
+ { id: 'E3', tool: 'calculate', input: { expression: '6.74-4.51' }, text: '2.23', sources: [], at: at(40) },
+ ];
+ result.citations = ['E1', 'E2'];
+ result.checked = { by: 'deepseek:deepseek-flash', cross_family: true, reason: 'ok', claims: 4, grounded: 4 };
+ result.plan = { assumptions: ['euro prices'], steps: [{ goal: 'Read both pages', state: 'done' }, { goal: 'Chart them', state: 'done' }] };
+ await s.db.query('UPDATE tasks SET result = $2::jsonb, cost_usd = 0.0124, created_at = $3, finished_at = $4 WHERE id = $1', [created.id, JSON.stringify(result), at(90), at(4)]);
+ const stated = ['\u2713 1. Read both pages', '\u2192 2. Chart them'].join('\n');
+ await appendEvent(s, created.id, { kind: 'output', step: 'plan', agent: result.agent, text: stated, at: at(30) });
+ await appendEvent(s, created.id, { kind: 'output', step: 'plan', agent: result.agent, text: stated.replace('\u2192', '\u2713'), at: at(20) });
+
+ try {
+ await check('citations become chips for the evidence the ledger has, and nothing else changes', async () => {
+ const r = await t.get(`/tasks/${created.id}`, { as: kim });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('<a class="ev" href="#ev-E1" data-ev="E1"'), 'a chip for E1');
+ assert.ok(/<span class="evs"><a class="ev" href="#ev-E2"[^>]*>2<\/a><a class="ev" href="#ev-E1"[^>]*>1<\/a><\/span>/.test(r.text), '[E2, E1] is two chips, in that order');
+ assert.ok(r.text.includes('<code>[E1]</code>'), 'a citation inside code stays code');
+ assert.ok(r.text.includes('[E9]'), 'an id the ledger does not have stays as written');
+ assert.ok(r.text.includes('aria-label="Evidence 2: Web search, b price <script>"'), 'the chip says what it opens, escaped');
+ assert.ok(!r.text.includes('<script>"'), 'nothing from a tool input is markup');
+ });
+
+ await check('the evidence behind the chips: each item, what it was asked, what it returned, its sources', async () => {
+ const r = await t.get(`/tasks/${created.id}`, { as: kim });
+ assert.ok(r.text.includes('<details class="evidence" id="evidence">'), 'one fold of evidence');
+ assert.ok(r.text.includes('3 items: what the tools returned, 2 cited'));
+ assert.ok(r.text.includes('<li class="ev-item is-cited" id="ev-E1">') && r.text.includes('<li class="ev-item" id="ev-E3">'), 'cited and uncited marked');
+ assert.ok(r.text.includes('<span class="ev-tool">Read a page</span>') && r.text.includes('https://a.example/pricing'), 'the tool by name, what it read');
+ assert.ok(r.text.includes('>A pricing</a>'), 'its sources');
+ });
+
+ await check('one calm check line, files as cards, the cost against the budget and the time it took', async () => {
+ const r = await t.get(`/tasks/${created.id}`, { as: kim });
+ assert.ok(r.text.includes('Checked: 4 claims, 4 grounded · by DeepSeek Flash, another model family'));
+ assert.ok(/<li class="file-card">[\s\S]*?<img src="[^"]+\/files\/art_[^"]+"/.test(r.text), 'the chart is drawn small on its card');
+ assert.ok(r.text.includes('Price-per-month.svg'), 'its name, without the runs of dashes a title leaves');
+ assert.ok(r.text.includes('<span>$0.01 of $0.05</span>') && /took 1 min 2\d s/.test(r.text), 'the cost against the budget and the time');
+ });
+
+ await check('the plan: every step and what it assumed, at the head of How it went once the task has ended', async () => {
+ const r = await t.get(`/tasks/${created.id}`, { as: kim });
+ const how = r.text.slice(r.text.indexOf('id="h-how"'));
+ assert.ok(how.includes('<section class="plan-card" id="plan-card"'), 'the plan card is under How it went');
+ assert.ok(how.includes('<li class="ps is-done"><span class="ps-mark" aria-hidden="true"></span><span class="ps-goal">Read both pages</span>'));
+ assert.ok(how.includes('Assuming euro prices'));
+ assert.strictEqual((r.text.match(/<section class="attempt"/g) || []).length, 1, 'plan updates do not split the no-script view into attempts');
+ assert.ok(r.text.includes('<li class="tl-item tl-plan"><span class="tl-k">Plan</span><span class="tl-v">Step 2 of 2 · Chart them</span>'), 'a plan update is a step of its attempt');
+ });
+
+ await check('a live task has the plan card ready, and it shows the plan as the agent states it', async () => {
+ const live = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Count to three' }, headers: SAME })).json();
+ await t.waitFor(live.id);
+ await s.db.query("UPDATE tasks SET state = 'running', result = NULL, finished_at = NULL WHERE id = $1", [live.id]);
+ let r = await t.get(`/tasks/${live.id}`, { as: kim });
+ assert.ok(/<section class="plan-card" id="plan-card" aria-labelledby="h-plan"( hidden)?>/.test(r.text), 'the card is there for js/task.js to fill');
+ await appendEvent(s, live.id, { kind: 'output', step: 'plan', agent: 'openvibe-runtime', text: '\u2192 1. Count\n\u00b7 2. Say it', at: at(1) });
+ r = await t.get(`/tasks/${live.id}`, { as: kim });
+ const head = r.text.slice(0, r.text.indexOf('id="h-how"'));
+ assert.ok(head.includes('<li class="ps is-active">') && head.includes('<li class="ps is-todo">'), 'above How it went while it runs: in hand, then next');
+ });
+
+ await check('another person still gets the 404 of a task that does not exist', async () => {
+ const r = await t.get(`/tasks/${created.id}`, { as: bo });
+ assert.strictEqual(r.status, 404);
+ assert.ok(!r.text.includes('ev-item'));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });