openvibers/openvibe.codes Commits
Who works on it: an agent and its model in one control, in plain words (L31 CH5) (#206)
Changes
nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++++/**++ * The models a person can pick for a turn (Codes L31 CH5), in words a beginner understands. One entry per agent: the++ * models whose names stay put (Claude Code's own aliases follow each new release; DeepSeek's two names are its API's),++ * and otherwise the agent's own default, which its CLI keeps current. The composer offers them as one control, "who++ * works on it": an agent and its model together. The value is "agent|model" (model empty for the agent's default).++ *++ * list(harnesses) → [{ agent, name, models: [{ id, name, note }], efforts }] for the agents Codes knows++ * choices(harnesses) → [{ value, label, agent, model, group }] for a picker, Auto first++ * parse(value, harnesses) → { agent, model } or null when it names nothing Codes offers++ */++const { EFFORT_LEVELS } = require('../../engine/adapters/base');++++const MODELS = {++ 'claude-code': [++ { id: 'opus', name: 'Opus', note: 'the strongest: hard changes, design, long work' },++ { id: 'sonnet', name: 'Sonnet', note: 'fast and capable: most tasks' },++ { id: 'haiku', name: 'Haiku', note: 'the quickest: small fixes and questions' },++ ],++ deepseek: [++ { id: 'deepseek-chat', name: 'Chat', note: 'quick and very cheap' },++ { id: 'deepseek-reasoner', name: 'Reasoner', note: 'thinks first: harder problems' },++ ],++};++const DEFAULT = { id: '', name: 'its default', note: 'the model the agent picks for itself' };++++function list(harnesses) {++ return (harnesses?.list?.() || []).map((a) => ({++ agent: a.id, name: a.name,++ models: MODELS[a.id] ? MODELS[a.id] : [DEFAULT],++ efforts: EFFORT_LEVELS[a.id] || [],++ }));++}++++function choices(harnesses) {++ const out = [{ value: '', label: 'Auto: Codes picks', agent: null, model: null, group: null }];++ for (const a of list(harnesses)) {++ for (const m of a.models) {++ out.push({ value: `${a.agent}|${m.id}`, label: m.id ? `${a.name} · ${m.name}` : a.name, note: m.note, agent: a.agent, model: m.id || null, group: a.name });++ }++ }++ return out;++}++++function parse(value, harnesses) {++ const v = String(value || '');++ if (!v) return { agent: null, model: null };++ const [agent, model = ''] = v.split('|');++ const known = list(harnesses).find((a) => a.agent === agent);++ if (!known) return null;++ if (model && !known.models.some((m) => m.id === model)) return null;++ return { agent, model: model || null };++}++++module.exports = { MODELS, list, choices, parse };@@@ -26,6 +26,9 @@ function createApi(ctx) {const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ // The models each agent offers, in words a beginner understands, with the effort levels it takes (Codes L31 CH5).++ r.get('/harnesses/models', reads, (req, res) => res.set('Cache-Control', 'public, max-age=300').json({ agents: require('../data/models').list(harnesses) }));++r.get('/harnesses', reads, (req, res) => {res.json({ harnesses: harnesses.list().map((h) => ({ ...h, agents: harnesses.agents(h.id) })) });});@@@ -20,6 +20,7 @@ const { raw, esc } = require('../render/html');const { markdown } = require('../render/markdown');const { asyncRouter } = require('./router');const { sessionLogOf } = require('../domain/run-tasks');++const models = require('../data/models');const LIVE = ['queued', 'placed', 'running'];const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));@@@ -292,9 +293,13 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</}// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────-- function agentPicker(selected, { label = 'Agent', auto = 'Auto' } = {}) {-- const list = harnesses?.list?.() || [];-- return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span><select name="agent" data-ch-agent><option value="">${esc(auto)}</option>${list.map((a) => `<option value="${esc(a.id)}"${selected === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;++ function whoPicker(selected, { label = 'Who works on it', auto = null } = {}) {++ const all = models.choices(harnesses);++ const groups = new Map();++ for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }++ const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}>${esc(c.label)}</option>`;++ const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);++ return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;}// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];@@@ -360,7 +365,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${fconst error = String(req.query.error || '').slice(0, 200);const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];-- const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${agentPicker(settings.default_agent || '')}${effortPicker('')}`;++ const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${whoPicker(settings.default_agent ? `${settings.default_agent}|` : '')}${effortPicker('')}`;const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;return frame(req, res, {title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,@@@ -376,12 +381,13 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${fconst back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);const text = String(b.text || '').trim().slice(0, 16000);if (!text) return back('Write what you want done first.');-- const agent = String(b.agent || '').trim();-- if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return back('Choose an agent from the list.');++ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);++ if (!who) return back('Choose who works on it from the list.');++ const agent = who.agent || '';let args;try {const { parseArgs } = require('../../harness/job');-- args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));++ args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));} catch (e) { return back(`Check the repository: ${e.message}.`); }let project = null;try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }@@@ -416,7 +422,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f// The stream starts after the last line this page already shows.const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;-- const pickers = agentPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');++ const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');return frame(req, res, {currentId: row.id, title: titleOf(row), head, side: await sideHtml(row),thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await taskThread(row),@@@ -437,10 +443,12 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${fconst row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);if (!row) return res.status(404).send('Not found');const b = req.body || {};-- const agent = String(b.agent || '').trim() || null;-- if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose an agent from the list.')}`);++ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);++ if (!who) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose who carries on from the list.')}`);++ // The same agent with another model carries on; another agent takes over.++ const agent = who.agent && who.agent !== row.agent ? who.agent : null;const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort) ? b.effort : null;-- const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, effort });++ const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, model: who.model, effort });return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);});@@@ -78,6 +78,22 @@ const { stepKind } = require('../server/http/chat');assert.ok(home.text.includes('name="effort"') && home.text.includes('>Thorough</option>'), 'the composer offers it in plain words');});++ await check('who works on it: an agent and its model in one control, kept on the first attempt (L31 CH5)', async () => {++ const home = await t.get('/', { as: ada });++ assert.ok(home.text.includes('name="who"') && home.text.includes('value="claude-code|sonnet"') && home.text.includes('Claude Code · Sonnet'), 'the choices in words');++ const made = await t.get('/chat', { as: ada, form: { text: 'A quick fix', repo: 'https://github.com/OpenVibers/OpenVibe.Live', who: 'claude-code|sonnet' } });++ assert.strictEqual(made.status, 303);++ const id = made.headers.get('location').split('/').pop();++ const att = await db.maybe('SELECT agent, model FROM codes_attempts WHERE task_id = $1 ORDER BY n LIMIT 1', [id]);++ assert.ok(att && att.model === 'sonnet', `the model rides on the attempt (${JSON.stringify(att)})`);++ const bad = await t.get('/chat', { as: ada, form: { text: 'x', repo: 'https://github.com/OpenVibers/OpenVibe.Live', who: 'claude-code|gpt-9' } });++ assert.match(bad.headers.get('location'), /Choose%20who%20works%20on%20it/, 'a model Codes does not offer is refused in words');++ const cat = await t.get('/api/v1/harnesses/models');++ assert.strictEqual(cat.status, 200);++ const claude = cat.json().agents.find((a) => a.agent === 'claude-code');++ assert.ok(claude && claude.models.some((m) => m.id === 'opus' && /strongest/.test(m.note)) && claude.efforts.includes('max'), 'the public catalogue says what each offers');++ });++await check('a visitor who is not signed in gets the product’s home, not the app', async () => {const page = await t.get('/');assert.strictEqual(page.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+
+/**
+ * The models a person can pick for a turn (Codes L31 CH5), in words a beginner understands. One entry per agent: the
+ * models whose names stay put (Claude Code's own aliases follow each new release; DeepSeek's two names are its API's),
+ * and otherwise the agent's own default, which its CLI keeps current. The composer offers them as one control, "who
+ * works on it": an agent and its model together. The value is "agent|model" (model empty for the agent's default).
+ *
+ * list(harnesses) → [{ agent, name, models: [{ id, name, note }], efforts }] for the agents Codes knows
+ * choices(harnesses) → [{ value, label, agent, model, group }] for a picker, Auto first
+ * parse(value, harnesses) → { agent, model } or null when it names nothing Codes offers
+ */
+const { EFFORT_LEVELS } = require('../../engine/adapters/base');
+
+const MODELS = {
+ 'claude-code': [
+ { id: 'opus', name: 'Opus', note: 'the strongest: hard changes, design, long work' },
+ { id: 'sonnet', name: 'Sonnet', note: 'fast and capable: most tasks' },
+ { id: 'haiku', name: 'Haiku', note: 'the quickest: small fixes and questions' },
+ ],
+ deepseek: [
+ { id: 'deepseek-chat', name: 'Chat', note: 'quick and very cheap' },
+ { id: 'deepseek-reasoner', name: 'Reasoner', note: 'thinks first: harder problems' },
+ ],
+};
+const DEFAULT = { id: '', name: 'its default', note: 'the model the agent picks for itself' };
+
+function list(harnesses) {
+ return (harnesses?.list?.() || []).map((a) => ({
+ agent: a.id, name: a.name,
+ models: MODELS[a.id] ? MODELS[a.id] : [DEFAULT],
+ efforts: EFFORT_LEVELS[a.id] || [],
+ }));
+}
+
+function choices(harnesses) {
+ const out = [{ value: '', label: 'Auto: Codes picks', agent: null, model: null, group: null }];
+ for (const a of list(harnesses)) {
+ for (const m of a.models) {
+ out.push({ value: `${a.agent}|${m.id}`, label: m.id ? `${a.name} · ${m.name}` : a.name, note: m.note, agent: a.agent, model: m.id || null, group: a.name });
+ }
+ }
+ return out;
+}
+
+function parse(value, harnesses) {
+ const v = String(value || '');
+ if (!v) return { agent: null, model: null };
+ const [agent, model = ''] = v.split('|');
+ const known = list(harnesses).find((a) => a.agent === agent);
+ if (!known) return null;
+ if (model && !known.models.some((m) => m.id === model)) return null;
+ return { agent, model: model || null };
+}
+
+module.exports = { MODELS, list, choices, parse };
@@ -26,6 +26,9 @@ function createApi(ctx) {
@@ -26,6 +26,9 @@ function createApi(ctx) {
const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ // The models each agent offers, in words a beginner understands, with the effort levels it takes (Codes L31 CH5).
+ r.get('/harnesses/models', reads, (req, res) => res.set('Cache-Control', 'public, max-age=300').json({ agents: require('../data/models').list(harnesses) }));
+
r.get('/harnesses', reads, (req, res) => {
r.get('/harnesses', reads, (req, res) => {
res.json({ harnesses: harnesses.list().map((h) => ({ ...h, agents: harnesses.agents(h.id) })) });
res.json({ harnesses: harnesses.list().map((h) => ({ ...h, agents: harnesses.agents(h.id) })) });
});
});
@@ -20,6 +20,7 @@ const { raw, esc } = require('../render/html');
@@ -20,6 +20,7 @@ const { raw, esc } = require('../render/html');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
const { sessionLogOf } = require('../domain/run-tasks');
+const models = require('../data/models');
const LIVE = ['queued', 'placed', 'running'];
const LIVE = ['queued', 'placed', 'running'];
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@ -292,9 +293,13 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</
@@ -292,9 +293,13 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</
}
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
- function agentPicker(selected, { label = 'Agent', auto = 'Auto' } = {}) {
- const list = harnesses?.list?.() || [];
- return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span><select name="agent" data-ch-agent><option value="">${esc(auto)}</option>${list.map((a) => `<option value="${esc(a.id)}"${selected === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
+ function whoPicker(selected, { label = 'Who works on it', auto = null } = {}) {
+ const all = models.choices(harnesses);
+ const groups = new Map();
+ for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }
+ const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}>${esc(c.label)}</option>`;
+ const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);
+ return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
}
// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).
// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).
const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];
const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];
@@ -360,7 +365,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -360,7 +365,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const error = String(req.query.error || '').slice(0, 200);
const error = String(req.query.error || '').slice(0, 200);
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];
const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];
- const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${agentPicker(settings.default_agent || '')}${effortPicker('')}`;
+ const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${whoPicker(settings.default_agent ? `${settings.default_agent}|` : '')}${effortPicker('')}`;
const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;
const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;
return frame(req, res, {
return frame(req, res, {
title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,
title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,
@@ -376,12 +381,13 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -376,12 +381,13 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);
const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);
const text = String(b.text || '').trim().slice(0, 16000);
const text = String(b.text || '').trim().slice(0, 16000);
if (!text) return back('Write what you want done first.');
if (!text) return back('Write what you want done first.');
- const agent = String(b.agent || '').trim();
- if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return back('Choose an agent from the list.');
+ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
+ if (!who) return back('Choose who works on it from the list.');
+ const agent = who.agent || '';
let args;
let args;
try {
try {
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
- args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
+ args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
} catch (e) { return back(`Check the repository: ${e.message}.`); }
} catch (e) { return back(`Check the repository: ${e.message}.`); }
let project = null;
let project = null;
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
@@ -416,7 +422,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -416,7 +422,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
// The stream starts after the last line this page already shows.
// The stream starts after the last line this page already shows.
const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
- const pickers = agentPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
+ const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
return frame(req, res, {
return frame(req, res, {
currentId: row.id, title: titleOf(row), head, side: await sideHtml(row),
currentId: row.id, title: titleOf(row), head, side: await sideHtml(row),
thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await taskThread(row),
thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await taskThread(row),
@@ -437,10 +443,12 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -437,10 +443,12 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);
const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
if (!row) return res.status(404).send('Not found');
const b = req.body || {};
const b = req.body || {};
- const agent = String(b.agent || '').trim() || null;
- if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose an agent from the list.')}`);
+ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
+ if (!who) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose who carries on from the list.')}`);
+ // The same agent with another model carries on; another agent takes over.
+ const agent = who.agent && who.agent !== row.agent ? who.agent : null;
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort) ? b.effort : null;
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort) ? b.effort : null;
- const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, effort });
+ const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, model: who.model, effort });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);
});
});
@@ -78,6 +78,22 @@ const { stepKind } = require('../server/http/chat');
@@ -78,6 +78,22 @@ const { stepKind } = require('../server/http/chat');
assert.ok(home.text.includes('name="effort"') && home.text.includes('>Thorough</option>'), 'the composer offers it in plain words');
assert.ok(home.text.includes('name="effort"') && home.text.includes('>Thorough</option>'), 'the composer offers it in plain words');
});
});
+ await check('who works on it: an agent and its model in one control, kept on the first attempt (L31 CH5)', async () => {
+ const home = await t.get('/', { as: ada });
+ assert.ok(home.text.includes('name="who"') && home.text.includes('value="claude-code|sonnet"') && home.text.includes('Claude Code · Sonnet'), 'the choices in words');
+ const made = await t.get('/chat', { as: ada, form: { text: 'A quick fix', repo: 'https://github.com/OpenVibers/OpenVibe.Live', who: 'claude-code|sonnet' } });
+ assert.strictEqual(made.status, 303);
+ const id = made.headers.get('location').split('/').pop();
+ const att = await db.maybe('SELECT agent, model FROM codes_attempts WHERE task_id = $1 ORDER BY n LIMIT 1', [id]);
+ assert.ok(att && att.model === 'sonnet', `the model rides on the attempt (${JSON.stringify(att)})`);
+ const bad = await t.get('/chat', { as: ada, form: { text: 'x', repo: 'https://github.com/OpenVibers/OpenVibe.Live', who: 'claude-code|gpt-9' } });
+ assert.match(bad.headers.get('location'), /Choose%20who%20works%20on%20it/, 'a model Codes does not offer is refused in words');
+ const cat = await t.get('/api/v1/harnesses/models');
+ assert.strictEqual(cat.status, 200);
+ const claude = cat.json().agents.find((a) => a.agent === 'claude-code');
+ assert.ok(claude && claude.models.some((m) => m.id === 'opus' && /strongest/.test(m.note)) && claude.efforts.includes('max'), 'the public catalogue says what each offers');
+ });
+
await check('a visitor who is not signed in gets the product’s home, not the app', async () => {
await check('a visitor who is not signed in gets the product’s home, not the app', async () => {
const page = await t.get('/');
const page = await t.get('/');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);