openvibers/openvibe.codes Commits

Codes' turns in the network's conversation kit (#316)

OP OpenVibers committed 6 days ago · ebd3b6fdb8 · 4 files, +400 −18

The task and the conductor's transcript are the kit's turns: what the person said and what the agent answered. The
answer is Markdown - headings, lists, tables, code with its language and a Copy button, links marked nofollow - and
every character is escaped before the markup is added, so an agent's words never become HTML.

Each tool call is a row with its name, the one line it was given and how it ended; a long output folds behind a fold
that says how much is in it. While a turn works the answer is drawn again as the text arrives (at most every 120 ms),
the page follows only when the reader was at the bottom, and the finished turn comes from the server, so the answer
has its Copy buttons.

public/js/md.js is the one renderer, used by the server and by the browser.

Changes

ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 38adebf..aa7b8cd 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -329,27 +329,125 @@
return 'tool';
};
const attemptN = Number(live.dataset.attempt || 0);
++ const MD = window.CodesMarkdown;
++ // The live line says what the agent is doing, in words: "Editing server/app.js".
++ const VERB = { read: 'Reading', find: 'Searching', edit: 'Editing', run: 'Running', web: 'Browsing', sub: 'Handing over', tool: 'Using' };
++ // What a tool call was given: its one line, whatever shape the agent wrote it in.
++ const whatOf = (b) => {
++ if (b.what) return String(b.what);
++ const i = b.input;
++ if (!i) return '';
++ if (typeof i === 'string') return i;
++ return String(i.file_path || i.path || i.command || i.pattern || i.query || i.url || '');
++ };
const es = page.es = new EventSource(stream);
-- let reloading = false;
-- const reload = () => { if (reloading) return; reloading = true; es.close(); setTimeout(() => location.reload(), 500); };
++ // The answer grows as the text arrives: it is drawn again at most every 120 ms, and the page follows only when
++ // the reader was at the bottom (the kit's rule).
++ let buffer = '', painted = 0, timer = 0, ended = false;
++ const nearBottom = () => (window.innerHeight || 0) + (window.scrollY || 0) >= (document.documentElement.scrollHeight || 0) - 220;
++ const answerOf = () => {
++ let node = live.querySelector('[data-ovt-answer]');
++ if (!node) {
++ node = document.createElement('div');
++ node.className = 'ovt-answer';
++ node.setAttribute('data-ovt-answer', '');
++ const reply = live.querySelector('.ovt-reply');
++ if (reply) reply.appendChild(node);
++ }
++ return node;
++ };
++ const paint = () => {
++ timer = 0;
++ painted = Date.now();
++ const node = answerOf();
++ if (!node) return;
++ const stay = nearBottom();
++ if (MD) { node.innerHTML = MD.markdown(buffer); if (MD.enhance) MD.enhance(node); }
++ else node.textContent = buffer;
++ if (stay) window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'auto' });
++ };
++ const schedule = () => {
++ if (timer) return;
++ timer = setTimeout(paint, Math.max(0, 120 - (Date.now() - painted)));
++ };
++ // A tool's output folds under its row, and the fold says how much is in it.
++ const foldOutput = (key, output) => {
++ if (output == null) return;
++ const rows = live.querySelectorAll('[data-ovt-work] > .ovt-w-tool');
++ let row = null;
++ for (const r of rows) if (String(r.getAttribute('data-key')) === String(key)) row = r;
++ if (!row) return;
++ const head = row.querySelector('.ovt-w-row');
++ if (head && MD) {
++ const mark = head.querySelector('.ovt-w-st');
++ const label = document.createElement('span');
++ label.className = 'ovt-w-in ovt-w-fold';
++ label.textContent = MD.outputLabel(output);
++ head.insertBefore(label, mark);
++ }
++ const det = document.createElement('details');
++ const sum = document.createElement('summary');
++ if (head) sum.appendChild(head);
++ det.appendChild(sum);
++ det.insertAdjacentHTML('beforeend', MD ? MD.outputBody(output) : '');
++ row.appendChild(det);
++ };
++ // The finished turn comes from the server: its answer has its Copy buttons and its work its output folds.
++ const finish = () => {
++ if (ended) return;
++ ended = true;
++ es.close();
++ const swap = (again) => {
++ fetch(window.location.href, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
++ .then((r) => (r.ok ? r.text() : ''))
++ .then((html) => {
++ if (!html) { location.reload(); return; }
++ const doc = new DOMParser().parseFromString(html, 'text/html');
++ const fresh = doc.querySelector('[data-turn="' + id + '"]');
++ if (!fresh) { location.reload(); return; }
++ if (again && fresh.hasAttribute('data-open')) { setTimeout(() => swap(false), 900); return; }
++ OV.replace(id, fresh.outerHTML);
++ // The composer stops working with the turn.
++ const form = $('[data-ovt-composer]', app);
++ if (form) {
++ form.classList.remove('is-busy');
++ const stop = $('[data-ovt-stop]', form);
++ if (stop) stop.hidden = true;
++ }
++ })
++ .catch(() => location.reload());
++ };
++ setTimeout(swap, 400);
++ };
es.addEventListener('line', (ev) => {
let d; try { d = JSON.parse(ev.data); } catch { return; }
if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;
const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };
if (b.t === 'tool') {
-- OV.tool(id, { name: b.name || 'tool', input: b.what || '', icon: kindOf(b.name), key: b.id });
-- OV.live(id, `${b.name || 'Working'} ${b.what || ''}`.trim());
++ const what = whatOf(b);
++ OV.tool(id, { name: b.name || 'tool', input: what, icon: kindOf(b.name), key: b.id });
++ OV.live(id, `${VERB[kindOf(b.name)] || 'Using'} ${what}`.trim());
} else if (b.t === 'tool_result') {
OV.toolDone(id, b.ok !== false, b.id);
++ foldOutput(b.id, b.output);
} else if (b.t === 'text' && b.text) {
-- OV.live(id, String(b.text).split('\n').find(Boolean) || 'Working');
-- } else if (b.t === 'done' || b.t === 'end' || d.kind === 'end') reload();
++ const t = String(b.text);
++ // The words may arrive as a whole message or as a piece of one; either way the answer holds all of them.
++ buffer = buffer && !t.startsWith(buffer) ? buffer + t : t;
++ OV.live(id, t.split('\n').find(Boolean) || 'Working');
++ schedule();
++ } else if (b.t === 'done') {
++ if (b.text) buffer = String(b.text);
++ schedule();
++ finish();
++ } else if (b.t === 'end' || d.kind === 'end') finish();
});
es.addEventListener('attempt', (ev) => {
let d; try { d = JSON.parse(ev.data); } catch { return; }
// The stream replays every attempt on connect: only the live one ending, or a newer one starting, is news.
-- if ((d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) || (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN)) reload();
++ if (d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) finish();
++ else if (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN) { es.close(); location.reload(); }
});
-- es.addEventListener('end', reload);
++ es.addEventListener('end', finish);
}
})();
ddiff --git a/public/js/md.js b/public/js/md.js
nnew file mode 100644
iindex 0000000..f1f0b82
---- /dev/null
++++ b/public/js/md.js
@@@ -0,0 +1,154 @@
++'use strict';
++/**
++ * Codes' Markdown for the conversation kit (openvibe-shared/thread + ov-thread.js): one renderer, used by the server
++ * to draw a turn and by the browser to draw the same turn again while the text streams in. Every character is escaped
++ * first and the markup is added afterwards, so an agent's words can never become HTML.
++ *
++ * The markup is the kit's: an answer is .ovt-answer, a code block carries its language and a Copy button, a table
++ * scrolls inside itself (thread.css) and a link is marked nofollow. Nothing here is styled: the kit draws it.
++ *
++ * const md = require('../../public/js/md.js'); // on the server
++ * <script src="/js/md.js"></script> // in the browser: window.CodesMarkdown
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ if (root) root.CodesMarkdown = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
++ // Only these may become a link; anything else stays words.
++ const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;
++
++ /** The inline markup of one line: code, links, bold, italic. The text is already escaped. */
++ function inline(text) {
++ const codes = [];
++ let out = text.replace(/`([^`\n]+)`/g, (m, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
++ out = out.replace(/\[([^\]\n]*)\]\(([^)\s]+)\)/g, (m, label, url) => (
++ SAFE_URL.test(url) ? '<a href="' + url + '" rel="nofollow ugc noopener" target="_blank">' + label + '</a>' : m
++ ));
++ out = out.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
++ out = out.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
++ out = out.replace(/\u0000(\d+)\u0000/g, (m, i) => '<code>' + codes[Number(i)] + '</code>');
++ return out;
++ }
++
++ const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
++ const isDivider = (line) => /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line) && line.includes('-');
++
++ /** One Markdown answer, as the kit's answer markup. */
++ function markdown(source) {
++ const lines = String(source == null ? '' : source).replace(/\r\n?/g, '\n').split('\n');
++ const out = [];
++ let i = 0;
++ while (i < lines.length) {
++ const line = lines[i];
++ if (!line.trim()) { i++; continue; }
++ // A fenced code block: its language, its text, and the Copy button the kit draws on it.
++ const fence = /^\s*```\s*([A-Za-z0-9+#._-]*)\s*$/.exec(line);
++ if (fence) {
++ const lang = fence[1] || '';
++ const body = [];
++ i++;
++ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { body.push(lines[i]); i++; }
++ i++;
++ const label = lang ? '<p class="ovt-code-lang"><span class="ovt-w-in">' + esc(lang) + '</span></p>' : '';
++ out.push(label + '<pre><code' + (lang ? ' data-lang="' + esc(lang) + '" class="language-' + esc(lang) + '"' : '') + '>' + esc(body.join('\n')) +
++ '</code><button type="button" class="ovt-code-copy" data-ovt-code-copy hidden>Copy</button></pre>');
++ continue;
++ }
++ const head = /^(#{1,6})\s+(.*)$/.exec(line);
++ if (head) {
++ const level = Math.min(6, head[1].length + 1);
++ out.push('<h' + level + '>' + inline(esc(head[2])) + '</h' + level + '>');
++ i++;
++ continue;
++ }
++ if (/^\s*>\s?/.test(line)) {
++ const body = [];
++ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
++ out.push('<blockquote><p>' + inline(esc(body.join(' '))) + '</p></blockquote>');
++ continue;
++ }
++ const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
++ if (item) {
++ const ordered = /\d/.test(item[2]);
++ const items = [];
++ while (i < lines.length) {
++ const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
++ if (!m) break;
++ items.push('<li>' + inline(esc(m[3])) + '</li>');
++ i++;
++ }
++ const tag = ordered ? 'ol' : 'ul';
++ out.push('<' + tag + '>' + items.join('') + '</' + tag + '>');
++ continue;
++ }
++ if (line.includes('|') && i + 1 < lines.length && isDivider(lines[i + 1])) {
++ const head2 = cells(line);
++ i += 2;
++ const body = [];
++ while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { body.push(cells(lines[i])); i++; }
++ out.push('<table><thead><tr>' + head2.map((h) => '<th>' + inline(esc(h)) + '</th>').join('') + '</tr></thead><tbody>' +
++ body.map((r) => '<tr>' + r.map((c) => '<td>' + inline(esc(c)) + '</td>').join('') + '</tr>').join('') + '</tbody></table>');
++ continue;
++ }
++ // A paragraph: until a blank line or the next kind of block.
++ const para = [];
++ while (i < lines.length && lines[i].trim() && !/^\s*```/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i])
++ && !/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) && !/^\s*>\s?/.test(lines[i])
++ && !(lines[i].includes('|') && i + 1 < lines.length && isDivider(lines[i + 1]))) {
++ para.push(lines[i]);
++ i++;
++ }
++ if (!para.length) { i++; continue; }
++ out.push('<p>' + inline(esc(para.join('\n')).replace(/\n/g, '<br>')) + '</p>');
++ }
++ return out.join('');
++ }
++
++ // A tool's output: long output folds, and the fold says how much is in it. Never hidden without a count.
++ const OUT_LIMIT = 4000;
++ function outputLabel(output) {
++ const text = String(output == null ? '' : output);
++ const cut = text.length > OUT_LIMIT;
++ const n = text.slice(0, OUT_LIMIT).split('\n').length;
++ return 'Output (' + n + ' line' + (n === 1 ? '' : 's') + (cut ? ', cut' : '') + ')';
++ }
++ function outputBody(output) {
++ const text = String(output == null ? '' : output);
++ return '<pre class="ovt-w-out">' + esc(text.slice(0, OUT_LIMIT)) + '</pre>';
++ }
++
++ /**
++ * The Copy button on a code block: the kit draws one on every answer's code, but it is drawn in the browser, so
++ * the server's own button stays hidden until this shows it and gives it something to do.
++ */
++ function enhance(root) {
++ if (!root || typeof document === 'undefined') return root;
++ const list = root.querySelectorAll ? root.querySelectorAll('[data-ovt-code-copy]') : [];
++ for (const b of list) {
++ if (b.dataset.ovtReady) continue;
++ b.dataset.ovtReady = '1';
++ b.hidden = false;
++ b.addEventListener('click', () => {
++ const pre = b.closest('pre');
++ const code = pre && pre.querySelector('code');
++ const text = code ? code.textContent : '';
++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).catch(() => { /* a refused clipboard changes nothing */ });
++ });
++ }
++ return root;
++ }
++
++ if (typeof document !== 'undefined') {
++ const boot = () => enhance(document);
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
++ else boot();
++ document.addEventListener('ov:boost:load', boot);
++ }
++
++ return { markdown, escape: esc, clip, outputLabel, outputBody, enhance };
++});
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 0b6d33e..f1948df 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -17,7 +17,7 @@
const express = require('express');
const { send } = require('../render/layout');
const { raw, esc } = require('../render/html');
--const { markdown } = require('../render/markdown');
++const md = require('../../public/js/md.js');
const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
const models = require('../data/models');
@@@ -228,14 +228,40 @@ function createChatRoutes(ctx) {
// A conversation is a list of turns: what the person said, and the agent's reply under its face and name. A
// hand-over to another agent, or a wait, is a line across the thread. How a task ended is a card in its last reply.
const STEP_ICON = { read: 'read', find: 'find', edit: 'edit', run: 'run', web: 'web', sub: 'sub', tool: 'tool' };
++ // The live line says what the agent is doing, in words: "Editing server/app.js".
++ const STEP_VERB = { read: 'Reading', find: 'Searching', edit: 'Editing', run: 'Running', web: 'Browsing', sub: 'Handing over', tool: 'Using' };
++ /** What a tool call was given: its one line, whatever shape the agent wrote it in. */
++ function toolWhat(b) {
++ if (b.what) return String(b.what);
++ const i = b.input;
++ if (!i) return '';
++ if (typeof i === 'string') return i;
++ return String(i.file_path || i.path || i.command || i.pattern || i.query || i.url || '');
++ }
const errorRow = (msg) => thread.turn({ avatar: false, reply: thread.card({ tone: 'bad', text: msg }), attrs: { role: 'alert' } });
++ /** One tool as a row of the kit's work list: its name, the one line it was given, and how it ended. */
++ function workRow(s) {
++ const st = s.ok == null ? (s.running ? 'is-run' : 'is-done') : (s.ok ? 'is-ok' : 'is-bad');
++ const mark = st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : st === 'is-done' ? '' : thread.icon(st === 'is-ok' ? 'ok' : 'bad');
++ const what = s.what ? `<span class="ovt-w-in">${esc(md.clip(s.what, 90))}</span>` : '';
++ const head = `${thread.icon(STEP_ICON[s.kind] || 'tool')}<b>${esc(s.name || 'Tool')}</b>${what}`;
++ if (s.output == null && st !== 'is-run') return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row">${head}<span class="ovt-w-st">${mark}</span></div></li>`;
++ // Long output folds, and the fold says how much is in it: never hidden without a count.
++ const count = s.output != null ? `<span class="ovt-w-in ovt-w-fold">${esc(md.outputLabel(s.output))}</span>` : '';
++ const body = s.output != null ? md.outputBody(s.output) : '<p class="ovt-w-out ovt-muted">Running…</p>';
++ return `<li class="ovt-w-tool ${st}"><details><summary>${head}${count}<span class="ovt-w-st">${mark}</span></summary>${body}</details></li>`;
++ }
/** The steps an agent took, as the kit's work rows: what it said along the way is a note, each tool a row. */
function workHtml(steps, ms, { live = false, liveText = '' } = {}) {
if (!steps.length && !live) return '';
const rows = steps.map((s, i) => (s.said
-- ? { kind: 'note', text: s.what }
-- : { kind: 'tool', name: s.name, input: s.what || '', icon: STEP_ICON[s.kind] || 'tool', ok: s.ok == null ? undefined : s.ok, running: live && s.ok == null && i === steps.length - 1 ? true : false }));
-- return thread.work({ open: live, live: liveText || 'Starting', took: ms ? minutes(ms) : null, rows });
++ ? `<li class="ovt-w-note">${thread.icon('note')}<p>${esc(s.what)}</p></li>`
++ : workRow(s.ok == null && live && i === steps.length - 1 ? { ...s, running: true } : s))).join('');
++ const tools = steps.filter((s) => !s.said).length;
++ const line = live
++ ? `<span class="ovt-w-live" data-ovt-live>${esc(liveText || 'Starting')}…</span>`
++ : `<span>${ms ? `Worked for ${esc(minutes(ms))}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;
++ return `<details class="ovt-work"${live ? ' open data-open' : ''}><summary>${line}${thread.icon('chev', 'ovt-chev')}</summary><ol class="ovt-w-list" data-ovt-work>${rows}</ol></details>`;
}
/** One agent's reply from its session lines: the steps it took, then what it said last (its answer). */
function agentReply(a, lines, { live = false, attempt = null } = {}) {
@@@ -245,8 +271,8 @@ function createChatRoutes(ctx) {
for (const l of lines) {
const b = asObject(l.body) || { t: 'text', text: String(l.body || '') };
const t = b.t || l.kind;
-- if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: b.what || '', kind: stepKind(b.name), ok: null });
-- else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) s.ok = b.ok !== false; }
++ if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: toolWhat(b), kind: stepKind(b.name), ok: null, output: null });
++ else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) { s.ok = b.ok !== false; if (b.output != null) s.output = String(b.output); } }
else if (t === 'text' && b.text) said.push(String(b.text));
else if (t === 'done') done = b;
else if (t === 'note' && b.text) said.push(`> ${String(b.text)}`);
@@@ -259,8 +285,8 @@ function createChatRoutes(ctx) {
const all = [...along.map((text) => ({ said: true, what: firstLine(text, 140) })), ...steps];
const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';
const lastStep = steps[steps.length - 1];
-- const liveText = lastStep ? `${lastStep.name} ${lastStep.what || ''}`.trim() : '';
-- const body = answer ? thread.answer({ html: markdown(answer) })
++ const liveText = lastStep ? `${STEP_VERB[lastStep.kind] || 'Using'} ${lastStep.what || ''}`.trim() : '';
++ const body = answer ? thread.answer({ html: md.markdown(answer) })
: live ? '' : `<p class="ovt-muted">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;
return { who: { ...a, sub: ran }, reply: workHtml(all, ms, { live, liveText }) + body };
}
@@@ -318,7 +344,7 @@ function createChatRoutes(ctx) {
const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: m.model || row.model } });
out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));
} else if (m.result && m.result.summary) {
-- out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));
++ out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: md.markdown(m.result.summary) }) }));
} else if (m.error) {
out.push(thread.turn({ id, you: mine, who, reply: `<p class="ovt-muted">${esc(m.error)}</p>` }));
} else if (m.state === 'queued') {
@@@ -429,7 +455,7 @@ function createChatRoutes(ctx) {
cls: page != null ? 'is-page' : '',
});
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });
}
/** The panel: who worked on it, and where the work is. */
ddiff --git a/test/thread-kit.test.js b/test/thread-kit.test.js
nnew file mode 100644
iindex 0000000..6ea1c50
---- /dev/null
++++ b/test/thread-kit.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++
++// Codes' turns in the network's conversation kit (openvibe-shared/thread): a turn is what the person said and what
++// the agent answered, the answer is Markdown (headings, lists, tables, code with its language and a Copy button,
++// links marked nofollow), each tool call is a row with its name, its one line and how it ended, and a long output
++// folds behind a fold that says how much is in it. Nothing an agent writes ever becomes HTML.
++
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const md = require('../public/js/md.js');
++
++(async () => {
++ const t = await boot();
++ const ada = t.network.addUser('kit-ada');
++ const db = t.ctx.store.db;
++ const now = Date.now();
++ const P = 'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW';
++ const MINE = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FD1';
++ const ANSWER = [
++ '# What changed',
++ '',
++ 'The rail scrolls sideways now. See [the notes](https://example.com/notes).',
++ '',
++ '- one line',
++ '- another',
++ '',
++ '```js',
++ 'const rail = document.querySelector(".ovt-rail");',
++ '```',
++ '',
++ '| file | lines |',
++ '| --- | --- |',
++ '| public/css/chat.css | 12 |',
++ '',
++ 'And a script tag: <script>alert("no")</script>',
++ ].join('\n');
++ const OUTPUT = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join('\n');
++ try {
++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at)
++ VALUES ($1, $2, $3, 'https://github.com/OpenVibers/OpenVibe.Live', 'main', 'Make the rail scroll sideways', 'claude-code', 'succeeded', '{}'::jsonb, $4, $4)`,
++ [MINE, ada.subject, P, now - 60_000]);
++ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, model, state, why, kind, created_at)
++ VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FE1', $1, 1, 'claude-code', 'opus', 'done', 'The rail scrolls sideways', 'first', now() - interval '50 minutes')`, [MINE]);
++ await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES
++ ($1, 1, now() - interval '49 minutes', 'tool', $2::jsonb, 1),
++ ($1, 2, now() - interval '48 minutes', 'tool_result', $3::jsonb, 1),
++ ($1, 3, now() - interval '47 minutes', 'done', $4::jsonb, 1)`,
++ [MINE,
++ JSON.stringify({ t: 'tool', id: 'call-1', name: 'Edit', what: 'public/css/chat.css' }),
++ JSON.stringify({ t: 'tool_result', id: 'call-1', ok: true, output: OUTPUT }),
++ JSON.stringify({ t: 'done', ok: true, text: ANSWER })]);
++
++ const page = await t.get(`/tasks/${MINE}`, { as: ada });
++ assert.strictEqual(page.status, 200);
++
++ await check('the answer is Markdown: headings, lists, a table, code with its language and Copy, safe links', async () => {
++ const text = page.text;
++ assert.ok(text.includes('<div class="ovt-answer" data-ovt-answer>'), 'the answer is the kit\'s own');
++ assert.ok(text.includes('<h2>What changed</h2>'), 'a heading');
++ assert.ok(text.includes('<ul><li>one line</li><li>another</li></ul>'), 'a list');
++ assert.ok(text.includes('<table><thead><tr><th>file</th><th>lines</th></tr></thead>'), 'a table');
++ assert.ok(text.includes('<code data-lang="js" class="language-js">'), 'the code block says its language');
++ assert.ok(text.includes('data-ovt-code-copy'), 'the code block has a Copy button');
++ assert.ok(text.includes('data-ovt-copy'), 'and the answer has one too');
++ assert.ok(text.includes('<a href="https://example.com/notes" rel="nofollow ugc noopener" target="_blank">the notes</a>'), 'a link says what it is');
++ });
++
++ await check('a tool call is a row: its name, the one line it was given, and how it ended', async () => {
++ const text = page.text;
++ assert.ok(text.includes('<li class="ovt-w-tool is-ok">'), 'the row is the kit\'s, and it worked');
++ assert.ok(text.includes('<b>Edit</b>'), 'the name');
++ assert.ok(text.includes('<span class="ovt-w-in">public/css/chat.css</span>'), 'the one line it was given');
++ assert.ok(text.includes('<span class="ovt-w-st">'), 'and the mark that says how it ended');
++ });
++
++ await check('a long output folds, and the fold says how much is in it', async () => {
++ const text = page.text;
++ assert.ok(text.includes('Output (12 lines)'), 'the count is on the fold');
++ assert.ok(text.includes('<details>') && text.includes('<pre class="ovt-w-out">'), 'the output is folded away');
++ });
++
++ await check('the renderer itself escapes first and marks up afterwards', async () => {
++ const html = md.markdown('# Title\n\nA [link](https://example.com) and <script>x</script>');
++ assert.ok(html.includes('<h2>Title</h2>'), 'a heading');
++ assert.ok(html.includes('rel="nofollow ugc noopener"'), 'a link says what it is');
++ assert.ok(html.includes('&lt;script&gt;x&lt;/script&gt;'), 'and a script tag stays words');
++ assert.ok(!html.includes('<script>'), 'never a script');
++ });
++
++ await check('a script tag in the agent\'s words is words, not a script', async () => {
++ const text = page.text;
++ assert.ok(text.includes('&lt;script&gt;alert(&quot;no&quot;)&lt;/script&gt;'), 'it is escaped');
++ assert.ok(!text.includes('<script>alert'), 'and never a script');
++ });
++
++ await check('the turn is drawn by the server: the page says it all without a script', async () => {
++ const text = page.text;
++ assert.ok(text.includes('<li class="ovt-turn"') && text.includes('class="ovt-bubble">Make the rail scroll sideways'), 'the turn and the brief are in the page');
++ assert.ok(text.includes('<details class="ovt-work">') || text.includes('<details class="ovt-work"'), 'the work folds under the answer');
++ assert.ok(text.includes('data-ovt-answer'), 'the answer is there to be read');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });