openvibers/openvibe.actor Commits
Actor's task page tells the truth about a run: search results, real time, folded duplicates, no empty answer bullet (#188)
Changes
@@@ -145,6 +145,8 @@ button.sc-btn.sc-primary { border: 0; }.tl-item { grid-template-columns: 1fr; gap: 2px; }.task-text { font-size: 1.25rem; }.outcome { padding: 14px; }++ /* A step's label says what differs on a phone: two lines, not one ellipsis that hides the query. */++ .task-session .ovas-sum b { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }}@media (prefers-reduced-motion: reduce) { .tl-item, .dot-pulse { animation: none; } .mode span { transition: none; } }nnew file mode 100644@@@ -0,0 +1,135 @@++/* Actor's task events (actor.task-event@1) as openvibe-shared/agent-session.js lines (1.1), for js/task.js.++ *++ * The shared view pairs a result with its call only by tool name, and times a step from when the page received it; on a++ * finished task that left a search running forever and the run's time reading "0 s". So the page says what the events++ * say instead:++ * - 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.++ */++(function (root, factory) {++ 'use strict';++ var api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ if (root) root.OpenVibeTaskEvents = api;++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++ var MAX_TEXT = 6000;++ var MAX_INPUT = 300;++++ function cut(value, n) { var s = String(value == null ? '' : value); return s.length > n ? s.slice(0, n - 1) + '…' : s; }++ function at(event) { var t = Date.parse(event && event.at); return Number.isFinite(t) ? t : null; }++ function inputOf(value) {++ if (typeof value === 'string') return cut(value, MAX_INPUT);++ if (value == null) return '';++ try { return cut(JSON.stringify(value), MAX_INPUT); } catch (err) { return cut(String(value), MAX_INPUT); }++ }++ function withAgent(line, event) { if (event.agent) line.agent = String(event.agent); return line; }++++ /** The instant the task's run started, from its first event (0 when it has none). */++ function firstAt(events) {++ for (var i = 0; i < (events || []).length; i++) { var t = at(events[i]); if (t != null) return t; }++ return 0;++ }++++ /** The run's real time: the first and last event, or opts.durationMs when the caller knows it. */++ function duration(events, opts) {++ if (opts && opts.durationMs != null) return Math.max(0, Number(opts.durationMs) || 0);++ var first = null, last = null;++ for (var i = 0; i < (events || []).length; i++) {++ var t = at(events[i]);++ if (t == null) continue;++ if (first == null || t < first) first = t;++ if (last == null || t > last) last = t;++ }++ return first == null || last == null ? 0 : Math.max(0, last - first);++ }++++ /**++ * events → lines. opts.final says the task has ended (settle unanswered calls, add the done line with the run's++ * time); opts.state is its final state (the done line is ok only when "succeeded"); opts.durationMs overrides the++ * computed time.++ */++ function lines(events, opts) {++ opts = opts || {};++ var out = [];++ 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 folded = null; // the result of a folded call, which must not become a step of its own++ var n = 0;++++ function settle(call, tool) {++ out.push({ t: 'tool_result', id: call.id, name: tool, ok: true, output: 'No result was recorded for this step.' });++ }++++ for (var i = 0; i < (events || []).length; i++) {++ var e = events[i];++ if (!e || typeof e !== 'object') continue;++ if (e.kind === 'state' || e.kind === 'end') {++ if (!opts.final && e.state) out.push({ t: 'state', state: String(e.state) });++ continue;++ }++ if (e.kind !== 'output') continue;++ var text = cut(e.text, MAX_TEXT);++++ if (e.step === 'tool_call') {++ var tool = String(e.tool || 'tool');++ var input = inputOf(e.input);++ folded = null; // a new call closes the one a duplicate expected to fold into++ if (last && last.tool === tool && last.input === input) {++ last.n++;++ var label = input ? input + ' ×' + last.n : '×' + last.n;++ last.line.what = label;++ last.line.input = { description: label };++ folded = { tool: tool };++ continue;++ }++ var call = withAgent({ t: 'tool', id: 't' + (++n), name: tool, what: input, input: { description: input } }, e);++ out.push(call);++ last = { tool: tool, input: input, line: call, n: 1, at: at(e) };++ (open[tool] = open[tool] || []).push({ id: call.id, at: last.at });++ continue;++ }++++ if (e.step === 'tool_result') {++ var resultTool = String(e.tool || 'tool');++ if (folded && folded.tool === resultTool) { folded = null; continue; }++ folded = null;++ var queue = open[resultTool];++ var waiting = queue && queue.length ? queue.shift() : null;++ if (!waiting) continue; // a result without its call (a page cut short) is not a step++ var line = withAgent({ t: 'tool_result', id: waiting.id, name: resultTool, ok: !e.is_error, output: cut(e.text, 8000) }, e);++ var seen = at(e);++ if (seen != null && waiting.at != null && seen > waiting.at) line.duration_ms = seen - waiting.at;++ out.push(line);++ continue;++ }++++ // Anything the agent said between two calls ends the "same call twice" run: a repeat after an answer is a++ // real second try. A result does not: the pairs of a repeated search are call, result, call, result.++ last = null;++ if (e.step === 'text') {++ 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 === '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));++ }++++ if (opts.final) {++ for (var name in open) {++ if (!Object.prototype.hasOwnProperty.call(open, name)) continue;++ open[name].forEach(function (call) { settle(call, name); });++ }++ var ms = duration(events, opts);++ if (ms > 0) out.push({ t: 'done', ok: opts.state ? String(opts.state) === 'succeeded' : true, duration_ms: ms, cost_usd: 0 });++ }++ return out;++ }++++ return { lines: lines, firstAt: firstAt, duration: duration };++});@@@ -29,10 +29,15 @@// Only the person's own desk becomes a link (Desks plan D4): an agent reads untrusted pages, so any other address stays text.var botRe = (art.getAttribute('data-bot') || 'https://openvibe.bot').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');var ownDesk = new RegExp('^' + botRe + '/machines/dev_[0-9A-HJKMNP-TV-Z]{26}/screen(?:\\?desk=[a-z0-9-]{1,40})?$');-- var view = window.OpenVibeAgentSession.mount(host, { title: (document.querySelector('.task-text') || {}).textContent || 'Task', status: art.getAttribute('data-open') === '1' ? 'running' : 'queued', follow: art.getAttribute('data-open') === '1', empty: 'Waiting for an agent to start…', link: function (u) { return ownDesk.test(u); } });-- view.pushAll(events);-- var pill = art.className.match(/state-([a-z_]+)/);-- if (pill && art.getAttribute('data-open') !== '1') view.status(pill[1] === 'done' ? 'succeeded' : pill[1]);++ var open = art.getAttribute('data-open') === '1';++ var state = (art.className.match(/state-([a-z_]+)/) || [])[1] || (open ? 'running' : 'succeeded');++ if (state === 'done') state = 'succeeded';++ // A finished task mounts finished: mounting it as queued started a clock at page load, which read "0 s". Its run++ // time comes from the events (task-events.js done line); a live one clocks from when its run really started.++ var lines = window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.lines(events, { final: !open, state: state }) : events;++ var startedAt = open && window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.firstAt(events) : 0;++ var view = window.OpenVibeAgentSession.mount(host, { title: (document.querySelector('.task-text') || {}).textContent || 'Task', status: open ? 'running' : state, startedAt: startedAt || undefined, follow: open, empty: 'Waiting for an agent to start…', link: function (u) { return ownDesk.test(u); } });++ view.pushAll(lines);return view;}@@@ -53,6 +53,30 @@ const summarize = (args) => {return String(v).slice(0, 300);};++/**++ * What a hosted web search turned up, for its step's result: the query and the sources the answer cites, with the++ * titles the Responses output gives them (a search item itself carries the query only).++ */++const searchFound = (query, sources, citations) => {++ const found = [];++ const seen = new Set();++ for (const c of citations || []) {++ if (!c || !c.url || seen.has(c.url)) continue;++ seen.add(c.url);++ found.push({ title: String(c.title || ''), url: String(c.url) });++ }++ for (const u of sources || []) {++ const url = String(u || '');++ if (!url || seen.has(url)) continue;++ seen.add(url);++ found.push({ title: '', url });++ }++ const head = query ? `Searched the web for "${query}".` : 'Searched the web.';++ if (!found.length) return `${head} The answer cites no sources.`;++ const lines = found.slice(0, 8).map((s) => `- ${s.title ? `${s.title}: ` : ''}${s.url}`);++ return `${head} The answer cites ${found.length} source${found.length === 1 ? '' : 's'}:\n${lines.join('\n')}`.slice(0, 1200);++};++function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride, codes: codesOverride }) {const p = config.providers;// The runtime reads a computer's screen through a vision model (agents/tools.js computer_screen): OpenAI's.@@@ -117,7 +141,13 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conawait emit({ step: 'text', text: 'Searching the web…' });const r = await openaiAgent({ instructions: AGENT_INSTRUCTIONS(), input: task, effort: mode === 'best' ? 'medium' : 'low', signal });await meter(r.cost_usd);-- for (const s of r.searches) await emit({ step: 'tool_call', tool: s.kind === 'search' ? 'web_search' : `web_${s.kind}`, input: String(s.query || '').slice(0, 300) });++ // Each search is a step with both ends: without its result the shared step view shows it running forever.++ for (const s of r.searches) {++ const tool = s.kind === 'search' ? 'web_search' : `web_${s.kind}`;++ const query = String(s.query || '').slice(0, 300);++ await emit({ step: 'tool_call', tool, input: query });++ await emit({ step: 'tool_result', tool, text: searchFound(query, r.sources, r.citations) });++ }if (!r.text) throw new ProviderError('the agent finished without an answer', { code: 'actor.agent.no_answer' });return { text: r.text, sources: r.sources, model: r.model };}@@@ -88,6 +88,21 @@ function sourcesOf(output, text) {return [...new Set(urls.map((u) => u.replace(/\?utm_source=openai$/, '')))].slice(0, 20);}++/** The answer's citations with their titles, as the Responses output carries them (url_citation annotations). */++function citationsOf(output) {++ const out = [];++ for (const item of output || []) {++ if (item.type !== 'message') continue;++ for (const c of item.content || []) {++ for (const a of c.annotations || []) {++ if (!a || a.type !== 'url_citation' || !a.url) continue;++ out.push({ url: String(a.url).replace(/\?utm_source=openai$/, ''), title: String(a.title || '') });++ }++ }++ }++ return out;++}++function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.fetch, timeoutMs = 150_000 }) {return async function run({ instructions, input, effort = 'low', maxOutputTokens = 4000, signal } = {}) {const body = { model, instructions, input, tools: [{ type: 'web_search' }], reasoning: { effort }, max_output_tokens: maxOutputTokens };@@@ -98,8 +113,8 @@ function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.const u = j.usage || {};const usage = { input: u.input_tokens || 0, cached: (u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0, output: u.output_tokens || 0, calls: searches.filter((s) => s.kind === 'search').length };if (j.status && j.status !== 'completed' && !text) throw new ProviderError(`the agent stopped (${j.status}${j.incomplete_details ? `: ${j.incomplete_details.reason}` : ''})`);-- return { text, sources: sourcesOf(output, text), searches, model: j.model || model, usage, cost_usd: priceUsage(rates, usage) };++ return { text, sources: sourcesOf(output, text), citations: citationsOf(output), searches, model: j.model || model, usage, cost_usd: priceUsage(rates, usage) };};}--module.exports = { chatClient, responsesAgent, priceUsage, sourcesOf, ProviderError };++module.exports = { chatClient, responsesAgent, priceUsage, sourcesOf, citationsOf, ProviderError };@@@ -335,7 +335,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],// How it went is the agents' session (openvibe-shared/agent-session.js, js/task.js mounts it from the events// below and, while the task runs, from its live stream); the attempts drawn here stay for a page without scripts.-- scripts: ['js/task.js'],++ scripts: ['js/task-events.js', 'js/task.js'],sharedScripts: ['agent-session.js'],styles: [showcase.STYLESHEET],noReferrer: true,@@@ -33,7 +33,7 @@ function markdown(src, { headingOffset = 1 } = {}) {let list = null; // { type: 'ul' | 'ol', items: [] }let fence = null;const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };-- const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };++ const flushList = () => { if (list && list.items.length) out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; };for (let i = 0; i < lines.length; i++) {const line = lines[i];if (fence) {@@@ -59,16 +59,19 @@ function markdown(src, { headingOffset = 1 } = {}) {out.push(`<h${level}>${inline(h[2])}</h${level}>`);continue;}++ // A model that ends on a bare marker ("-" or "1.") or an empty item ("- ") leaves an empty bullet otherwise.++ if (/^\s*(?:[-*]|\d+\.)\s*$/.test(line)) continue;const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);if (li) {flushPara();++ if (!li[2].trim()) continue;const type = li[1] ? 'ol' : 'ul';if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }list.items.push(li[2]);continue;}if (!line.trim()) { flushPara(); flushList(); continue; }-- if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }++ if (list && list.items.length && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }flushList();para.push(line.trim());}@@@ -71,7 +71,7 @@ async function startProviders() {status: 'completed', model: 'gpt-5-mini-stand-in',output: [{ type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'stand-in search' } },-- { type: 'message', content: [{ type: 'output_text', text: `Web answer to: ${String(body.input).slice(0, 200)} (see https://example.org/source)`, annotations: [{ type: 'url_citation', url: 'https://example.org/source' }] }] },++ { type: 'message', content: [{ type: 'output_text', text: `Web answer to: ${String(body.input).slice(0, 200)} (see https://example.org/source)`, annotations: [{ type: 'url_citation', url: 'https://example.org/source', title: 'Example source' }] }] },],usage: { input_tokens: 2000, output_tokens: 200, input_tokens_details: { cached_tokens: 0 } },}));nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++// An answer's render leaves no empty bullet: a model that ends on a bare list marker ("-" or "1.") or writes a blank++// item ("- ") must not draw a lone "•" after the answer (the owner's screenshot, 2026-10-30). The rest of the render++// is unchanged: real lists still render, an item's text still inline.++const assert = require('assert');++const { markdown, inline } = require('../server/render/markdown');++++assert.strictEqual(markdown('The answer.\n- '), '<p>The answer.</p>', 'a trailing empty item draws nothing');++assert.strictEqual(markdown('The answer.\n-'), '<p>The answer.</p>', 'a trailing bare marker draws nothing');++assert.strictEqual(markdown('The answer.\n1. '), '<p>The answer.</p>', 'a trailing empty ordered item draws nothing');++assert.strictEqual(markdown('- '), '', 'an all-empty list is not a list');++assert.strictEqual(markdown('- \n* \n1. '), '', 'empty items and bare markers leave nothing');++assert.strictEqual(markdown('- \n- second'), '<ul><li>second</li></ul>', 'a blank first item does not hide the next');++assert.strictEqual(markdown('- one\n-\n- two'), '<ul><li>one</li><li>two</li></ul>', 'a bare marker inside a list is skipped');++assert.strictEqual(markdown('- one\n- \n- two'), '<ul><li>one</li><li>two</li></ul>', 'a blank item inside a list is skipped');++assert.strictEqual(markdown('1. one\n2. two'), '<ol><li>one</li><li>two</li></ol>', 'a real ordered list renders');++assert.strictEqual(markdown('- **bold** item'), '<ul><li><strong>bold</strong> item</li></ul>', 'items keep their inline Markdown');++assert.strictEqual(markdown('Step one\n\n- step\n\nDone.'), '<p>Step one</p>\n<ul><li>step</li></ul>\n<p>Done.</p>', 'the render around lists is unchanged');++assert.strictEqual(inline('- not a list'), '- not a list');++++console.log('markdown: all checks passed');nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++// The task page's event mapping (public/js/task-events.js): a search's result lands on its call, a call a finished++// 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 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 result = (text, s, extra) => ({ kind: 'output', step: 'tool_result', tool: 'web_search', text, at: at(s), agent: 'openai-agent', ...extra });++const text = (t, s) => ({ kind: 'output', step: 'text', text: t, at: at(s), agent: 'openai-agent' });++const end = (state, s) => ({ kind: 'end', state, at: at(s) });++++// A finished hosted search: both calls are answered, the repeated one folds into "×2", the sources are in the result.++{++ const events = [++ text('Searching the web…', '00'),++ call('Eiffel Tower height', '02'), result('Searched the web for "Eiffel Tower height". The answer cites 1 source:\n- https://en.wikipedia.org/wiki/Eiffel_Tower', '04'),++ call('Eiffel Tower height', '05'), result('Searched the web for "Eiffel Tower height". The answer cites 1 source:\n- https://en.wikipedia.org/wiki/Eiffel_Tower', '07'),++ text('The Eiffel Tower is **330 m** tall.', '08'),++ end('succeeded', '09'),++ ];++ const out = lines(events, { final: true, state: 'succeeded' });++ const tools = out.filter((l) => l.t === 'tool');++ const results = out.filter((l) => l.t === 'tool_result');++ const done = out.filter((l) => l.t === 'done');++ assert.strictEqual(tools.length, 1, 'identical consecutive calls fold into one line');++ assert.strictEqual(tools[0].what, 'Eiffel Tower height ×2');++ assert.deepStrictEqual(tools[0].input, { description: 'Eiffel Tower height ×2' });++ assert.strictEqual(results.length, 1, 'the folded call does not leave a second result');++ assert.strictEqual(results[0].id, tools[0].id, 'a result lands on its own call');++ assert.match(results[0].output, /en\.wikipedia\.org/);++ assert.strictEqual(results[0].duration_ms, 2000, 'the step keeps the time between its call and result');++ assert.strictEqual(done.length, 1);++ assert.strictEqual(done[0].duration_ms, 9000, 'the run time is the first and last event');++ assert.strictEqual(done[0].ok, true);++ assert.ok(!out.some((l) => l.t === 'state'), 'a finished page does not replay states over its final one');++}++++// A call a finished task never answered: done with "No result", never a spinner.++{++ const out = lines([call('Eiffel Tower height', '02'), end('failed', '30')], { final: true, state: 'failed' });++ const results = out.filter((l) => l.t === 'tool_result');++ assert.strictEqual(results.length, 1, 'an unanswered call settles when the task has ended');++ assert.strictEqual(results[0].ok, true);++ assert.match(results[0].output, /No result was recorded/);++ assert.strictEqual(out.filter((l) => l.t === 'done')[0].ok, false, 'a failed task says so on its done line');++}++++// While the task runs the same call is still running: no invented result, no done line, states replay.++{++ const out = lines([{ kind: 'state', state: 'running', at: at('00') }, call('Eiffel Tower height', '02')], { final: false });++ assert.strictEqual(out.filter((l) => l.t === 'tool_result').length, 0);++ assert.strictEqual(out.filter((l) => l.t === 'done').length, 0);++ assert.deepStrictEqual(out.filter((l) => l.t === 'state').map((l) => l.state), ['running']);++}++++// Identical calls separated by what the agent said are two tries, not one.++{++ const out = lines([call('same', '02'), result('one', '03'), text('Let me check again.', '04'), call('same', '05'), result('two', '06')], { final: true, state: 'succeeded' });++ assert.strictEqual(out.filter((l) => l.t === 'tool').length, 2);++ assert.strictEqual(out.filter((l) => l.t === 'tool_result').length, 2);++}++++// A different tool or input is a different line; a result with no call is dropped.++{++ const out = lines([++ call('a', '02'), { kind: 'output', step: 'tool_call', tool: 'run_tool', input: 'mx', at: at('03') },++ { kind: 'output', step: 'tool_result', tool: 'run_tool', text: 'ok', at: at('05') },++ { kind: 'output', step: 'tool_result', tool: 'ghost', text: 'orphan', at: at('06') },++ ], { final: false });++ assert.deepStrictEqual(out.filter((l) => l.t === 'tool').map((l) => l.name), ['web_search', 'run_tool']);++ assert.strictEqual(out.filter((l) => l.t === 'tool_result').length, 1, 'only the call that exists gets a result');++}++++// Time helpers: first event and the span; 0 rather than a made-up clock when the events carry no time.++{++ assert.strictEqual(firstAt([{ at: at('03') }, { at: at('01') }]), Date.parse(at('03')));++ assert.strictEqual(firstAt([]), 0);++ assert.strictEqual(duration([{ at: at('01') }, { at: at('11') }]), 10000);++ assert.strictEqual(duration([{ at: at('01') }]), 0);++ assert.strictEqual(duration([], { durationMs: 2500 }), 2500);++}++++console.log('task events: all checks passed');@@@ -54,6 +54,17 @@ const SAME = { 'sec-fetch-site': 'same-origin' };assert.strictEqual(task.result.agent, 'openai-agent');assert.deepStrictEqual(task.result.sources, ['https://example.org/source']);assert.strictEqual(task.result.checked.by, 'deepseek');++ // Every hosted search is a step with both ends, and its result says what it found (a spinner otherwise).++ const stream = (await t.get(`/api/v1/tasks/${id}/events`, { as: kim })).text;++ const steps = [...stream.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1]));++ const searches = steps.filter((e) => e.step === 'tool_call' && e.tool === 'web_search');++ const found = steps.filter((e) => e.step === 'tool_result' && e.tool === 'web_search');++ assert.ok(searches.length > 0, 'the hosted search is a step');++ assert.strictEqual(found.length, searches.length, 'each search has its result');++ assert.match(found[0].text, /stand-in search/, 'the result names the query');++ assert.match(found[0].text, /Example source: https:\/\/example\.org\/source/, 'and the sources with their titles');++ const pageHtml = (await t.get(`/tasks/${id}`, { as: kim })).text;++ assert.match(pageHtml, /js\/task-events\.js/, 'the task page maps its events for the session view');// 2000 in + 200 out at gpt-5-mini plus one search, plus the classifier and the checker on DeepSeek.assert.ok(task.cost.usd > 0.0105 && task.cost.usd < 0.012, String(task.cost.usd));});
@@ -145,6 +145,8 @@ button.sc-btn.sc-primary { border: 0; }
@@ -145,6 +145,8 @@ button.sc-btn.sc-primary { border: 0; }
.tl-item { grid-template-columns: 1fr; gap: 2px; }
.tl-item { grid-template-columns: 1fr; gap: 2px; }
.task-text { font-size: 1.25rem; }
.task-text { font-size: 1.25rem; }
.outcome { padding: 14px; }
.outcome { padding: 14px; }
+ /* A step's label says what differs on a phone: two lines, not one ellipsis that hides the query. */
+ .task-session .ovas-sum b { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
}
}
@media (prefers-reduced-motion: reduce) { .tl-item, .dot-pulse { animation: none; } .mode span { transition: none; } }
@media (prefers-reduced-motion: reduce) { .tl-item, .dot-pulse { animation: none; } .mode span { transition: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+/* Actor's task events (actor.task-event@1) as openvibe-shared/agent-session.js lines (1.1), for js/task.js.
+ *
+ * The shared view pairs a result with its call only by tool name, and times a step from when the page received it; on a
+ * finished task that left a search running forever and the run's time reading "0 s". So the page says what the events
+ * say instead:
+ * - 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.
+ */
+(function (root, factory) {
+ 'use strict';
+ var api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ if (root) root.OpenVibeTaskEvents = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+ var MAX_TEXT = 6000;
+ var MAX_INPUT = 300;
+
+ function cut(value, n) { var s = String(value == null ? '' : value); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
+ function at(event) { var t = Date.parse(event && event.at); return Number.isFinite(t) ? t : null; }
+ function inputOf(value) {
+ if (typeof value === 'string') return cut(value, MAX_INPUT);
+ if (value == null) return '';
+ try { return cut(JSON.stringify(value), MAX_INPUT); } catch (err) { return cut(String(value), MAX_INPUT); }
+ }
+ function withAgent(line, event) { if (event.agent) line.agent = String(event.agent); return line; }
+
+ /** The instant the task's run started, from its first event (0 when it has none). */
+ function firstAt(events) {
+ for (var i = 0; i < (events || []).length; i++) { var t = at(events[i]); if (t != null) return t; }
+ return 0;
+ }
+
+ /** The run's real time: the first and last event, or opts.durationMs when the caller knows it. */
+ function duration(events, opts) {
+ if (opts && opts.durationMs != null) return Math.max(0, Number(opts.durationMs) || 0);
+ var first = null, last = null;
+ for (var i = 0; i < (events || []).length; i++) {
+ var t = at(events[i]);
+ if (t == null) continue;
+ if (first == null || t < first) first = t;
+ if (last == null || t > last) last = t;
+ }
+ return first == null || last == null ? 0 : Math.max(0, last - first);
+ }
+
+ /**
+ * events → lines. opts.final says the task has ended (settle unanswered calls, add the done line with the run's
+ * time); opts.state is its final state (the done line is ok only when "succeeded"); opts.durationMs overrides the
+ * computed time.
+ */
+ function lines(events, opts) {
+ opts = opts || {};
+ var out = [];
+ 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 folded = null; // the result of a folded call, which must not become a step of its own
+ var n = 0;
+
+ function settle(call, tool) {
+ out.push({ t: 'tool_result', id: call.id, name: tool, ok: true, output: 'No result was recorded for this step.' });
+ }
+
+ for (var i = 0; i < (events || []).length; i++) {
+ var e = events[i];
+ if (!e || typeof e !== 'object') continue;
+ if (e.kind === 'state' || e.kind === 'end') {
+ if (!opts.final && e.state) out.push({ t: 'state', state: String(e.state) });
+ continue;
+ }
+ if (e.kind !== 'output') continue;
+ var text = cut(e.text, MAX_TEXT);
+
+ if (e.step === 'tool_call') {
+ var tool = String(e.tool || 'tool');
+ var input = inputOf(e.input);
+ folded = null; // a new call closes the one a duplicate expected to fold into
+ if (last && last.tool === tool && last.input === input) {
+ last.n++;
+ var label = input ? input + ' ×' + last.n : '×' + last.n;
+ last.line.what = label;
+ last.line.input = { description: label };
+ folded = { tool: tool };
+ continue;
+ }
+ var call = withAgent({ t: 'tool', id: 't' + (++n), name: tool, what: input, input: { description: input } }, e);
+ out.push(call);
+ last = { tool: tool, input: input, line: call, n: 1, at: at(e) };
+ (open[tool] = open[tool] || []).push({ id: call.id, at: last.at });
+ continue;
+ }
+
+ if (e.step === 'tool_result') {
+ var resultTool = String(e.tool || 'tool');
+ if (folded && folded.tool === resultTool) { folded = null; continue; }
+ folded = null;
+ var queue = open[resultTool];
+ var waiting = queue && queue.length ? queue.shift() : null;
+ if (!waiting) continue; // a result without its call (a page cut short) is not a step
+ var line = withAgent({ t: 'tool_result', id: waiting.id, name: resultTool, ok: !e.is_error, output: cut(e.text, 8000) }, e);
+ var seen = at(e);
+ if (seen != null && waiting.at != null && seen > waiting.at) line.duration_ms = seen - waiting.at;
+ out.push(line);
+ continue;
+ }
+
+ // Anything the agent said between two calls ends the "same call twice" run: a repeat after an answer is a
+ // real second try. A result does not: the pairs of a repeated search are call, result, call, result.
+ last = null;
+ if (e.step === 'text') {
+ 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 === '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));
+ }
+
+ if (opts.final) {
+ for (var name in open) {
+ if (!Object.prototype.hasOwnProperty.call(open, name)) continue;
+ open[name].forEach(function (call) { settle(call, name); });
+ }
+ var ms = duration(events, opts);
+ if (ms > 0) out.push({ t: 'done', ok: opts.state ? String(opts.state) === 'succeeded' : true, duration_ms: ms, cost_usd: 0 });
+ }
+ return out;
+ }
+
+ return { lines: lines, firstAt: firstAt, duration: duration };
+});
@@ -29,10 +29,15 @@
@@ -29,10 +29,15 @@
// Only the person's own desk becomes a link (Desks plan D4): an agent reads untrusted pages, so any other address stays text.
// Only the person's own desk becomes a link (Desks plan D4): an agent reads untrusted pages, so any other address stays text.
var botRe = (art.getAttribute('data-bot') || 'https://openvibe.bot').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var botRe = (art.getAttribute('data-bot') || 'https://openvibe.bot').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var ownDesk = new RegExp('^' + botRe + '/machines/dev_[0-9A-HJKMNP-TV-Z]{26}/screen(?:\\?desk=[a-z0-9-]{1,40})?$');
var ownDesk = new RegExp('^' + botRe + '/machines/dev_[0-9A-HJKMNP-TV-Z]{26}/screen(?:\\?desk=[a-z0-9-]{1,40})?$');
- var view = window.OpenVibeAgentSession.mount(host, { title: (document.querySelector('.task-text') || {}).textContent || 'Task', status: art.getAttribute('data-open') === '1' ? 'running' : 'queued', follow: art.getAttribute('data-open') === '1', empty: 'Waiting for an agent to start…', link: function (u) { return ownDesk.test(u); } });
- view.pushAll(events);
- var pill = art.className.match(/state-([a-z_]+)/);
- if (pill && art.getAttribute('data-open') !== '1') view.status(pill[1] === 'done' ? 'succeeded' : pill[1]);
+ var open = art.getAttribute('data-open') === '1';
+ var state = (art.className.match(/state-([a-z_]+)/) || [])[1] || (open ? 'running' : 'succeeded');
+ if (state === 'done') state = 'succeeded';
+ // A finished task mounts finished: mounting it as queued started a clock at page load, which read "0 s". Its run
+ // time comes from the events (task-events.js done line); a live one clocks from when its run really started.
+ var lines = window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.lines(events, { final: !open, state: state }) : events;
+ var startedAt = open && window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.firstAt(events) : 0;
+ var view = window.OpenVibeAgentSession.mount(host, { title: (document.querySelector('.task-text') || {}).textContent || 'Task', status: open ? 'running' : state, startedAt: startedAt || undefined, follow: open, empty: 'Waiting for an agent to start…', link: function (u) { return ownDesk.test(u); } });
+ view.pushAll(lines);
return view;
return view;
}
}
@@ -53,6 +53,30 @@ const summarize = (args) => {
@@ -53,6 +53,30 @@ const summarize = (args) => {
return String(v).slice(0, 300);
return String(v).slice(0, 300);
};
};
+/**
+ * What a hosted web search turned up, for its step's result: the query and the sources the answer cites, with the
+ * titles the Responses output gives them (a search item itself carries the query only).
+ */
+const searchFound = (query, sources, citations) => {
+ const found = [];
+ const seen = new Set();
+ for (const c of citations || []) {
+ if (!c || !c.url || seen.has(c.url)) continue;
+ seen.add(c.url);
+ found.push({ title: String(c.title || ''), url: String(c.url) });
+ }
+ for (const u of sources || []) {
+ const url = String(u || '');
+ if (!url || seen.has(url)) continue;
+ seen.add(url);
+ found.push({ title: '', url });
+ }
+ const head = query ? `Searched the web for "${query}".` : 'Searched the web.';
+ if (!found.length) return `${head} The answer cites no sources.`;
+ const lines = found.slice(0, 8).map((s) => `- ${s.title ? `${s.title}: ` : ''}${s.url}`);
+ return `${head} The answer cites ${found.length} source${found.length === 1 ? '' : 's'}:\n${lines.join('\n')}`.slice(0, 1200);
+};
+
function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride, codes: codesOverride }) {
function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride, codes: codesOverride }) {
const p = config.providers;
const p = config.providers;
// The runtime reads a computer's screen through a vision model (agents/tools.js computer_screen): OpenAI's.
// The runtime reads a computer's screen through a vision model (agents/tools.js computer_screen): OpenAI's.
@@ -117,7 +141,13 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -117,7 +141,13 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
await emit({ step: 'text', text: 'Searching the web…' });
await emit({ step: 'text', text: 'Searching the web…' });
const r = await openaiAgent({ instructions: AGENT_INSTRUCTIONS(), input: task, effort: mode === 'best' ? 'medium' : 'low', signal });
const r = await openaiAgent({ instructions: AGENT_INSTRUCTIONS(), input: task, effort: mode === 'best' ? 'medium' : 'low', signal });
await meter(r.cost_usd);
await meter(r.cost_usd);
- for (const s of r.searches) await emit({ step: 'tool_call', tool: s.kind === 'search' ? 'web_search' : `web_${s.kind}`, input: String(s.query || '').slice(0, 300) });
+ // Each search is a step with both ends: without its result the shared step view shows it running forever.
+ for (const s of r.searches) {
+ const tool = s.kind === 'search' ? 'web_search' : `web_${s.kind}`;
+ const query = String(s.query || '').slice(0, 300);
+ await emit({ step: 'tool_call', tool, input: query });
+ await emit({ step: 'tool_result', tool, text: searchFound(query, r.sources, r.citations) });
+ }
if (!r.text) throw new ProviderError('the agent finished without an answer', { code: 'actor.agent.no_answer' });
if (!r.text) throw new ProviderError('the agent finished without an answer', { code: 'actor.agent.no_answer' });
return { text: r.text, sources: r.sources, model: r.model };
return { text: r.text, sources: r.sources, model: r.model };
}
}
@@ -88,6 +88,21 @@ function sourcesOf(output, text) {
@@ -88,6 +88,21 @@ function sourcesOf(output, text) {
return [...new Set(urls.map((u) => u.replace(/\?utm_source=openai$/, '')))].slice(0, 20);
return [...new Set(urls.map((u) => u.replace(/\?utm_source=openai$/, '')))].slice(0, 20);
}
}
+/** The answer's citations with their titles, as the Responses output carries them (url_citation annotations). */
+function citationsOf(output) {
+ const out = [];
+ for (const item of output || []) {
+ if (item.type !== 'message') continue;
+ for (const c of item.content || []) {
+ for (const a of c.annotations || []) {
+ if (!a || a.type !== 'url_citation' || !a.url) continue;
+ out.push({ url: String(a.url).replace(/\?utm_source=openai$/, ''), title: String(a.title || '') });
+ }
+ }
+ }
+ return out;
+}
+
function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.fetch, timeoutMs = 150_000 }) {
function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.fetch, timeoutMs = 150_000 }) {
return async function run({ instructions, input, effort = 'low', maxOutputTokens = 4000, signal } = {}) {
return async function run({ instructions, input, effort = 'low', maxOutputTokens = 4000, signal } = {}) {
const body = { model, instructions, input, tools: [{ type: 'web_search' }], reasoning: { effort }, max_output_tokens: maxOutputTokens };
const body = { model, instructions, input, tools: [{ type: 'web_search' }], reasoning: { effort }, max_output_tokens: maxOutputTokens };
@@ -98,8 +113,8 @@ function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.
@@ -98,8 +113,8 @@ function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.
const u = j.usage || {};
const u = j.usage || {};
const usage = { input: u.input_tokens || 0, cached: (u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0, output: u.output_tokens || 0, calls: searches.filter((s) => s.kind === 'search').length };
const usage = { input: u.input_tokens || 0, cached: (u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0, output: u.output_tokens || 0, calls: searches.filter((s) => s.kind === 'search').length };
if (j.status && j.status !== 'completed' && !text) throw new ProviderError(`the agent stopped (${j.status}${j.incomplete_details ? `: ${j.incomplete_details.reason}` : ''})`);
if (j.status && j.status !== 'completed' && !text) throw new ProviderError(`the agent stopped (${j.status}${j.incomplete_details ? `: ${j.incomplete_details.reason}` : ''})`);
- return { text, sources: sourcesOf(output, text), searches, model: j.model || model, usage, cost_usd: priceUsage(rates, usage) };
+ return { text, sources: sourcesOf(output, text), citations: citationsOf(output), searches, model: j.model || model, usage, cost_usd: priceUsage(rates, usage) };
};
};
}
}
-module.exports = { chatClient, responsesAgent, priceUsage, sourcesOf, ProviderError };
+module.exports = { chatClient, responsesAgent, priceUsage, sourcesOf, citationsOf, ProviderError };
@@ -335,7 +335,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -335,7 +335,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],
crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],
// How it went is the agents' session (openvibe-shared/agent-session.js, js/task.js mounts it from the events
// How it went is the agents' session (openvibe-shared/agent-session.js, js/task.js mounts it from the events
// below and, while the task runs, from its live stream); the attempts drawn here stay for a page without scripts.
// below and, while the task runs, from its live stream); the attempts drawn here stay for a page without scripts.
- scripts: ['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],
noReferrer: true,
noReferrer: true,
@@ -33,7 +33,7 @@ function markdown(src, { headingOffset = 1 } = {}) {
@@ -33,7 +33,7 @@ function markdown(src, { headingOffset = 1 } = {}) {
let list = null; // { type: 'ul' | 'ol', items: [] }
let list = null; // { type: 'ul' | 'ol', items: [] }
let fence = null;
let fence = null;
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
- const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
+ const flushList = () => { if (list && list.items.length) out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; };
for (let i = 0; i < lines.length; i++) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const line = lines[i];
if (fence) {
if (fence) {
@@ -59,16 +59,19 @@ function markdown(src, { headingOffset = 1 } = {}) {
@@ -59,16 +59,19 @@ function markdown(src, { headingOffset = 1 } = {}) {
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
continue;
continue;
}
}
+ // A model that ends on a bare marker ("-" or "1.") or an empty item ("- ") leaves an empty bullet otherwise.
+ if (/^\s*(?:[-*]|\d+\.)\s*$/.test(line)) continue;
const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
if (li) {
if (li) {
flushPara();
flushPara();
+ if (!li[2].trim()) continue;
const type = li[1] ? 'ol' : 'ul';
const type = li[1] ? 'ol' : 'ul';
if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
list.items.push(li[2]);
list.items.push(li[2]);
continue;
continue;
}
}
if (!line.trim()) { flushPara(); flushList(); continue; }
if (!line.trim()) { flushPara(); flushList(); continue; }
- if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
+ if (list && list.items.length && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
flushList();
flushList();
para.push(line.trim());
para.push(line.trim());
}
}
@@ -71,7 +71,7 @@ async function startProviders() {
@@ -71,7 +71,7 @@ async function startProviders() {
status: 'completed', model: 'gpt-5-mini-stand-in',
status: 'completed', model: 'gpt-5-mini-stand-in',
output: [
output: [
{ type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'stand-in search' } },
{ type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'stand-in search' } },
- { type: 'message', content: [{ type: 'output_text', text: `Web answer to: ${String(body.input).slice(0, 200)} (see https://example.org/source)`, annotations: [{ type: 'url_citation', url: 'https://example.org/source' }] }] },
+ { type: 'message', content: [{ type: 'output_text', text: `Web answer to: ${String(body.input).slice(0, 200)} (see https://example.org/source)`, annotations: [{ type: 'url_citation', url: 'https://example.org/source', title: 'Example source' }] }] },
],
],
usage: { input_tokens: 2000, output_tokens: 200, input_tokens_details: { cached_tokens: 0 } },
usage: { input_tokens: 2000, output_tokens: 200, input_tokens_details: { cached_tokens: 0 } },
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+// An answer's render leaves no empty bullet: a model that ends on a bare list marker ("-" or "1.") or writes a blank
+// item ("- ") must not draw a lone "•" after the answer (the owner's screenshot, 2026-10-30). The rest of the render
+// is unchanged: real lists still render, an item's text still inline.
+const assert = require('assert');
+const { markdown, inline } = require('../server/render/markdown');
+
+assert.strictEqual(markdown('The answer.\n- '), '<p>The answer.</p>', 'a trailing empty item draws nothing');
+assert.strictEqual(markdown('The answer.\n-'), '<p>The answer.</p>', 'a trailing bare marker draws nothing');
+assert.strictEqual(markdown('The answer.\n1. '), '<p>The answer.</p>', 'a trailing empty ordered item draws nothing');
+assert.strictEqual(markdown('- '), '', 'an all-empty list is not a list');
+assert.strictEqual(markdown('- \n* \n1. '), '', 'empty items and bare markers leave nothing');
+assert.strictEqual(markdown('- \n- second'), '<ul><li>second</li></ul>', 'a blank first item does not hide the next');
+assert.strictEqual(markdown('- one\n-\n- two'), '<ul><li>one</li><li>two</li></ul>', 'a bare marker inside a list is skipped');
+assert.strictEqual(markdown('- one\n- \n- two'), '<ul><li>one</li><li>two</li></ul>', 'a blank item inside a list is skipped');
+assert.strictEqual(markdown('1. one\n2. two'), '<ol><li>one</li><li>two</li></ol>', 'a real ordered list renders');
+assert.strictEqual(markdown('- **bold** item'), '<ul><li><strong>bold</strong> item</li></ul>', 'items keep their inline Markdown');
+assert.strictEqual(markdown('Step one\n\n- step\n\nDone.'), '<p>Step one</p>\n<ul><li>step</li></ul>\n<p>Done.</p>', 'the render around lists is unchanged');
+assert.strictEqual(inline('- not a list'), '- not a list');
+
+console.log('markdown: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+// The task page's event mapping (public/js/task-events.js): a search's result lands on its call, a call a finished
+// 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 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 result = (text, s, extra) => ({ kind: 'output', step: 'tool_result', tool: 'web_search', text, at: at(s), agent: 'openai-agent', ...extra });
+const text = (t, s) => ({ kind: 'output', step: 'text', text: t, at: at(s), agent: 'openai-agent' });
+const end = (state, s) => ({ kind: 'end', state, at: at(s) });
+
+// A finished hosted search: both calls are answered, the repeated one folds into "×2", the sources are in the result.
+{
+ const events = [
+ text('Searching the web…', '00'),
+ call('Eiffel Tower height', '02'), result('Searched the web for "Eiffel Tower height". The answer cites 1 source:\n- https://en.wikipedia.org/wiki/Eiffel_Tower', '04'),
+ call('Eiffel Tower height', '05'), result('Searched the web for "Eiffel Tower height". The answer cites 1 source:\n- https://en.wikipedia.org/wiki/Eiffel_Tower', '07'),
+ text('The Eiffel Tower is **330 m** tall.', '08'),
+ end('succeeded', '09'),
+ ];
+ const out = lines(events, { final: true, state: 'succeeded' });
+ const tools = out.filter((l) => l.t === 'tool');
+ const results = out.filter((l) => l.t === 'tool_result');
+ const done = out.filter((l) => l.t === 'done');
+ assert.strictEqual(tools.length, 1, 'identical consecutive calls fold into one line');
+ assert.strictEqual(tools[0].what, 'Eiffel Tower height ×2');
+ assert.deepStrictEqual(tools[0].input, { description: 'Eiffel Tower height ×2' });
+ assert.strictEqual(results.length, 1, 'the folded call does not leave a second result');
+ assert.strictEqual(results[0].id, tools[0].id, 'a result lands on its own call');
+ assert.match(results[0].output, /en\.wikipedia\.org/);
+ assert.strictEqual(results[0].duration_ms, 2000, 'the step keeps the time between its call and result');
+ assert.strictEqual(done.length, 1);
+ assert.strictEqual(done[0].duration_ms, 9000, 'the run time is the first and last event');
+ assert.strictEqual(done[0].ok, true);
+ assert.ok(!out.some((l) => l.t === 'state'), 'a finished page does not replay states over its final one');
+}
+
+// A call a finished task never answered: done with "No result", never a spinner.
+{
+ const out = lines([call('Eiffel Tower height', '02'), end('failed', '30')], { final: true, state: 'failed' });
+ const results = out.filter((l) => l.t === 'tool_result');
+ assert.strictEqual(results.length, 1, 'an unanswered call settles when the task has ended');
+ assert.strictEqual(results[0].ok, true);
+ assert.match(results[0].output, /No result was recorded/);
+ assert.strictEqual(out.filter((l) => l.t === 'done')[0].ok, false, 'a failed task says so on its done line');
+}
+
+// While the task runs the same call is still running: no invented result, no done line, states replay.
+{
+ const out = lines([{ kind: 'state', state: 'running', at: at('00') }, call('Eiffel Tower height', '02')], { final: false });
+ assert.strictEqual(out.filter((l) => l.t === 'tool_result').length, 0);
+ assert.strictEqual(out.filter((l) => l.t === 'done').length, 0);
+ assert.deepStrictEqual(out.filter((l) => l.t === 'state').map((l) => l.state), ['running']);
+}
+
+// Identical calls separated by what the agent said are two tries, not one.
+{
+ const out = lines([call('same', '02'), result('one', '03'), text('Let me check again.', '04'), call('same', '05'), result('two', '06')], { final: true, state: 'succeeded' });
+ assert.strictEqual(out.filter((l) => l.t === 'tool').length, 2);
+ assert.strictEqual(out.filter((l) => l.t === 'tool_result').length, 2);
+}
+
+// A different tool or input is a different line; a result with no call is dropped.
+{
+ const out = lines([
+ call('a', '02'), { kind: 'output', step: 'tool_call', tool: 'run_tool', input: 'mx', at: at('03') },
+ { kind: 'output', step: 'tool_result', tool: 'run_tool', text: 'ok', at: at('05') },
+ { kind: 'output', step: 'tool_result', tool: 'ghost', text: 'orphan', at: at('06') },
+ ], { final: false });
+ assert.deepStrictEqual(out.filter((l) => l.t === 'tool').map((l) => l.name), ['web_search', 'run_tool']);
+ assert.strictEqual(out.filter((l) => l.t === 'tool_result').length, 1, 'only the call that exists gets a result');
+}
+
+// Time helpers: first event and the span; 0 rather than a made-up clock when the events carry no time.
+{
+ assert.strictEqual(firstAt([{ at: at('03') }, { at: at('01') }]), Date.parse(at('03')));
+ assert.strictEqual(firstAt([]), 0);
+ assert.strictEqual(duration([{ at: at('01') }, { at: at('11') }]), 10000);
+ assert.strictEqual(duration([{ at: at('01') }]), 0);
+ assert.strictEqual(duration([], { durationMs: 2500 }), 2500);
+}
+
+console.log('task events: all checks passed');
@@ -54,6 +54,17 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -54,6 +54,17 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.strictEqual(task.result.agent, 'openai-agent');
assert.strictEqual(task.result.agent, 'openai-agent');
assert.deepStrictEqual(task.result.sources, ['https://example.org/source']);
assert.deepStrictEqual(task.result.sources, ['https://example.org/source']);
assert.strictEqual(task.result.checked.by, 'deepseek');
assert.strictEqual(task.result.checked.by, 'deepseek');
+ // Every hosted search is a step with both ends, and its result says what it found (a spinner otherwise).
+ const stream = (await t.get(`/api/v1/tasks/${id}/events`, { as: kim })).text;
+ const steps = [...stream.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1]));
+ const searches = steps.filter((e) => e.step === 'tool_call' && e.tool === 'web_search');
+ const found = steps.filter((e) => e.step === 'tool_result' && e.tool === 'web_search');
+ assert.ok(searches.length > 0, 'the hosted search is a step');
+ assert.strictEqual(found.length, searches.length, 'each search has its result');
+ assert.match(found[0].text, /stand-in search/, 'the result names the query');
+ assert.match(found[0].text, /Example source: https:\/\/example\.org\/source/, 'and the sources with their titles');
+ const pageHtml = (await t.get(`/tasks/${id}`, { as: kim })).text;
+ assert.match(pageHtml, /js\/task-events\.js/, 'the task page maps its events for the session view');
// 2000 in + 200 out at gpt-5-mini plus one search, plus the classifier and the checker on DeepSeek.
// 2000 in + 200 out at gpt-5-mini plus one search, plus the classifier and the checker on DeepSeek.
assert.ok(task.cost.usd > 0.0105 && task.cost.usd < 0.012, String(task.cost.usd));
assert.ok(task.cost.usd > 0.0105 && task.cost.usd < 0.012, String(task.cost.usd));
});
});