openvibers/openvibe.chat Commits
Show live agent run cards in Chat and Vibe rooms (#518)
Changes
nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/ask-354-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/ask-354-390.png differnnew file mode 100644@@@ -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);@@@ -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) + '"]');@@@ -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');@@@ -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--;@@@ -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);@@@ -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 };}@@@ -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) });@@@ -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>@@@ -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; };@@@ -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>`;nnew file mode 100644@@@ -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; });@@@ -124,6 +124,22 @@ t('a video watch page is a card with Watch together, and the stored card comes bassert.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, /<Read files>/);++ 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);@@@ -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 aconst 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');});
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
Binary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/ask-354-390.png differ
Binary files /dev/null and b/docs/screenshots/ask-354-390.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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);
@@ -223,6 +223,13 @@
@@ -223,6 +223,13 @@
var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
function embedCard(e) {
function embedCard(e) {
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return null;
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 url = httpsHref(e.url);
var box = el('div', 'oc-card'); box.dataset.kind = EMBED_KINDS.indexOf(e.kind) >= 0 ? e.kind : 'link';
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;
var img = typeof e.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(e.image) ? httpsHref(e.image) : null;
@@ -243,18 +250,22 @@
@@ -243,18 +250,22 @@
function addCard(li, m) {
function addCard(li, m) {
if (m && Array.isArray(m.embeds) && m.embeds.length) {
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); });
m.embeds.slice(0, 3).forEach(function (e) { var c = embedCard(e); if (c) li.appendChild(c); });
+ mountRunCards(li);
return;
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.
// A preview that landed after its message: replace the line's cards with the finished list.
function updateEmbeds(id, embeds) {
function updateEmbeds(id, embeds) {
if (!feed || !Array.isArray(embeds)) return;
if (!feed || !Array.isArray(embeds)) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
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 });
addCard(li, { embeds: embeds });
}
}
+
+ function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }
function applyEdit(id, m) {
function applyEdit(id, m) {
if (!feed) return;
if (!feed) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
@@ -308,6 +308,15 @@
@@ -308,6 +308,15 @@
var ans = el('div', 'ovt-answer'); ans.setAttribute('data-ovt-answer', '');
var ans = el('div', 'ovt-answer'); ans.setAttribute('data-ovt-answer', '');
if (MD) ans.innerHTML = MD.render(m.message); else ans.textContent = m.message;
if (MD) ans.innerHTML = MD.render(m.message); else ans.textContent = m.message;
reply.appendChild(ans);
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 : [];
var res = Array.isArray(m.results) ? m.results : [];
if (res.length && room) {
if (res.length && room) {
var resBox = el('div', 'vb-res');
var resBox = el('div', 'vb-res');
@@ -34,7 +34,7 @@ const TABLES = new Set(['chat_messages', 'room_messages', 'dm_messages', 'inbox_
@@ -34,7 +34,7 @@ const TABLES = new Set(['chat_messages', 'room_messages', 'dm_messages', 'inbox_
const MAX_TITLE = 120;
const MAX_TITLE = 120;
const MAX_LINE = 200;
const MAX_LINE = 200;
const MAX_NOTE = 160;
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).
// 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) => {
const baseOf = (value, production) => {
@@ -310,6 +310,7 @@ async function hydrate(rows, table) {
@@ -310,6 +310,7 @@ async function hydrate(rows, table) {
if (!row) return;
if (!row) return;
const card = parse(row.card);
const card = parse(row.card);
if (!card) { row.card = null; return; }
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 (Date.now() - (Number(card.at) || 0) < FRESH_MS) { row.card = card; return; }
if (budget <= 0) { row.card = card; return; }
if (budget <= 0) { row.card = card; return; }
budget--;
budget--;
@@ -394,11 +394,11 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
@@ -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. */
/** 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).
// 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 message = String(text || '').trim().slice(0, Number(room.assistant) === 1 ? MAX_AI_TEXT_VIBE : MAX_TEXT);
const id = await db.tx(async () => {
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 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);
await announceMessage(room, { subject_id: null, username: member.handle, display_name: member.name }, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
@@ -162,7 +162,7 @@ async function work(room, user, text, mode) {
@@ -162,7 +162,7 @@ async function work(room, user, text, mode) {
return { message, task: null };
return { message, task: null };
}
}
// A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.
// 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 };
return { message, task };
}
}
@@ -257,6 +257,19 @@ async function startOnActor(room, t, deps = {}) {
@@ -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.
// 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 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' });
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) {
} catch (err) {
await setStatus(room, t.id, { status: 'failed', runner: 'actor', error: oneLine(err.message, LIMITS.error), finished_at: nowText() });
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) });
await announceStep(room, await row(room, t.id), { status: 'failed', reason: oneLine(err.message, LIMITS.error) });
@@ -172,6 +172,7 @@ ${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vib
@@ -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.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.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.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')}
${(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('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
@@ -147,6 +147,13 @@ function linkCard(card) {
@@ -147,6 +147,13 @@ function linkCard(card) {
*/
*/
function embedCard(e) {
function embedCard(e) {
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return '';
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 href = cardHref(e.url);
const image = cardImage(e.image);
const image = cardImage(e.image);
const kind = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'].includes(e.kind) ? e.kind : 'link';
const kind = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'].includes(e.kind) ? e.kind : 'link';
@@ -164,7 +171,7 @@ function embedCard(e) {
@@ -164,7 +171,7 @@ function embedCard(e) {
function messageCards(m) {
function messageCards(m) {
if (!m || typeof m !== 'object') return '';
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('');
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; };
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
@@ -25,6 +25,7 @@ const workspace = require('../rooms/workspace');
@@ -25,6 +25,7 @@ const workspace = require('../rooms/workspace');
const how = require('../rooms/how');
const how = require('../rooms/how');
const memory = require('../rooms/vibe-memory');
const memory = require('../rooms/vibe-memory');
const { esc } = require('./layout');
const { esc } = require('./layout');
+const ui = require('./ui');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const thread = require('openvibe-shared/thread');
@@ -161,7 +162,7 @@ function youOf(m, me) {
@@ -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. */
/** 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) {
function answerOf(m, hows, retrySlug, slug) {
const info = hows.get(Number(m.id));
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) {
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>`;
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>`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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; });
@@ -124,6 +124,22 @@ t('a video watch page is a card with Watch together, and the stored card comes b
@@ -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');
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, /<Read files>/);
+ 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 () => {
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');
const priv = await dmSend(bob, 'https://openvibe.video/v/777');
assert.strictEqual(priv.status, 200, priv.text);
assert.strictEqual(priv.status, 200, priv.text);
@@ -15,7 +15,7 @@ const SITE = 'https://openvibe.chat';
@@ -15,7 +15,7 @@ const SITE = 'https://openvibe.chat';
let h, ann, bob, workspace, config;
let h, ann, bob, workspace, config;
const turns = [];
const turns = [];
const actorCalls = [];
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 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 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;
const realFetch = globalThis.fetch;
@@ -41,7 +41,7 @@ t('boot', async () => {
@@ -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' });
const disabled = await vibeApi('POST', '', ann, { message: 'Research this first', mode: 'research' });
assert.strictEqual(disabled.status, 503, 'the owner switch is off by default');
assert.strictEqual(disabled.status, 503, 'the owner switch is off by default');
config.ai.vibeDoIt = true;
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,
@@ -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));
await new Promise((res) => setTimeout(res, 150));
const msgs = await messagesOf(slug);
const msgs = await messagesOf(slug);
assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : 'ann')), ['ann', 'vibe']);
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');
assert.strictEqual(turns.length, 0, 'no instant answer');
// Actor finishes: the answer comes back into the chat, without "asked by".
// Actor finishes: the answer comes back into the chat, without "asked by".
actorTask = {
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.' } },
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();
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,
@@ -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');
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.
// "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());
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>'
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');
+ '<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.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');
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);
const how = await vibeApi('GET', `/${slug}/how`, ann);
assert.strictEqual(how.status, 200);
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');
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
@@ -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;
const slug = start.body.room.slug;
await new Promise((res) => setTimeout(res, 150));
await new Promise((res) => setTimeout(res, 150));
assert.strictEqual(turns.length, 1, 'Chat mode: Vibe answers at once');
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;
actorCalls.length = 0;
let r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Add a dark favicon to OpenVibe.Tools', mode: 'code' });
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.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual([r.body.task.kind, r.body.task.status, r.body.task.runner], ['code', 'running', 'actor']);
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/);
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' });
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']);
assert.deepStrictEqual([r.body.task.kind, r.body.task.status], ['computer', 'running']);
await new Promise((res) => setTimeout(res, 150));
await new Promise((res) => setTimeout(res, 150));
const vibeLines = (await messagesOf(slug)).filter((m) => m.ai_member_id).map((m) => m.message);
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');
assert.strictEqual(turns.length, 1, 'no instant answers to the tasks');
});
});