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)

OP OpenVibers committed 14 days ago · 2282a900fb · 11 files, +318 −11

Changes

ddiff --git a/public/css/actor.css b/public/css/actor.css
iindex f21d47c..c74f021 100644
---- a/public/css/actor.css
++++ b/public/css/actor.css
@@@ -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; } }
ddiff --git a/public/js/task-events.js b/public/js/task-events.js
nnew file mode 100644
iindex 0000000..5e02d1d
---- /dev/null
++++ b/public/js/task-events.js
@@@ -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 };
++});
ddiff --git a/public/js/task.js b/public/js/task.js
iindex 0f8f0fe..387ab7f 100644
---- a/public/js/task.js
++++ b/public/js/task.js
@@@ -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;
}
ddiff --git a/server/agents/adapters.js b/server/agents/adapters.js
iindex 534a0db..dbe228c 100644
---- a/server/agents/adapters.js
++++ b/server/agents/adapters.js
@@@ -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 = con
await 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 };
}
ddiff --git a/server/agents/providers.js b/server/agents/providers.js
iindex fb83062..4ab6e7c 100644
---- a/server/agents/providers.js
++++ b/server/agents/providers.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f1c4541..0072701 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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,
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
iindex 08333b4..31e7290 100644
---- a/server/render/markdown.js
++++ b/server/render/markdown.js
@@@ -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());
}
ddiff --git a/test/helpers/providers.js b/test/helpers/providers.js
iindex 2415eac..92fb1e8 100644
---- a/test/helpers/providers.js
++++ b/test/helpers/providers.js
@@@ -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 } },
}));
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
nnew file mode 100644
iindex 0000000..b79a5bb
---- /dev/null
++++ b/test/markdown.test.js
@@@ -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');
ddiff --git a/test/task-events.test.js b/test/task-events.test.js
nnew file mode 100644
iindex 0000000..891823d
---- /dev/null
++++ b/test/task-events.test.js
@@@ -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');
ddiff --git a/test/tasks.test.js b/test/tasks.test.js
iindex 1beba07..6ec1da8 100644
---- a/test/tasks.test.js
++++ b/test/tasks.test.js
@@@ -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));
});