openvibers/openvibe.chat Commits

Vibe: Chat · Research · Agent · Code modes in the composer; the last three are agent tasks of the chat (on the person's own Actor allowance and computer) that answer in the chat when done (#171)

OP OpenVibers committed 19 days ago · 7e55a855a0 · 9 files, +219 −13

Changes

ddiff --git a/README.md b/README.md
iindex 5fcd9d4..13f798b 100644
---- a/README.md
++++ b/README.md
@@@ -439,6 +439,12 @@ chat, and the rest of Chat (Messages, Rooms and servers, Global chat) under the
(forms post; the page shows Vibe's answer once written); `public/web/vibe.js` starts a chat in place, follows the room
socket, sends on Enter and keeps a draft typed while signed out through the sign-in.
++- **Modes** (the composer's chips, plan track K): **Chat** answers here and now. **Research**, **Agent** and **Code** post
++ the message without Vibe's instant answer and make it a task of the chat (`vibe.work`, the workspace queue's machinery
++ in `server/rooms/workspace.js`) on the person's own OpenVibe.Actor allowance: research with sources at once; Agent on
++ their own computer through Node and Bot; Code with OpenVibe.Codes on their machine through Run. Vibe says it is on it
++ with the task's link, and the result comes back into the chat. `POST /api/chat/vibe { message, mode }` starts a chat
++ that way; `POST /api/chat/vibe/:slug/ask { message, mode }` asks in one; the forms post `mode` too.
- **A chat is a private room** (`server/rooms/vibe.js`, `migrations/0015_vibe_chats.sql`: `rooms.assistant = 1`) the
person owns, with one AI member, Vibe, who answers every message without being named (`ai-members.js` afterPost).
Messages, pictures, the socket (`join_room`, `room_message`, `room_ai_typing`), export and account deletion are the
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 7cce46f..61da8bd 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -140,7 +140,7 @@ body.oc-app-body { overflow: hidden; }
/* ── The composer ─────────────────────────────────────────────────────────── */
.vb-dock { padding: 0 16px max(10px, env(safe-area-inset-bottom)); }
.vb-composer {
-- max-width: var(--vb-col); margin: 0 auto; display: flex; align-items: flex-end; gap: 8px;
++ max-width: var(--vb-col); margin: 0 auto; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px;
padding: 8px 8px 8px 18px; border-radius: var(--vb-radius); border: 1px solid var(--oc-line);
background: var(--oc-panel); box-shadow: 0 10px 34px rgba(0, 0, 0, .22);
transition: border-color .15s, box-shadow .15s;
@@@ -161,6 +161,15 @@ body.oc-app-body { overflow: hidden; }
.vb-send:hover { transform: translateY(-1px); }
.vb-send:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
.vb-send[disabled], .vb-composer.is-empty .vb-send { opacity: .4; cursor: default; transform: none; }
++/* The modes: a row of chips under the box (Chat · Research · Agent · Code); the checked one is lit. */
++.vb-modes { order: 3; flex-basis: 100%; display: flex; gap: 6px; margin: 0 0 0 -10px; padding: 2px 0 0; border: 0; min-width: 0; overflow-x: auto; scrollbar-width: none; }
++.vb-modes::-webkit-scrollbar { display: none; }
++.vb-modes label { position: relative; flex: none; }
++.vb-modes input { position: absolute; opacity: 0; pointer-events: none; }
++.vb-modes span { display: inline-flex; align-items: center; padding: 4px 11px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .8rem; font-weight: 600; cursor: pointer; white-space: nowrap; transition: background-color .15s, border-color .15s, color .15s; }
++.vb-modes input:checked + span { background: color-mix(in srgb, var(--oc-accent) 16%, transparent); border-color: color-mix(in srgb, var(--oc-accent) 55%, var(--oc-line)); color: var(--oc-text); }
++.vb-modes input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++@media (hover: hover) { .vb-modes span:hover { color: var(--oc-text); } }
.vb-fine { max-width: var(--vb-col); margin: 6px auto 0; text-align: center; font-size: .76rem; color: var(--oc-muted); }
/* ── Phones and narrow windows: the rail slides in over the chat ──────────── */
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex a7e8699..561b6d6 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -204,6 +204,11 @@
var del = app.querySelector('[data-vb-delete]');
if (del) del.addEventListener('submit', function (e) { if (!window.confirm('Delete this chat? Its messages go too.')) e.preventDefault(); });
++ // ── Modes: the box says what the chosen one will do ──
++ if (form) [].forEach.call(form.querySelectorAll('input[name="mode"]'), function (r) {
++ r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });
++ });
++
// ── Sending ──
if (form) form.addEventListener('submit', function (e) {
var text = input.value.trim();
@@@ -225,7 +230,12 @@
showWait(); toBottom();
input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();
var url, init;
-- if (sendingPicture) {
++ var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
++ if (mode !== 'chat' && !sendingPicture) {
++ // Research, Agent or Code: an agent task of this chat; Vibe says it is on it, and the result comes back here.
++ url = room ? '/api/chat/vibe/' + encodeURIComponent(room) + '/ask' : '/api/chat/vibe';
++ init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, mode: mode }) };
++ } else if (sendingPicture) {
// The picture is the body; the question rides in X-Caption. A new chat starts with it (/api/chat/vibe/picture).
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/pictures' : '/api/chat/vibe/picture';
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 67586ad..944240d 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -260,7 +260,7 @@ async function announceDeleted(room, ids) {
}
}
--async function post(room, user, text, { now = Date.now() } = {}) {
++async function post(room, user, text, { now = Date.now(), ai = true } = {}) {
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@@ -288,8 +288,9 @@ async function post(room, user, text, { now = Date.now() } = {}) {
return Number(r.lastInsertRowid);
});
const row = await messageById(id);
-- // A message naming one of the room's AI members (@handle) gets its answer after this one is out.
-- setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });
++ // A message naming one of the room's AI members (@handle) gets its answer after this one is out. `ai: false` posts
++ // without one (a Vibe chat's Research, Agent or Code request: the answer comes from the task instead).
++ if (ai) setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });
return row;
}
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex 72fac81..db44565 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -41,13 +41,24 @@ router.get('/', requireAuth, personOnly, async (req, res) => {
} catch (err) { send(res, err); }
});
++const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null);
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
try {
-- const out = await vibe.start(req.user, req.body && req.body.message);
++ const b = req.body || {};
++ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message);
res.status(201).json(out);
} catch (err) { send(res, err); }
});
++/** A Research, Agent or Code request in an existing chat: { message, mode }. → { message, task } */
++router.post('/:slug/ask', requireAuth, personOnly, startLimit, async (req, res) => {
++ try {
++ const room = await vibe.ownChat(req.params.slug, req.user);
++ if (!room) return res.status(404).json({ error: 'No such chat', code: 'rooms.not_found' });
++ res.status(201).json(await vibe.work(room, req.user, req.body && req.body.message, req.body && req.body.mode));
++ } catch (err) { send(res, err); }
++});
++
const pictureLimit = limits('chat.vibe.picture', { minute: 10, hour: 120 });
const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };
router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next) => express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex c9652d6..c92d7ae 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -14,6 +14,11 @@
* rename(room, user, name) → the room
* remove(room, user) → the chat and its messages are deleted (not archived: it was the person's own)
* repliesToday(user) / countReply(user) the person's answers from Vibe this UTC day, across all their chats
++ * work(room, user, text, mode) → { message, task }: a Research, Agent or Code request (the composer's modes): the
++ * message is posted without Vibe's instant answer and becomes a task of the chat
++ * (server/rooms/workspace.js) on the person's own OpenVibe.Actor allowance: research
++ * with sources; Agent on their computer (Node, through Bot); Code with OpenVibe.Codes
++ * on their machine (through Run). Vibe says it is on it, and posts the result here.
*
* Limits: config.ai.vibeRepliesPerDay answers a person a day, MAX_CHATS chats kept (starting one more deletes the
* oldest), and each message goes through the rooms' own post checks (length, spam, rate).
@@@ -81,6 +86,55 @@ async function start(user, text) {
}
}
++// The composer's modes beyond Chat: each a kind of task in the chat's queue.
++const MODES = Object.freeze({ research: 'research', agent: 'computer', code: 'code' });
++const ON_IT = {
++ research: 'On it: I am researching this as an agent task, with sources. The answer comes back right here, usually within a few minutes.',
++ computer: 'On it: I am doing this on your own computer, through OpenVibe.Node, at the level you set there. I will say here what I did.',
++ code: 'On it: I sent this to OpenVibe.Codes on your own computer (through OpenVibe.Run). The pull request comes back here when it is ready.',
++};
++
++async function work(room, user, text, mode) {
++ const kind = MODES[String(mode || '')];
++ if (!kind) fail(422, 'vibe.mode', 'Pick Chat, Research, Agent or Code');
++ if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');
++ const message = await rooms.post(room, user, text, { ai: false });
++ const workspace = require('./workspace');
++ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [room.id]);
++ const say = async (line) => {
++ if (!member) return null;
++ const posted = await rooms.postAsAi(room, member, line);
++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }
++ return posted;
++ };
++ let task;
++ try {
++ task = await workspace.add(room, user, { title: String(text || ''), details: String(text || ''), kind, via: 'vibe', sourceMessage: message.id });
++ if (kind !== 'research') task = await workspace.runOnComputer(room, user, task.id);
++ } catch (err) {
++ if (!(err && err.status)) throw err;
++ await say(`I could not start that: ${err.message}.`);
++ 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}`);
++ return { message, task };
++}
++
++/** A new chat that starts with a Research, Agent or Code request. → { room, message, task } */
++async function startWork(user, text, mode) {
++ const body = String(text || '').replace(/\r\n/g, '\n').trim();
++ if (!MODES[String(mode || '')]) fail(422, 'vibe.mode', 'Pick Chat, Research, Agent or Code');
++ if (!body) fail(422, 'rooms.empty', 'Write something first');
++ const room = await createRoom(user, body);
++ let out;
++ try { out = await work(room, user, body, mode); } catch (err) {
++ if (err && err.code && String(err.code).startsWith('rooms.')) await db.tx(() => erase(room.id)); // refused first message: no empty chat
++ throw err;
++ }
++ return { room: await rooms.publicRoom(room, { role: 'owner' }), ...out };
++}
++
async function list(user, { limit = 100 } = {}) {
if (!user) return [];
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
@@@ -125,4 +179,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
}
--module.exports = { VIBE, MAX_CHATS, TITLE_MAX, DEFAULT_TITLE, VibeError, enabled, isVibeRoom, titleOf, start, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };
++module.exports = { VIBE, MAX_CHATS, TITLE_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };
ddiff --git a/server/rooms/workspace.js b/server/rooms/workspace.js
iindex 31f56ea..7221bf7 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -54,7 +54,8 @@ function shape(t, room) {
}
async function mustRead(room, user) {
-- if (!rooms.isWorkspace(room)) fail(404, 'work.not_workspace', 'This room has no work queue');
++ // A workspace room's queue, or a Vibe chat's own (its Research, Agent and Code requests, server/rooms/vibe.js).
++ if (!rooms.isWorkspace(room) && Number(room.assistant) !== 1) fail(404, 'work.not_workspace', 'This room has no work queue');
const a = await rooms.access(room, user);
if (!a.read) fail(404, 'rooms.not_found', 'No such room');
return a;
@@@ -263,13 +264,14 @@ async function announce(room, t, deps = {}) {
const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
const member = members[0];
const who = t.requester_name || t.requester_username || 'someone';
++ const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in it
let text;
if (t.status === 'succeeded') {
const body = String(t.result || '').trim();
const clip = body.length > 1500 ? `${body.slice(0, 1500).replace(/\s+\S*$/, '')}…` : body;
-- text = `✅ Done: "${t.title}" (asked by ${who})${clip ? `\n\n${clip}` : ''}${t.result_url ? `\n\n${t.result_url}` : ''}\n\nFull result: ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
++ text = `✅ Done: "${t.title}"${asked}${clip ? `\n\n${clip}` : ''}${t.result_url ? `\n\n${t.result_url}` : ''}\n\nFull result: ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
} else if (t.status === 'failed') {
-- text = `⚠️ Could not finish "${t.title}" (asked by ${who}): ${t.error || 'it did not work out'}. ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
++ text = `⚠️ Could not finish "${t.title}"${asked}: ${t.error || 'it did not work out'}. ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
} else {
text = `Cancelled: "${t.title}".`;
}
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 6d42291..baa3975 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -86,6 +86,14 @@ function turn(m, me) {
return `<li class="vb-turn vb-me" data-id="${Number(m.id) || 0}"><div class="vb-bubble"><p class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</p>${esc(m.message).replace(/\n/g, '<br>')}${pictures.pageHtml(m.message)}</div></li>`;
}
++// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.
++const MODES = [
++ ['chat', 'Chat', 'Talk it through: Vibe answers here and now', 'Message Vibe'],
++ ['research', 'Research', 'An agent researches it and answers here with its sources', 'What should I research?'],
++ ['agent', 'Agent', 'An agent does it on your own computer (OpenVibe.Node), at the level you set there', 'What should I do on your computer?'],
++ ['code', 'Code', 'OpenVibe.Codes writes it on your computer and opens a pull request', 'What should I build or fix? Name the repository'],
++];
++
function composer({ slug, signedIn, enabled, prefill = '' }) {
const action = !signedIn ? `/auth/login` : slug ? `/vibe/${esc(slug)}` : '/vibe';
// A picture (public/web/vibe.js): it goes to OpenVibe.Pics as the person, and Vibe sees it with the question.
@@@ -99,6 +107,7 @@ ${attach}
<label class="oc-sr" for="vb-input">Message Vibe</label>
<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="2000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>
++<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>
</form>
<p class="vb-fine">${enabled ? 'Vibe can get things wrong. Check what matters.' : 'Vibe is resting right now. Try again later.'}</p>`;
}
@@@ -186,7 +195,8 @@ ${room ? thread : hello}
router.post('/vibe', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
try {
-- const out = await vibe.start(actor.user, req.body && req.body.message);
++ const b = req.body || {};
++ const out = b.mode && b.mode !== 'chat' ? await vibe.startWork(actor.user, b.message, b.mode) : await vibe.start(actor.user, b.message);
res.redirect(303, `/vibe/${out.room.slug}`);
} catch (err) {
await render(req, res, { status: err.status || 500, error: err.status ? err.message : 'Something went wrong. Try again.' });
@@@ -198,8 +208,12 @@ ${room ? thread : hello}
const room = await vibe.ownChat(req.params.slug, actor.user);
if (!room) return res.redirect(303, '/vibe');
try {
-- const message = await rooms.post(room, actor.user, req.body && req.body.message);
-- require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
++ const b = req.body || {};
++ if (b.mode && b.mode !== 'chat') await vibe.work(room, actor.user, b.message, b.mode);
++ else {
++ const message = await rooms.post(room, actor.user, b.message);
++ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
++ }
res.redirect(303, `/vibe/${room.slug}`);
} catch (err) {
await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'Your message could not be sent.' });
ddiff --git a/test/vibe-modes.test.js b/test/vibe-modes.test.js
nnew file mode 100644
iindex 0000000..e261125
---- /dev/null
++++ b/test/vibe-modes.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * The Vibe composer's modes (server/rooms/vibe.js work/startWork, vibe-routes.js, web/vibe-pages.js; plan track K):
++ * Research, Agent and Code post the message without Vibe's instant answer and make it a task of the chat on the
++ * person's own OpenVibe.Actor allowance (research at once; Agent and Code on their own computer), Vibe says it is on it
++ * with the task's link, and the result comes back into the chat (without "asked by": the chat is theirs alone). Chat
++ * mode answers as before; an unknown mode, someone else's chat or no sign-in is refused; the composer shows the modes.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe modes');
++const SITE = 'https://openvibe.chat';
++let h, ann, bob, workspace;
++const turns = [];
++const actorCalls = [];
++let actorTask = { id: 'tsk_r1', state: 'queued' };
++const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u && u.token, body });
++const messagesOf = async (slug) => ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || []);
++const realFetch = globalThis.fetch;
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ workspace = require('../server/rooms/workspace');
++ workspace._setTokens({ getToken: async () => 'chat-actor-token' });
++ require('../server/ai/client')._setClient({
++ runs: { create: async (workflow) => { turns.push(workflow); return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'Chat answer' } }] } } }; } },
++ });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ globalThis.fetch = async (url, o = {}) => {
++ if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);
++ actorCalls.push({ url: String(url), method: o.method || 'GET', subject: o.headers['X-OV-Subject'], body: o.body ? JSON.parse(o.body) : null });
++ return { ok: true, status: (o.method || 'GET') === 'POST' ? 201 : 200, json: async () => actorTask };
++ };
++});
++
++t('Research in a new chat: a task on Actor as the person, Vibe says it is on it, no instant answer', async () => {
++ const r = await vibeApi('POST', '', ann, { message: 'Compare the three cheapest cloud browsers for agents', mode: 'research' });
++ assert.strictEqual(r.status, 201, r.text);
++ const slug = r.body.room.slug;
++ assert.strictEqual(r.body.task.kind, 'research');
++ const create = actorCalls.find((c) => c.method === 'POST');
++ assert.strictEqual(create.subject, h.live.users.get(ann.id).subject_id, 'on the person\'s own allowance');
++ assert.match(create.body.task, /^Research this and write a short report for a team, with the links of your sources: Compare the three cheapest/);
++ 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.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', result: { answer: 'Browser Use Cloud is cheapest at $0.02 an hour.', sources: ['https://browser-use.com/cloud-browsers'] } };
++ await workspace.poll();
++ const after = await messagesOf(slug);
++ const done = after[after.length - 1];
++ assert.ok(done.ai_member_id && /^✅ Done: "Compare the three cheapest cloud browsers for agents"\n\nBrowser Use Cloud/.test(done.message), done.message);
++ assert.ok(!/asked by/.test(done.message), 'a Vibe chat has one person in it');
++});
++
++t('Code in an existing chat: Codes on their own computer; Agent too; Chat mode answers as before', async () => {
++ const start = await vibeApi('POST', '', ann, { message: 'hello' });
++ const slug = start.body.room.slug;
++ await new Promise((res) => setTimeout(res, 150));
++ assert.strictEqual(turns.length, 1, 'Chat mode: Vibe answers at once');
++ actorTask = { id: 'tsk_c1', state: 'queued' };
++ 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' };
++ 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\.Node/.test(l)));
++ assert.strictEqual(turns.length, 1, 'no instant answers to the tasks');
++});
++
++t('refusals: an unknown mode, someone else\'s chat, no sign-in', async () => {
++ const start = await vibeApi('POST', '', ann, { message: 'mine' });
++ const slug = start.body.room.slug;
++ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, ann, { message: 'x', mode: 'shell' })).status, 422);
++ assert.strictEqual((await vibeApi('POST', '', ann, { message: 'x', mode: 'shell' })).status, 422);
++ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, bob, { message: 'mine now', mode: 'research' })).status, 404);
++ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, null, { message: 'x', mode: 'research' })).status, 401);
++});
++
++t('the composer shows the four modes, Chat chosen, each saying what it does', async () => {
++ const page = await h.http('GET', '/vibe', { headers: { cookie: `ov_token=${ann.token}` } });
++ assert.match(page.text, /<fieldset class="vb-modes" aria-label="What Vibe does with it">/);
++ assert.match(page.text, /value="chat" data-placeholder="Message Vibe" checked><span>Chat<\/span>/);
++ ['research', 'agent', 'code'].forEach((m) => assert.match(page.text, new RegExp(`value="${m}"`)));
++});
++
++t.run(async () => { globalThis.fetch = realFetch; if (h && h.close) await h.close(); });