openvibers/openvibe.chat Commits

Show live agent run cards in Chat and Vibe rooms (#518)

OP OpenVibers committed 3 days ago · 7386a4cc39 · 15 files, +187 −18

* Show live AG-UI cards in Chat and Vibe rooms

* Link Codes run cards to the full task page

Changes

ddiff --git a/docs/screenshots/ask-354-1280.png b/docs/screenshots/ask-354-1280.png
nnew file mode 100644
iindex 0000000..ee5613a
BBinary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
ddiff --git a/docs/screenshots/ask-354-390.png b/docs/screenshots/ask-354-390.png
nnew file mode 100644
iindex 0000000..9403319
BBinary files /dev/null and b/docs/screenshots/ask-354-390.png differ
ddiff --git a/public/web/agent-run-card.js b/public/web/agent-run-card.js
nnew file mode 100644
iindex 0000000..1cd5349
---- /dev/null
++++ b/public/web/agent-run-card.js
@@@ -0,0 +1,64 @@
++/* A chat.embed@1 agent-run card follows its owner-authorized AG-UI stream through Network. */
++(function (root) {
++ 'use strict';
++ function token() {
++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
++ return m ? decodeURIComponent(m[1]) : null;
++ }
++ var loading = null;
++ function shared() {
++ if (root.OpenVibeAgentSession) return Promise.resolve(true);
++ if (loading) return loading;
++ loading = new Promise(function (resolve) {
++ var script = document.createElement('script'); script.src = '/shared/agent-session.js'; script.async = true;
++ script.onload = function () { resolve(!!root.OpenVibeAgentSession); };
++ script.onerror = function () { resolve(false); };
++ document.head.appendChild(script);
++ });
++ return loading;
++ }
++ function mountAll(from) {
++ Array.prototype.forEach.call((from || document).querySelectorAll('.oc-agent-run:not([data-mounted])'), function (host) {
++ host.dataset.mounted = '1';
++ var bearer = token(), runUrl = host.dataset.ovRunUrl, eventsUrl = host.dataset.ovEventsUrl;
++ if (!bearer || !/^https:\/\/openvibe\.network\/api\/frame\/(?:codes-runs|agent-runs\/actor)\/tsk_[0-9A-HJKMNP-TV-Z]{26}(?:~[1-9][0-9]*)?$/.test(runUrl || '') || eventsUrl !== runUrl + '/events') return;
++ shared().then(function (ready) {
++ if (!ready || !host.isConnected) return;
++ return fetch(runUrl, { credentials: 'omit', headers: { Authorization: 'Bearer ' + bearer, Accept: 'application/json' } }).then(function (r) { return r.ok ? r.json() : null; }).then(function (run) {
++ if (!run || !host.isConnected) return;
++ var href = host.querySelector('a') && host.querySelector('a').href;
++ var view = root.OpenVibeAgentSession.mount(host, { mode: 'card', href: href, title: run.title || host.dataset.ovRunTitle, status: run.status || 'running' });
++ var last = 0;
++ function follow() {
++ if (!host.isConnected) { view.destroy(); return; }
++ return fetch(eventsUrl, { credentials: 'omit', headers: { Authorization: 'Bearer ' + bearer, Accept: 'text/event-stream', 'Last-Event-ID': String(last) } }).then(function (r) {
++ if (!r.ok || !r.body) return;
++ var reader = r.body.getReader(), decoder = new TextDecoder(), buffer = '', done = false;
++ function read() { if (!host.isConnected) { reader.cancel(); view.destroy(); return Promise.resolve(); } return reader.read().then(function (part) {
++ if (part.done) return;
++ if (!host.isConnected) { reader.cancel(); view.destroy(); return; }
++ buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
++ var cut;
++ while ((cut = buffer.indexOf('\n\n')) !== -1) {
++ var frame = buffer.slice(0, cut); buffer = buffer.slice(cut + 2);
++ var id = /^id: *(\d+)$/m.exec(frame);
++ var data = frame.split('\n').filter(function (line) { return line.indexOf('data:') === 0; }).map(function (line) { return line.slice(5).trimStart(); }).join('\n');
++ if (id) last = Number(id[1]);
++ if (data) try { var event = JSON.parse(data); view.push(event); if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') done = true; } catch (err) { /* one malformed event is skipped */ }
++ }
++ return read();
++ }); }
++ return read().then(function () { if (!done && host.isConnected && /running|queued|placed/.test(run.status || '')) setTimeout(follow, 1500); });
++ }).catch(function () {
++ if (host.isConnected && /running|queued|placed/.test(run.status || '')) setTimeout(follow, 1500);
++ });
++ }
++ follow();
++ });
++ }).catch(function () { /* the link remains available */ });
++ });
++ }
++ root.OpenVibeAgentRunCards = { mountAll: mountAll };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { mountAll(document); });
++ else mountAll(document);
++})(window);
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex d6f3d40..fc2c58c 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -223,6 +223,13 @@
var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
function embedCard(e) {
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return null;
++ if (e.kind === 'agent-run') {
++ var run = String(e.run_url || ''), events = String(e.events_url || '');
++ if (!/^https:\/\/openvibe\.network\/api\/frame\/(?:codes-runs|agent-runs\/actor)\/tsk_[0-9A-HJKMNP-TV-Z]{26}(?:~[1-9][0-9]*)?$/.test(run) || events !== run + '/events') return null;
++ var host = el('div', 'oc-agent-run'); host.dataset.ovRunUrl = run; host.dataset.ovEventsUrl = events; host.dataset.ovRunTitle = e.title;
++ var link = el('a', '', e.title); link.href = /^https:\/\/openvibe\.(?:actor\/tasks|codes\/run\/tasks)\/tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(e.url || '')) ? e.url : '#'; link.rel = 'noopener'; host.appendChild(link);
++ return host;
++ }
var url = httpsHref(e.url);
var box = el('div', 'oc-card'); box.dataset.kind = EMBED_KINDS.indexOf(e.kind) >= 0 ? e.kind : 'link';
var img = typeof e.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(e.image) ? httpsHref(e.image) : null;
@@@ -243,18 +250,22 @@
function addCard(li, m) {
if (m && Array.isArray(m.embeds) && m.embeds.length) {
m.embeds.slice(0, 3).forEach(function (e) { var c = embedCard(e); if (c) li.appendChild(c); });
++ mountRunCards(li);
return;
}
-- var c = linkCard(m && m.card); if (c) li.appendChild(c);
++ var c = m && m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m && m.card); if (c) li.appendChild(c);
++ mountRunCards(li);
}
// A preview that landed after its message: replace the line's cards with the finished list.
function updateEmbeds(id, embeds) {
if (!feed || !Array.isArray(embeds)) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
-- Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
++ Array.prototype.forEach.call(li.querySelectorAll('.oc-card, .oc-agent-run'), function (c) { c.remove(); });
addCard(li, { embeds: embeds });
}
++
++ function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }
function applyEdit(id, m) {
if (!feed) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex ae74b67..d025013 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -308,6 +308,15 @@
var ans = el('div', 'ovt-answer'); ans.setAttribute('data-ovt-answer', '');
if (MD) ans.innerHTML = MD.render(m.message); else ans.textContent = m.message;
reply.appendChild(ans);
++ if (m.card && m.card.kind === 'agent-run') {
++ var run = String(m.card.run_url || ''), events = String(m.card.events_url || '');
++ if (/^https:\/\/openvibe\.network\/api\/frame\/(?:codes-runs|agent-runs\/actor)\/tsk_[0-9A-HJKMNP-TV-Z]{26}(?:~[1-9][0-9]*)?$/.test(run) && events === run + '/events') {
++ var card = el('div', 'oc-agent-run'); card.dataset.ovRunUrl = run; card.dataset.ovEventsUrl = events; card.dataset.ovRunTitle = String(m.card.title || 'Agent run');
++ var link = el('a', '', String(m.card.title || 'Agent run')); link.href = /^https:\/\/openvibe\.actor\/tasks\/tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(m.card.url || '')) ? m.card.url : '#'; card.appendChild(link);
++ reply.appendChild(card);
++ if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(reply);
++ }
++ }
var res = Array.isArray(m.results) ? m.results : [];
if (res.length && room) {
var resBox = el('div', 'vb-res');
ddiff --git a/server/chat/link-cards.js b/server/chat/link-cards.js
iindex 85c9fd7..5e55318 100644
---- a/server/chat/link-cards.js
++++ b/server/chat/link-cards.js
@@@ -34,7 +34,7 @@ const TABLES = new Set(['chat_messages', 'room_messages', 'dm_messages', 'inbox_
const MAX_TITLE = 120;
const MAX_LINE = 200;
const MAX_NOTE = 160;
--const KINDS = ['video', 'party', 'voice', 'skill'];
++const KINDS = ['video', 'party', 'voice', 'skill', 'agent-run'];
// A test names its own stand-in; a test that does not never reaches the real site (the same rule as party-pages.js).
const baseOf = (value, production) => {
@@@ -310,6 +310,7 @@ async function hydrate(rows, table) {
if (!row) return;
const card = parse(row.card);
if (!card) { row.card = null; return; }
++ if (card.kind === 'agent-run') { row.card = card; return; }
if (Date.now() - (Number(card.at) || 0) < FRESH_MS) { row.card = card; return; }
if (budget <= 0) { row.card = card; return; }
budget--;
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex da570a1..668477e 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -394,11 +394,11 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
}
/** A message from one of the room's AI members (server/rooms/ai-members.js): user_id 0, the member's id. */
--async function postAsAi(room, member, text, { now = Date.now(), how = null } = {}) {
++async function postAsAi(room, member, text, { now = Date.now(), how = null, card = null } = {}) {
// Vibe's answers in its own chats may run longer than a person's message (a recipe, a plan, code).
const message = String(text || '').trim().slice(0, Number(room.assistant) === 1 ? MAX_AI_TEXT_VIBE : MAX_TEXT);
const id = await db.tx(async () => {
-- const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, ai_member_id, how) VALUES (?, 0, NULL, ?, ?, ?)', [room.id, message, member.id, how ? JSON.stringify(how) : null]);
++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, ai_member_id, how, card) VALUES (?, 0, NULL, ?, ?, ?, ?)', [room.id, message, member.id, how ? JSON.stringify(how) : null, card ? JSON.stringify(card) : null]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await announceMessage(room, { subject_id: null, username: member.handle, display_name: member.name }, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex 335acc5..efae2e6 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -162,7 +162,7 @@ async function work(room, user, text, mode) {
return { message, task: null };
}
// A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.
-- if (task.status !== 'failed') await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);
++ if (task.status !== 'failed' && !task.actor_task) await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);
return { message, task };
}
ddiff --git a/server/rooms/workspace.js b/server/rooms/workspace.js
iindex 2f5f625..b1b118e 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -257,6 +257,19 @@ async function startOnActor(room, t, deps = {}) {
// A task a worker could take stays taken while Actor holds it, so nobody claims it twice.
await setStatus(room, t.id, { actor_task: String(task.id), runner: 'actor', status: task.state === 'running' || !QUICK.has(t.kind) ? 'running' : 'queued', started_at: nowText() });
await announceStep(room, await row(room, t.id), { status: task.state === 'running' || !QUICK.has(t.kind) ? 'running' : 'queued' });
++ if (Number(room.assistant) === 1 && ACTOR_TASK.test(String(task.id || ''))) {
++ try {
++ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = 'vibe') DESC, id LIMIT 1", [room.id]);
++ if (member) {
++ const runUrl = `https://openvibe.network/api/frame/agent-runs/actor/${task.id}`;
++ const posted = await (deps.rooms || rooms).postAsAi(room, member, `Working on “${t.title}”`, { card: {
++ kind: 'agent-run', title: t.title, status: 'running', url: `https://openvibe.actor/tasks/${task.id}`,
++ run_url: runUrl, events_url: `${runUrl}/events`, at: Date.now()
++ } });
++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }
++ }
++ } catch { /* the task keeps running if its chat card cannot be posted */ }
++ }
} catch (err) {
await setStatus(room, t.id, { status: 'failed', runner: 'actor', error: oneLine(err.message, LIMITS.error), finished_at: nowText() });
await announceStep(room, await row(room, t.id), { status: 'failed', reason: oneLine(err.message, LIMITS.error) });
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 392d8a1..1468229 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -172,6 +172,7 @@ ${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vib
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
++${o.script || o.app ? `<script src="${asset('agent-run-card.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex c01d425..3bc8494 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -147,6 +147,13 @@ function linkCard(card) {
*/
function embedCard(e) {
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return '';
++ if (e.kind === 'agent-run') {
++ const run = String(e.run_url || ''), events = String(e.events_url || '');
++ const trusted = /^https:\/\/openvibe\.network\/api\/frame\/(?:codes-runs|agent-runs\/actor)\/tsk_[0-9A-HJKMNP-TV-Z]{26}(?:~[1-9][0-9]*)?$/;
++ const href = /^https:\/\/openvibe\.(?:actor\/tasks|codes\/run\/tasks)\/tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(e.url || '')) ? e.url : null;
++ if (!trusted.test(run) || events !== `${run}/events`) return '';
++ return `<div class="oc-agent-run" data-ov-run-url="${esc(run)}" data-ov-events-url="${esc(events)}" data-ov-run-title="${esc(e.title)}">${href ? `<a href="${esc(href)}" rel="noopener">${esc(e.title)}</a>` : esc(e.title)}</div>`;
++ }
const href = cardHref(e.url);
const image = cardImage(e.image);
const kind = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'].includes(e.kind) ? e.kind : 'link';
@@@ -164,7 +171,7 @@ function embedCard(e) {
function messageCards(m) {
if (!m || typeof m !== 'object') return '';
if (Array.isArray(m.embeds) && m.embeds.length) return m.embeds.slice(0, 3).map(embedCard).filter(Boolean).join('');
-- return linkCard(m.card);
++ return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);
}
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 27363ad..51619a9 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -25,6 +25,7 @@ const workspace = require('../rooms/workspace');
const how = require('../rooms/how');
const memory = require('../rooms/vibe-memory');
const { esc } = require('./layout');
++const ui = require('./ui');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
@@@ -161,7 +162,7 @@ function youOf(m, me) {
/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
function answerOf(m, hows, retrySlug, slug) {
const info = hows.get(Number(m.id));
-- return `<p class="oc-sr">Vibe said:</p>${m.vibe_model ? `<p class="vb-import-model">${esc(m.vibe_model)}</p>` : ''}${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;
++ return `<p class="oc-sr">Vibe said:</p>${m.vibe_model ? `<p class="vb-import-model">${esc(m.vibe_model)}</p>` : ''}${thread.answer({ html: md.render(m.message), copy: false })}${m.card && m.card.kind === 'agent-run' ? ui.embedCard(m.card) : ''}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;
}
function compareOf(asked, answers) {
return `<div class="vb-compare" data-vb-question="${Number(asked.id)}"><div class="vb-compare-tabs" role="tablist" aria-label="Models">${answers.map((a, i) => `<button type="button" role="tab" class="vb-compare-tab${i ? '' : ' is-active'}" data-vb-tab="${i}" aria-selected="${i ? 'false' : 'true'}">${esc(a.model)} <span aria-hidden="true">${a.status === 'finished' ? '●' : '○'}</span></button>`).join('')}</div><div class="vb-compare-grid">${answers.map((a, i) => `<section class="vb-compare-answer${i ? '' : ' is-active'}" data-vb-answer="${i}"><header><strong>${esc(a.model)}</strong><small>${a.provider ? `${esc(a.provider)} · ` : ''}${a.cost_usd == null ? '' : `$${Number(a.cost_usd).toFixed(4)} · `}${a.ms == null ? '' : `${(Number(a.ms) / 1000).toFixed(1)}s`}</small></header><div class="vb-compare-text" data-vb-text>${a.status === 'writing' ? '<span class="vb-pulse" aria-hidden="true"></span>Writing…' : a.status === 'failed' ? esc(a.error || 'Could not answer') : md.render(a.text)}</div><div class="vb-compare-actions"><button type="button" data-vb-copy="${i}">Copy</button><button type="button" data-vb-pick="${esc(a.model)}" data-vb-message="${Number(asked.id)}">Continue with this one</button><button type="button" data-vb-prefer="${esc(a.model)}" data-vb-message="${Number(asked.id)}">This one was better</button></div></section>`).join('')}</div></div>`;
ddiff --git a/test/agent-run-card-chrome.test.js b/test/agent-run-card-chrome.test.js
nnew file mode 100644
iindex 0000000..41caca3
---- /dev/null
++++ b/test/agent-run-card-chrome.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const fs = require('node:fs');
++const path = require('node:path');
++let chromium;
++try { ({ chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright')); } catch { /* optional browser harness */ }
++if (!chromium || !fs.existsSync('/usr/bin/google-chrome')) { console.log('agent-run card Chrome: skipped (Chrome unavailable)'); process.exit(0); }
++
++const id = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const url = `https://openvibe.network/api/frame/agent-runs/actor/${id}`;
++const shared = path.dirname(require.resolve('openvibe-shared/agent-session'));
++const server = http.createServer((req, res) => {
++ const file = req.url === '/shared/agent-session.js' ? path.join(shared, 'agent-session.js')
++ : req.url === '/shared/agent-session.css' ? path.join(shared, 'agent-session.css')
++ : req.url === '/agent-run-card.js' ? path.join(__dirname, '../public/web/agent-run-card.js') : null;
++ if (file) { res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'application/javascript' }); return fs.createReadStream(file).pipe(res); }
++ res.writeHead(200, { 'content-type': 'text/html', 'set-cookie': 'ov_token=test-bearer; Path=/' });
++ res.end(`<!doctype html><html><head><script src="/agent-run-card.js" defer></script></head><body><div class="oc-agent-run" data-ov-run-url="${url}" data-ov-events-url="${url}/events" data-ov-run-title="Read the sources"><a href="https://openvibe.actor/tasks/${id}">Read the sources</a></div></body></html>`);
++});
++
++(async () => {
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
++ try {
++ const page = await browser.newPage({ viewport: { width: 390, height: 800 } });
++ const seen = [], errors = [];
++ page.on('pageerror', (e) => errors.push(e.message));
++ await page.route('https://openvibe.network/api/frame/agent-runs/actor/**', async (route) => {
++ seen.push({ url: route.request().url(), auth: route.request().headers().authorization, last: route.request().headers()['last-event-id'] });
++ if (!route.request().url().endsWith('/events')) return route.fulfill({ status: 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify({ title: 'Read the sources', status: 'running' }) });
++ return route.fulfill({ status: 200, contentType: 'text/event-stream', headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'authorization,last-event-id' }, body: 'id: 1\nevent: STEP_STARTED\ndata: {"type":"STEP_STARTED","stepName":"read","metadata":{"ov":{"title":"Read the sources"}}}\n\n' });
++ });
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { waitUntil: 'load' });
++ await page.locator('.ovas--card').waitFor();
++ await page.getByText('Read the sources').first().waitFor();
++ assert.ok(seen.some((r) => r.url.endsWith('/events') && r.auth === 'Bearer test-bearer' && r.last === '0'));
++ assert.deepEqual(errors, []);
++ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390);
++ await page.close();
++ console.log('agent-run card Chrome: owner stream rendered at 390 px, Last-Event-ID 0, no page errors');
++ } finally { await browser.close(); server.close(); }
++})().catch((e) => { console.error(e); server.close(); process.exitCode = 1; });
ddiff --git a/test/link-cards.test.js b/test/link-cards.test.js
iindex 4351e7e..17222fe 100644
---- a/test/link-cards.test.js
++++ b/test/link-cards.test.js
@@@ -124,6 +124,22 @@ t('a video watch page is a card with Watch together, and the stored card comes b
assert.ok(html.text.includes('oc-card-pic'), 'the picture');
});
++t('an agent-run embed keeps its authorized stream and escapes its title', async () => {
++ const id = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++ const run = `https://openvibe.network/api/frame/agent-runs/actor/${id}`;
++ const card = { kind: 'agent-run', title: '<Read files>', url: `https://openvibe.actor/tasks/${id}`, run_url: run, events_url: `${run}/events` };
++ const rows = [{ card: JSON.stringify(card) }];
++ await require('../server/chat/link-cards').hydrate(rows, 'room_messages');
++ assert.deepStrictEqual(rows[0].card, card, 'the run card is never replaced by a stale link preview');
++ const html = require('../server/web/ui').messageCards(rows[0]);
++ assert.match(html, /class="oc-agent-run"/);
++ assert.match(html, /&lt;Read files&gt;/);
++ assert.ok(!html.includes('<Read files>'));
++ const codes = require('../server/web/ui').embedCard({ ...card, url: `https://openvibe.codes/run/tasks/${id}`, run_url: `https://openvibe.network/api/frame/codes-runs/${id}`, events_url: `https://openvibe.network/api/frame/codes-runs/${id}/events` });
++ assert.ok(codes.includes(`href="https://openvibe.codes/run/tasks/${id}"`), 'a Codes card links to its full task');
++ assert.strictEqual(require('../server/web/ui').embedCard({ ...card, events_url: 'https://elsewhere.example/events' }), '');
++});
++
t('a private video and a page Video does not vouch for are no card', async () => {
const priv = await dmSend(bob, 'https://openvibe.video/v/777');
assert.strictEqual(priv.status, 200, priv.text);
ddiff --git a/test/vibe-modes.test.js b/test/vibe-modes.test.js
iindex 793f1ce..18c6d41 100644
---- a/test/vibe-modes.test.js
++++ b/test/vibe-modes.test.js
@@@ -15,7 +15,7 @@ const SITE = 'https://openvibe.chat';
let h, ann, bob, workspace, config;
const turns = [];
const actorCalls = [];
--let actorTask = { id: 'tsk_r1', state: 'queued' };
++let actorTask = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', state: 'queued' };
const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u && u.token, body });
const messagesOf = async (slug, as = ann) => ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: as.token })).body.messages || []).slice().reverse();
const realFetch = globalThis.fetch;
@@@ -41,7 +41,7 @@ t('boot', async () => {
};
});
--t('Research in a new chat: a task on Actor as the person, Vibe says it is on it, no instant answer', async () => {
++t('Research in a new chat: a task on Actor as the person, Vibe posts a live run card', async () => {
const disabled = await vibeApi('POST', '', ann, { message: 'Research this first', mode: 'research' });
assert.strictEqual(disabled.status, 503, 'the owner switch is off by default');
config.ai.vibeDoIt = true;
@@@ -55,11 +55,13 @@ t('Research in a new chat: a task on Actor as the person, Vibe says it is on it,
await new Promise((res) => setTimeout(res, 150));
const msgs = await messagesOf(slug);
assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : 'ann')), ['ann', 'vibe']);
-- assert.match(msgs[1].message, new RegExp(`^On it: I am researching this[\\s\\S]*${SITE}/r/${slug}/tasks/${r.body.task.id}$`));
++ assert.match(msgs[1].message, /^Working on “Compare the three cheapest cloud browsers for agents”$/);
++ assert.strictEqual(msgs[1].card.kind, 'agent-run');
++ assert.strictEqual(msgs[1].card.events_url, `https://openvibe.network/api/frame/agent-runs/actor/${actorTask.id}/events`);
assert.strictEqual(turns.length, 0, 'no instant answer');
// Actor finishes: the answer comes back into the chat, without "asked by".
actorTask = {
-- id: 'tsk_r1', state: 'succeeded', cost: { usd: 0.0042, free_allowance_used: 0.0042 },
++ id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', state: 'succeeded', cost: { usd: 0.0042, free_allowance_used: 0.0042 },
result: { answer: 'Browser Use Cloud is cheapest at $0.02 an hour.', sources: ['https://browser-use.com/cloud-browsers'], agent: 'openvibe-runtime', model: 'deepseek-v4-flash', class: 'research', checked: { by: 'qwen', cross_family: true, reason: 'The sources back the answer.' } },
};
await workspace.poll();
@@@ -69,14 +71,15 @@ t('Research in a new chat: a task on Actor as the person, Vibe says it is on it,
assert.ok(!/asked by/.test(done.message), 'a Vibe chat has one person in it');
// "How Vibe did this", folded under the answer: what ran it, the check, time and cost, the sources, every step on Actor.
const page = await fetch(`${h.base}/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
++ assert.match(page, /class="oc-agent-run" data-ov-run-url="https:\/\/openvibe\.network\/api\/frame\/agent-runs\/actor\//, 'the saved message renders the live card');
assert.ok(page.includes('<details class="vb-how"><summary>How Vibe did this</summary><ul><li>Researched by OpenVibe runtime (deepseek-v4-flash)</li>'
+ '<li>Checked by a second model (qwen): The sources back the answer.</li><li>Took under a minute · cost $0.0042 of your daily allowance</li>'), 'the fold under the answer');
assert.match(page, new RegExp(`<li class="vb-how-sources"><a href="#vb-sources-${done.id}">Sources \\(1\\) under this answer</a></li>`), 'How links to the chips');
-- assert.ok(page.includes('<li><a href="https://openvibe.actor/tasks/tsk_r1">Every step on OpenVibe Agents</a></li>'), 'every step on Actor');
++ assert.ok(page.includes('<li><a href="https://openvibe.actor/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3">Every step on OpenVibe Agents</a></li>'), 'every step on Actor');
assert.match(page, new RegExp(`<ul class="vb-sources" aria-label="Sources" id="vb-sources-${done.id}">[\\s\\S]*?browser-use\\.com[\\s\\S]*?<\\/ul>`), 'the source as a chip under the answer, once');
const how = await vibeApi('GET', `/${slug}/how`, ann);
assert.strictEqual(how.status, 200);
-- assert.strictEqual(how.body.how[String(done.id)].actor_url, 'https://openvibe.actor/tasks/tsk_r1', 'the same, for an answer that arrives while the page is open');
++ assert.strictEqual(how.body.how[String(done.id)].actor_url, 'https://openvibe.actor/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', 'the same, for an answer that arrives while the page is open');
assert.strictEqual((await vibeApi('GET', `/${slug}/how`, bob)).status, 404, 'someone else\'s chat is not there');
});
@@@ -85,19 +88,19 @@ t('Code in an existing chat: Codes on their own computer; Agent too; Chat mode a
const slug = start.body.room.slug;
await new Promise((res) => setTimeout(res, 150));
assert.strictEqual(turns.length, 1, 'Chat mode: Vibe answers at once');
-- actorTask = { id: 'tsk_c1', state: 'queued' };
++ actorTask = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', state: 'queued' };
actorCalls.length = 0;
let r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Add a dark favicon to OpenVibe.Tools', mode: 'code' });
assert.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual([r.body.task.kind, r.body.task.status, r.body.task.runner], ['code', 'running', 'actor']);
assert.match(actorCalls.find((c) => c.method === 'POST').body.task, /^Make this code change with OpenVibe\.Codes on my own computer/);
-- actorTask = { id: 'tsk_a1', state: 'queued' };
++ actorTask = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A5', state: 'queued' };
r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Tidy my Downloads folder', mode: 'agent' });
assert.deepStrictEqual([r.body.task.kind, r.body.task.status], ['computer', 'running']);
await new Promise((res) => setTimeout(res, 150));
const vibeLines = (await messagesOf(slug)).filter((m) => m.ai_member_id).map((m) => m.message);
-- assert.ok(vibeLines.some((l) => /^On it: I sent this to OpenVibe\.Codes on your own computer/.test(l)));
-- assert.ok(vibeLines.some((l) => /^On it: I am doing this on your own computer, through OpenVibe Link/.test(l)));
++ assert.ok(vibeLines.includes('Working on “Add a dark favicon to OpenVibe.Tools”'));
++ assert.ok(vibeLines.includes('Working on “Tidy my Downloads folder”'));
assert.strictEqual(turns.length, 1, 'no instant answers to the tasks');
});