openvibers/openvibe.actor Commits

Actor v2 stage V2-5b: follow-ups that keep the question and answer in mind (#195)

OP OpenVibers committed 14 days ago · 3b61249c56 · 10 files, +226 −11

A person asks again under an answer; the new task runs with up to three
answered tasks before it (the same person's) as the conversation it
continues, and its plan is made with them too. An earlier answer's [E#]
citations belonged to its own run, so they are taken out, and the loop is
told to fetch again any live fact it cites.

- migrations/0016_task_follow_ups.sql (expand): tasks.parent_id, set NULL
  when the parent goes; exported and erased with the person.
- POST /tasks/:id/follow-up: signed in, the task's own person (else 404),
  same origin (else 403), after an answer only, in the parent's mode and
  inside the same allowance as any task.
- The page: one composer under the answer (it grows; Ctrl/Cmd+Enter sends),
  the follow-ups listed above it, and "Following up on …" on each one.

Changes

ddiff --git a/migrations/0016_task_follow_ups.sql b/migrations/0016_task_follow_ups.sql
nnew file mode 100644
iindex 0000000..19ed850
---- /dev/null
++++ b/migrations/0016_task_follow_ups.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Actor v2, stage V2-5b: a follow-up is a task that continues another, with that task's question and answer in mind.
++--
++-- parent_id is the task it follows up on, always the same person's (server/http/pages.js checks it before the
++-- insert); NULL for a task that starts fresh. When a parent goes (retention, the person's deletion), its follow-ups
++-- stay as tasks of their own.
++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_id text COLLATE "C" REFERENCES tasks (id) ON DELETE SET NULL;
++CREATE INDEX IF NOT EXISTS tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
ddiff --git a/public/css/task.css b/public/css/task.css
iindex 6c60264..edcf5ec 100644
---- a/public/css/task.css
++++ b/public/css/task.css
@@@ -66,4 +66,20 @@
.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }
.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }
++/* Follow-ups (stage V2-5b): the thread above, one composer under the answer. */
++.follows-on { margin: 0 0 .3rem; font-size: .9rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
++.follows-on a { font-weight: 650; }
++.follow { margin: -0.6rem 0 1.6rem; }
++.follow > h2 { margin: 0 0 .5rem; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.follow-list { list-style: none; margin: 0 0 .7rem; padding: 0; display: grid; gap: 6px; }
++.follow-list li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 10px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.follow-list a { min-width: 0; flex: 1 1 14rem; font-weight: 650; overflow-wrap: anywhere; text-decoration: none; }
++.follow-list a:hover { text-decoration: underline; }
++.follow-form { display: flex; align-items: flex-end; gap: 8px; padding: 6px 6px 6px 14px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); transition: border-color .15s, box-shadow .15s; }
++.follow-form:focus-within { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
++.follow-form textarea { flex: 1; min-width: 0; min-height: 40px; max-height: 40vh; padding: 9px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; line-height: 1.45; }
++.follow-form textarea::placeholder { color: var(--text-muted, #7386a3); }
++.follow-form .sc-btn { flex: none; min-height: 40px; padding: 0 18px; border-radius: 12px; }
++.follow-hint { margin: .45rem 0 0 4px; font-size: .82rem; color: var(--text-muted, #7386a3); }
++
@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }
ddiff --git a/public/js/task.js b/public/js/task.js
iindex d0379c3..992b49b 100644
---- a/public/js/task.js
++++ b/public/js/task.js
@@@ -90,8 +90,22 @@
if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }
}
++ // The follow-up box grows with what is typed (up to 40% of the screen), and Ctrl/Cmd+Enter sends it.
++ function followBox() {
++ var box = document.getElementById('follow-text');
++ if (!box || box.dataset.bound) return;
++ box.dataset.bound = '1';
++ var fit = function () { box.style.height = 'auto'; box.style.height = Math.min(box.scrollHeight, Math.round(window.innerHeight * 0.4)) + 'px'; };
++ box.addEventListener('input', fit);
++ box.addEventListener('keydown', function (e) {
++ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && box.value.trim() && box.form) { e.preventDefault(); if (box.form.requestSubmit) box.form.requestSubmit(); else box.form.submit(); }
++ });
++ if (location.hash === '#follow-up') { try { box.focus({ preventScroll: true }); } catch (err) { box.focus(); } }
++ }
++
function start() {
evidence();
++ followBox();
var art0 = document.querySelector('article.task');
var view = art0 ? session(art0) : null;
var art = document.querySelector('article.task[data-open="1"]');
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d6f60f5..6a73358 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -334,6 +334,28 @@ ${raw(showcase.faq({ id: 'faq', items: faq }))}
return res.redirect(303, `/tasks/${out.task.id}`);
});
++ // A follow-up (Actor v2, stage V2-5b): a new task of the same person's that continues an answered one, with its
++ // question and answer in mind (store.followContext). Only from the person's own page on Actor, in the parent's mode,
++ // inside the same allowance as any task.
++ const FOLLOW_ERROR = {
++ 'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
++ 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
++ 'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',
++ };
++ r.post('/tasks/:id/follow-up', require('express').urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.redirect(303, '/tasks');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A follow-up must be sent from openvibe.actor itself.');
++ const parent = await store.getTask(s, req.params.id);
++ if (!parent || parent.requester !== requesterOf(req)) return res.status(404).type('text/plain').send('No such task');
++ if (parent.state !== 'succeeded') return res.redirect(303, `/tasks/${parent.id}`);
++ const text = String((req.body && req.body.task) || '').trim().slice(0, 20000);
++ if (!text) return res.redirect(303, `/tasks/${parent.id}#follow-up`);
++ const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode: parent.mode }, parentId: parent.id });
++ if (out.code) return res.redirect(303, `/tasks/${parent.id}?follow=${encodeURIComponent(out.code)}#follow-up`);
++ return res.redirect(303, `/tasks/${out.task.id}`);
++ });
++
// ── Your tasks ───────────────────────────────────────────
r.get('/tasks', async (req, res) => {
if (!signedIn(req)) {
@@@ -398,6 +420,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const desk = open ? store.deskFrom(events) : null;
const look = looks.get(row.id);
++ const parentRow = row.parent_id ? await store.getTask(s, row.parent_id) : null;
++ const parentTask = parentRow && parentRow.requester === row.requester ? parentRow : null;
++ const follows = t.state === 'succeeded' ? await store.followUps(s, row) : [];
++ const followProblem = typeof req.query.follow === 'string' ? (FOLLOW_ERROR[req.query.follow] || 'That follow-up could not start. Try again in a moment.') : null;
const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)
|| (planEvent ? taskEvents.plan(planEvent.text) : null);
@@@ -415,9 +441,11 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">
<header class="task-head">
<p class="task-status"><span class="pill pill-${t.state}">${STATE_TEXT[t.state]}</span>${kind ? html`<span class="chip">${kind}</span>` : ''}<span class="chip">${MODE_LABEL[t.mode]}</span><span class="chip" id="task-cost">${t.cost ? usd(t.cost.usd) : '$0'} of ${usd(t.budget.per_task_usd)}</span>${time(t.created_at)}</p>
++${parentTask ? html`<p class="follows-on">Following up on <a href="/tasks/${parentTask.id}">${parentTask.task.length > 90 ? `${parentTask.task.slice(0, 89)}…` : parentTask.task}</a></p>` : ''}
<h1 class="task-text">${row.task}</h1>
</header>
${outcome(t, row)}
++${t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}
${open ? planCard(plan) : ''}
${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
@@@ -464,6 +492,25 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
</section>`;
}
++ /**
++ * Ask a follow-up: one box under the answer, the follow-ups already asked above it. The new task keeps this question
++ * and answer in mind and is checked the same way. It is a plain form: no script needed.
++ */
++ function followBlock(row, follows, problem) {
++ const STATE = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Not finished', cancelled: 'Cancelled' };
++ return html`<section class="follow" id="follow-up" aria-labelledby="h-follow">
++<h2 id="h-follow"${follows.length ? '' : raw(' class="sr-only"')}>${follows.length ? 'Follow-ups' : 'Ask a follow-up'}</h2>
++${follows.length ? html`<ol class="follow-list">${follows.map((f) => html`<li><a href="/tasks/${f.id}">${f.task.length > 120 ? `${f.task.slice(0, 119)}…` : f.task}</a> <span class="pill pill-${f.state}">${STATE[f.state] || f.state}</span></li>`)}</ol>` : ''}
++${problem ? html`<p class="notice warn" role="alert">${problem}</p>` : ''}
++<form method="post" action="/tasks/${row.id}/follow-up" class="follow-form">
++<label for="follow-text" class="sr-only">Ask a follow-up</label>
++<textarea id="follow-text" name="task" rows="1" maxlength="20000" required placeholder="Ask a follow-up…"></textarea>
++<button class="sc-btn sc-primary" type="submit">Ask</button>
++</form>
++<p class="follow-hint">It keeps this question and answer in mind, and checks its answer the same way.</p>
++</section>`;
++ }
++
/** The outcome card: the first thing under the task. */
function outcome(t, row) {
if (t.state === 'succeeded') return resultBlock(t.result, t);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 5fe27ab..13c56a6 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -35,7 +35,7 @@ const TABLES = [
{
table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
-- 'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks'],
++ 'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id'],
},
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex d3ca32c..3f47b1a 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -116,6 +116,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const selfReflections = loopCfg.selfReflections == null ? 2 : loopCfg.selfReflections;
/** The plan: a fast-tier call when one is available, else one honest step from the task itself. */
++ // A follow-up is planned with what it follows: "and for 8 GB?" means nothing on its own.
++ const FOLLOW_NOTE = 'This follows up on the conversation above. Citations in the earlier answers belong to earlier runs: fetch again any live fact you cite.';
++ function planInput(state) {
++ if (!state.context.length) return state.task;
++ return `Earlier in this conversation:\n${state.context.map((c) => `Asked: ${clip(c.task, 600)}\nAnswered: ${clip(c.answer, 1200)}`).join('\n\n')}\n\nNow, a follow-up: ${state.task}`;
++ }
++
async function makePlan(state, ctx) {
const fallback = () => ({ assumptions: [], steps: [{ goal: clip(state.task.replace(/\s+/g, ' ').trim(), 200) || 'Do the task', state: 'pending' }] });
let plan = fallback();
@@@ -125,7 +132,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
tier: 'fast',
messages: [
{ role: 'system', content: `You plan one task for an agent that can call tools. Reply with JSON only: {"assumptions":["..."],"steps":[{"goal":"..."}]}. Give 2 to 5 short sub-goals in the order you will do them. An assumption is only what you must suppose and the task does not say; keep the list empty when there is none. Today is ${today(now)}.` },
-- { role: 'user', content: state.task.slice(0, 6000) },
++ { role: 'user', content: planInput(state).slice(0, 6000) },
],
json: true, maxTokens: 400, signal: state.signal,
});
@@@ -247,7 +254,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
}
-- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {
++ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, context = [] }) {
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
// Files and charts made in this run are stored privately for the person the task is for, in Actor's own
@@@ -262,13 +269,15 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const state = {
task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,
calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,
-- ctx,
++ ctx, context: Array.isArray(context) ? context.filter((c) => c && c.task && c.answer).slice(-3) : [],
};
try {
state.plan = await makePlan(state, ctx);
await emitPlan(state, ctx);
state.messages.push({ role: 'system', content: SYSTEM({ date: today(now), plan: planText(state.plan), maxCalls }) });
-- state.messages.push({ role: 'user', content: state.task });
++ // A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.
++ for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });
++ state.messages.push({ role: 'user', content: state.context.length ? `${FOLLOW_NOTE}\n\n${state.task}` : state.task });
const tools = registry.definitions({ requester, mode, config, models, signal, artifacts });
// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.
const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
iindex ecaf37e..9474eba 100644
---- a/server/tasks/create.js
++++ b/server/tasks/create.js
@@@ -18,7 +18,7 @@ function untilMidnight(nowMs) {
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
}
--async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null }) {
++async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, parentId = null }) {
const v = contracts.validate('actor.task-create-request@1', body);
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
const tier = config.allowance;
@@@ -69,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const row = await store.insertTask(s, {
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
-- webhooks, webhook_secret: ownSecret ? mintSecret() : null,
++ webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,
// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.
timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,
});
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex ad055a4..9e4a0a6 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -156,6 +156,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
try {
out = await loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
++ context: row.parent_id ? await store.followContext(s, row) : [],
emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }),
meter, budgetLeft, waitForPerson, requestApproval,
});
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex 3a0dda7..ee36cbb 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -22,15 +22,38 @@ function subjectRef(requester) {
async function insertTask(s, t) {
await s.db.query(
-- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15)`,
++ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16)`,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
-- t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null]);
++ t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null]);
await addSpend(s, t.requester, 0, { tasks: 1 });
return getTask(s, t.id);
}
const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);
++
++/**
++ * The conversation a follow-up continues (Actor v2, stage V2-5b): up to `depth` tasks before it, oldest first, each its
++ * question and its answer, only the same person's, only answered ones. An answer's [E#] citations belonged to its own
++ * run, so they are taken out: the new run cites only what it fetches itself. Each answer is clipped to `clip` chars.
++ */
++async function followContext(s, row, { depth = 3, clip = 3000 } = {}) {
++ const out = [];
++ let at = row && row.parent_id;
++ for (let i = 0; at && i < depth; i++) {
++ const p = await getTask(s, at);
++ if (!p || p.requester !== row.requester) break;
++ const result = p.result ? (typeof p.result === 'string' ? JSON.parse(p.result) : p.result) : null;
++ if (p.state === 'succeeded' && result && result.answer) {
++ const answer = String(result.answer).replace(/\s*\[E\d{1,3}(?:\s*,\s*E\d{1,3})*\]/g, '');
++ out.unshift({ task: String(p.task), answer: answer.length > clip ? `${answer.slice(0, clip - 1)}…` : answer });
++ }
++ at = p.parent_id;
++ }
++ return out;
++}
++/** The follow-ups of a task, oldest first: { id, task, state, created_at }. */
++const followUps = (s, row) => s.db.many('SELECT id, task, state, created_at FROM tasks WHERE parent_id = $1 AND requester = $2 ORDER BY created_at, id LIMIT 20', [row.id, row.requester]);
const byIdempotency = (s, requester, key) => s.db.maybe('SELECT * FROM tasks WHERE requester = $1 AND idem_key = $2', [requester, key]);
/** A requester's tasks, newest first; `via` narrows to the ones that service started for them. */
@@@ -205,4 +228,4 @@ function toWire(row, { baseUrl }) {
return t;
}
--module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };
++module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps };
ddiff --git a/test/task-follow-ups.test.js b/test/task-follow-ups.test.js
nnew file mode 100644
iindex 0000000..eec910f
---- /dev/null
++++ b/test/task-follow-ups.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++// Follow-ups (Actor v2, stage V2-5b): a person asks again under an answer, and the new task runs with that question and
++// answer in mind (its plan too), without the old run's citations; only the task's own person may, only from Actor's
++// own page, only after an answer; the two pages link to each other, and the column goes with the person's data.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const bo = t.network.addUser('bo');
++ t.config.loop.selfReflections = 0;
++ const s = t.ctx.s;
++ const answered = async (task, answer) => {
++ t.providers.state.script = [{ content: answer }];
++ const created = (await t.get('/api/v1/tasks', { as: kim, json: { task }, headers: SAME })).json();
++ await t.waitFor(created.id);
++ const row = await s.db.maybe('SELECT state FROM tasks WHERE id = $1', [created.id]);
++ assert.strictEqual(row.state, 'succeeded');
++ return created.id;
++ };
++ const follow = (id, text, o = {}) => t.get(`/tasks/${id}/follow-up`, { as: o.as || kim, form: { task: text }, headers: o.headers || SAME });
++
++ try {
++ const first = await answered('Which VPS is cheapest for 2 vCPU and 4 GB?', 'Hetzner CX22 at €4.51 a month [E1], then Netcup [E2, E3].');
++
++ await check('a follow-up is a new task that runs with the question and answer before it, citations taken out', async () => {
++ t.providers.reset();
++ t.config.loop.selfReflections = 0;
++ t.providers.state.script = [{ content: 'With 8 GB, Hetzner CX32 at €7.59.' }];
++ const r = await follow(first, 'And for 8 GB?');
++ assert.strictEqual(r.status, 303);
++ const next = ID.exec(r.headers.get('location'))[1];
++ await t.waitFor(next);
++ const row = await s.db.maybe('SELECT parent_id, requester, mode, state FROM tasks WHERE id = $1', [next]);
++ assert.deepStrictEqual([row.parent_id, row.state, row.mode], [first, 'succeeded', 'balanced'], 'the same person\'s task, in the parent\'s mode');
++ const sent = t.providers.requests.map((q) => JSON.stringify(q.body || {}));
++ const loopCall = sent.find((b) => b.includes('This follows up on the conversation above'));
++ assert.ok(loopCall, 'the loop is told it is a follow-up, and to fetch live facts again');
++ assert.ok(loopCall.includes('And for 8 GB?') && loopCall.includes('Hetzner CX22 at €4.51 a month'), 'with the earlier answer and the new question');
++ assert.ok(loopCall.includes('{"role":"assistant","content":"Hetzner CX22 at €4.51 a month, then Netcup."}'), 'the earlier answer as its turn, without the earlier run\'s citations');
++ assert.ok(sent.some((b) => b.includes('Earlier in this conversation') && b.includes('Now, a follow-up: And for 8 GB?')), 'the plan is made with what it follows');
++ });
++
++ await check('the two pages link each other: the follow-ups under the answer, the thread above the follow-up', async () => {
++ const kids = await s.db.many('SELECT id FROM tasks WHERE parent_id = $1', [first]);
++ const parentPage = (await t.get(`/tasks/${first}`, { as: kim })).text;
++ assert.ok(parentPage.includes('<section class="follow" id="follow-up"'), 'the follow-up box');
++ assert.ok(parentPage.includes(`action="/tasks/${first}/follow-up"`) && parentPage.includes('name="task"'), 'a plain form, no script needed');
++ assert.ok(parentPage.includes(`<a href="/tasks/${kids[0].id}">And for 8 GB?</a>`), 'the follow-up asked, listed above the box');
++ const childPage = (await t.get(`/tasks/${kids[0].id}`, { as: kim })).text;
++ assert.ok(childPage.includes(`Following up on <a href="/tasks/${first}">Which VPS is cheapest`), 'and it links back');
++ });
++
++ await check('only the task\'s own person, only from Actor, only after an answer, never empty', async () => {
++ const before = Number(await s.db.value('SELECT count(*) FROM tasks'));
++ assert.strictEqual((await follow(first, 'mine now', { as: bo })).status, 404, 'another person: the 404 of a task that does not exist');
++ assert.strictEqual((await follow(first, 'from elsewhere', { headers: { 'sec-fetch-site': 'cross-site' } })).status, 403, 'another site cannot post one');
++ const empty = await follow(first, ' ');
++ assert.ok(empty.status === 303 && /#follow-up$/.test(empty.headers.get('location')), 'nothing to ask: back to the box');
++ await s.db.query("UPDATE tasks SET state = 'failed' WHERE id = $1", [first]);
++ const unanswered = await follow(first, 'and then?');
++ assert.ok(unanswered.status === 303 && unanswered.headers.get('location').endsWith(`/tasks/${first}`), 'no answer yet, nothing to follow');
++ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [first]);
++ assert.strictEqual(Number(await s.db.value('SELECT count(*) FROM tasks')), before, 'none of those made a task');
++ });
++
++ await check('the conversation reaches back three answers at most, and never into another person\'s task', async () => {
++ const { followContext } = require('../server/tasks/store');
++ const ids = [first];
++ for (let i = 0; i < 4; i++) {
++ t.providers.state.script = [{ content: `Answer ${i + 2}.` }];
++ const r = await follow(ids[ids.length - 1], `Question ${i + 2}?`);
++ const id = ID.exec(r.headers.get('location'))[1];
++ await t.waitFor(id);
++ ids.push(id);
++ }
++ const last = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [ids[ids.length - 1]]);
++ const ctx = await followContext(s, last);
++ assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?'], 'the three before it, oldest first');
++ await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[3], `user:${bo.subject}`]);
++ assert.deepStrictEqual((await followContext(s, last)).map((c) => c.task), [], 'a parent that is not the same person\'s ends the thread');
++ });
++
++ await check('the parent column goes with the person\'s data', async () => {
++ const ad = require('../server/identity/account-data');
++ const spec = JSON.stringify(ad.TABLES || ad.tables || ad);
++ assert.ok(spec.includes('parent_id'), 'exported and erased with the rest of the task');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });