openvibers/openvibe.codes Commits
Raise the ask as a resumable event; the chain waits on the person (#406)
Changes
nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++/**++ * One structured question awaiting a person: `agent.attention@1`, raised, answered and resumed.++ *++ * A worker that cannot proceed without a person raises an attention instead of failing: one question, the evidence++ * it holds, the options it offers, and the session the answer must resume. Authorised independent work may continue++ * meanwhile; the answer resumes the same session (engine/chain.js reads the open record and says `attention`,++ * never `failed`). The person's answer arrives from the phone path; Codes records it here and hands the session++ * back. The contract shape is OpenVibe.Contracts' `agent/attention.v1.json`; the evidence, options and session ride++ * alongside it and are projected off by toContract.++ *++ * raise({ ticket_id, question, evidence, options, session }) the open record++ * answer(record, { answer, by }) the answered record++ * dismiss(record, { note }) the dismissed record++ * isOpen(record) an unanswered, undismissed record++ * resumeSession(record) the session the answer resumes, or null++ * toContract(record) the attention.v1 shape++ * isValidContract(doc) { valid, errors } for the attention.v1 shape++ * STATES open, answered, dismissed++ */++++const STATES = ['open', 'answered', 'dismissed'];++const MAX_TEXT = 4000;++++let counter = 0;++++const isText = (v, max = MAX_TEXT) => typeof v === 'string' && v.trim().length > 0 && (!max || v.length <= max);++const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);++const stamp = (v) => (v === undefined ? new Date().toISOString() : String(v));++++/** The open record: one question, what backs it, what the person can pick, and the session it resumes. */++function raise({ id = null, ticket_id = null, question = null, evidence = [], options = [], session = null, created_at } = {}) {++ if (!isText(ticket_id, 200)) throw new Error('an attention needs the ticket it blocks');++ if (!isText(question)) throw new Error('an attention needs the question');++ if (evidence !== undefined && !Array.isArray(evidence)) throw new Error('attention evidence is a list');++ if (options !== undefined && !Array.isArray(options)) throw new Error('attention options is a list');++ if (session !== undefined && session !== null && !isText(session, 500)) throw new Error('an attention session is a short string');++ return {++ id: isText(id, 200) ? String(id) : `att_${Date.now().toString(36)}_${(counter++).toString(36)}`,++ ticket_id: String(ticket_id),++ question: String(question),++ evidence: (Array.isArray(evidence) ? evidence : []).filter(isObject).slice(0, 10),++ options: (Array.isArray(options) ? options : []).filter((o) => typeof o === 'string' && o.trim()).map((o) => String(o)).slice(0, 10),++ ...(session ? { session: String(session) } : {}),++ state: 'open',++ created_at: stamp(created_at),++ };++}++++/** The answer, recorded on the open record. Answering anything but an open record is a mistake, said out loud. */++function answer(record, { answer: text = null, by = null, answered_at } = {}) {++ if (!isOpen(record)) throw new Error('only an open attention can be answered');++ if (!isText(text)) throw new Error('an attention answer is non-empty text');++ return {++ ...record,++ state: 'answered',++ answer: String(text),++ ...(by !== undefined && by !== null ? { answered_by: String(by) } : {}),++ answered_at: stamp(answered_at),++ };++}++++/** Dismissed: the question is withdrawn, and the session is free. */++function dismiss(record, { note = null } = {}) {++ if (!isOpen(record)) throw new Error('only an open attention can be dismissed');++ return { ...record, state: 'dismissed', ...(note ? { dismiss_note: String(note).slice(0, 1000) } : {}) };++}++++/** An unanswered, undismissed record. */++function isOpen(record) {++ return !!record && record.state === 'open' && isText(record.question) && isText(record.ticket_id);++}++++/** The session the answer must resume: the record's own, the same session the worker waited in. */++function resumeSession(record) {++ if (!isObject(record)) return null;++ return record.session && String(record.session).trim() ? String(record.session) : null;++}++++/** The contract shape: what attention.v1.json carries, nothing Codes-only. */++function toContract(record) {++ if (!isObject(record)) throw new Error('an attention record is an object');++ const doc = {++ id: String(record.id || ''),++ ticket_id: String(record.ticket_id || ''),++ question: String(record.question || ''),++ state: record.state,++ created_at: String(record.created_at || ''),++ };++ if (record.answer !== undefined) doc.answer = String(record.answer);++ return doc;++}++++/** The attention.v1 shape, structurally: the required fields, the closed state list, the optional answer. */++function isValidContract(doc) {++ const errors = [];++ if (!isObject(doc)) return { valid: false, errors: ['an attention must be an object'] };++ for (const k of ['id', 'ticket_id', 'question', 'state', 'created_at']) {++ if (!isText(doc[k], k === 'question' ? MAX_TEXT : 500)) errors.push(`attention.${k} must be non-empty text`);++ }++ if (!STATES.includes(doc.state)) errors.push(`attention.state must be one of ${STATES.join(', ')}`);++ if (doc.answer !== undefined && !isText(doc.answer)) errors.push('attention.answer must be non-empty text when present');++ if (Number.isNaN(Date.parse(doc.created_at))) errors.push('attention.created_at must be a date-time');++ const allowed = ['id', 'ticket_id', 'question', 'state', 'created_at', 'answer'];++ for (const k of Object.keys(doc)) if (!allowed.includes(k)) errors.push(`attention.${k} is not an attention field`);++ return { valid: errors.length === 0, errors };++}++++module.exports = {++ STATES,++ raise, answer, dismiss, isOpen, resumeSession, toContract, isValidContract,++};@@@ -398,7 +398,7 @@ function detect(attempt = {}) {** @param {object[]} attempts the attempts (annotated or not)* @param {object} [opts] what the caller knows: { cfg, health, counts, models, runners, tier }-- * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, failed++ * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, attention, failed*/function nextStep(attempts, opts = {}) {const runs = annotate(attempts);@@@ -432,6 +432,18 @@ function settledParent(runs, end) {/** The next step, given the chain's end. */function step(runs, end, opts = {}) {if (!end) return { action: 'none', why: 'there are no attempts' };++ // An open question waits for the person, never fails the task: the answer resumes the same session, and++ // authorised independent work may continue meanwhile. It comes first: a worker blocked on a person is waiting.++ if (end.attention && end.attention.state === 'open') {++ const session = (end.attention.session && String(end.attention.session)) || facts(end).session || null;++ return {++ action: 'attention',++ ...(end.attention.id ? { attention: end.attention.id } : {}),++ ...(end.attention.question ? { question: String(end.attention.question).slice(0, 500) } : {}),++ ...(session ? { session } : {}),++ why: 'the worker asked a question and waits for the answer; the answer resumes the same session',++ };++ }if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };// AL1: a worker's "done" is a proposal, not an outcome. Nothing is done until an independent verifier accepted the// goal's evidence; a rejection (or a verdict that could not be made) goes back to the same session.@@@ -22,6 +22,7 @@* engine.finding one finding in the agent.finding@1 shape: confirmed, a lead that needs validation, or rejected* engine.findings a review's findings across revisions: stable ids, ACTIVE/RESOLVED and the lifecycle line* engine.verifier a fresh verifier per confirmed record, with a refute prompt++ * engine.attention one question awaiting a person: raised, answered and resumed (agent.attention@1)** The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.*/@@@ -45,6 +46,7 @@ const review = require('./review');const finding = require('./finding');const findings = require('./findings');const verifier = require('./verifier');++const attention = require('./attention');const goal = require('./goal');module.exports = {@@@ -67,6 +69,7 @@ module.exports = {finding,findings,verifier,++ attention,goal,// The names a caller usually wants, in one place.classify: router.classify,nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++++/**++ * Release 2 item 5: the ask as a resumable event (agent.attention@1).++ *++ * A worker that needs a person raises one structured question instead of failing. The chain answers `attention`++ * with the session the answer resumes; the answered or dismissed record falls back to the ordinary flow. The record++ * projects to the attention.v1 contract shape with nothing Codes-only in it.++ */++const assert = require('assert');++const attention = require('../engine/attention');++const chain = require('../engine/chain');++const { check, done } = require('./helpers/boot');++++const now = Date.now();++const job = (runner, name, status, at, extra = {}) => ({++ key: `${runner}:${name}`, runner, name, runnerName: runner.toUpperCase(),++ status, updated: at, error: status === 'done' ? '' : 'it broke', ...extra,++});++++(async () => {++ try {++ await check('an attention is raised open, with its evidence, options and session', () => {++ const rec = attention.raise({++ ticket_id: 't-1', question: 'Ship it or fix the test first?',++ evidence: [{ kind: 'command', summary: 'the red test', command: 'ov test red.test.js' }],++ options: ['ship it', 'fix first'], session: 'sess-9',++ });++ assert.ok(rec.id.startsWith('att_'));++ assert.strictEqual(rec.state, 'open');++ assert.strictEqual(rec.session, 'sess-9');++ assert.deepStrictEqual(rec.options, ['ship it', 'fix first']);++ assert.strictEqual(rec.evidence.length, 1);++ assert.ok(!Number.isNaN(Date.parse(rec.created_at)));++ assert.strictEqual(attention.isOpen(rec), true);++ assert.strictEqual(attention.resumeSession(rec), 'sess-9');++ });++++ await check('raising needs the ticket and the question', () => {++ assert.throws(() => attention.raise({ question: 'q?' }), /ticket/);++ assert.throws(() => attention.raise({ ticket_id: 't' }), /question/);++ assert.throws(() => attention.raise({ ticket_id: 't', question: ' ' }), /question/);++ });++++ await check('the answer and the dismissal close the record, and only an open one moves', () => {++ const rec = attention.raise({ ticket_id: 't', question: 'Which region?' });++ const answered = attention.answer(rec, { answer: 'eu', by: 'owner' });++ assert.strictEqual(answered.state, 'answered');++ assert.strictEqual(answered.answer, 'eu');++ assert.strictEqual(attention.isOpen(answered), false);++ assert.throws(() => attention.answer(answered, { answer: 'us' }), /only an open attention/);++ const dismissed = attention.dismiss(attention.raise({ ticket_id: 't', question: 'Still want this?' }), { note: 'asked twice' });++ assert.strictEqual(dismissed.state, 'dismissed');++ assert.throws(() => attention.dismiss(dismissed), /only an open attention/);++ assert.throws(() => attention.answer(rec, { answer: ' ' }), /non-empty/);++ });++++ await check('the record projects to the attention.v1 contract shape', () => {++ const rec = attention.raise({ id: 'att_1', ticket_id: 't', question: 'Go?', session: 's' });++ const doc = attention.toContract(rec);++ assert.deepStrictEqual(Object.keys(doc).sort(), ['created_at', 'id', 'question', 'state', 'ticket_id']);++ assert.deepStrictEqual(attention.isValidContract(doc), { valid: true, errors: [] });++ assert.deepStrictEqual(attention.isValidContract(attention.toContract(attention.answer(rec, { answer: 'yes' }))).valid, true);++ assert.strictEqual(attention.isValidContract({ ...doc, state: 'waiting' }).valid, false);++ assert.strictEqual(attention.isValidContract({ ...doc, question: '' }).valid, false);++ assert.strictEqual(attention.isValidContract({ ...doc, session: 's' }).valid, false, 'nothing Codes-only crosses into the contract');++ });++++ await check('an open question is attention with its session, never a failure', () => {++ const rec = attention.raise({ id: 'att_7', ticket_id: 't', question: 'Ship it?', session: 'sess-9' });++ const attempts = [job('cx', 'ask', 'failed', now - 1000, { attention: rec, facts: { session: 'sess-9' } })];++ const s = chain.nextStep(attempts);++ assert.strictEqual(s.action, 'attention');++ assert.strictEqual(s.session, 'sess-9');++ assert.strictEqual(s.attention, 'att_7');++ assert.ok(s.question.includes('Ship it'));++ });++++ await check('the answer resumes the same session: answered and dismissed fall back to the ordinary flow', () => {++ const cfg = { subagents: { cx: { enabled: true, max: 2 } }, fallback: { order: ['cx'] } };++ const ctx = { cfg, health: { cx: { state: 'ok' } }, counts: {}, models: {}, runners: ['cx'] };++ const rec = attention.raise({ id: 'att_8', ticket_id: 't', question: 'Ship it?' });++ const answered = [job('cx', 'ask', 'failed', now - 1000, { attention: attention.answer(rec, { answer: 'yes' }) })];++ assert.notStrictEqual(chain.nextStep(answered, ctx).action, 'attention', 'an answered question is not waited on');++ const dismissed = [job('cx', 'ask', 'failed', now - 1000, { attention: attention.dismiss(attention.raise({ ticket_id: 't', question: 'q?' })) })];++ assert.notStrictEqual(chain.nextStep(dismissed, ctx).action, 'attention', 'a dismissed question is not waited on');++ });++++ await check('the open question wins over the failure handling, and carries the run session when its own is missing', () => {++ const rec = attention.raise({ ticket_id: 't', question: 'Which model?' });++ const s = chain.nextStep([job('cx', 'ask', 'failed', now - 1000, { attention: rec, facts: { session: 'sess-3' } })]);++ assert.strictEqual(s.action, 'attention');++ assert.strictEqual(s.session, 'sess-3', 'the run session carries the resume when the record names none');++ });++++ console.log('attention: one structured question, answered in the same session, contracted');++ } catch (e) {++ console.error(e);++ process.exit(1);++ }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * One structured question awaiting a person: `agent.attention@1`, raised, answered and resumed.
+ *
+ * A worker that cannot proceed without a person raises an attention instead of failing: one question, the evidence
+ * it holds, the options it offers, and the session the answer must resume. Authorised independent work may continue
+ * meanwhile; the answer resumes the same session (engine/chain.js reads the open record and says `attention`,
+ * never `failed`). The person's answer arrives from the phone path; Codes records it here and hands the session
+ * back. The contract shape is OpenVibe.Contracts' `agent/attention.v1.json`; the evidence, options and session ride
+ * alongside it and are projected off by toContract.
+ *
+ * raise({ ticket_id, question, evidence, options, session }) the open record
+ * answer(record, { answer, by }) the answered record
+ * dismiss(record, { note }) the dismissed record
+ * isOpen(record) an unanswered, undismissed record
+ * resumeSession(record) the session the answer resumes, or null
+ * toContract(record) the attention.v1 shape
+ * isValidContract(doc) { valid, errors } for the attention.v1 shape
+ * STATES open, answered, dismissed
+ */
+
+const STATES = ['open', 'answered', 'dismissed'];
+const MAX_TEXT = 4000;
+
+let counter = 0;
+
+const isText = (v, max = MAX_TEXT) => typeof v === 'string' && v.trim().length > 0 && (!max || v.length <= max);
+const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+const stamp = (v) => (v === undefined ? new Date().toISOString() : String(v));
+
+/** The open record: one question, what backs it, what the person can pick, and the session it resumes. */
+function raise({ id = null, ticket_id = null, question = null, evidence = [], options = [], session = null, created_at } = {}) {
+ if (!isText(ticket_id, 200)) throw new Error('an attention needs the ticket it blocks');
+ if (!isText(question)) throw new Error('an attention needs the question');
+ if (evidence !== undefined && !Array.isArray(evidence)) throw new Error('attention evidence is a list');
+ if (options !== undefined && !Array.isArray(options)) throw new Error('attention options is a list');
+ if (session !== undefined && session !== null && !isText(session, 500)) throw new Error('an attention session is a short string');
+ return {
+ id: isText(id, 200) ? String(id) : `att_${Date.now().toString(36)}_${(counter++).toString(36)}`,
+ ticket_id: String(ticket_id),
+ question: String(question),
+ evidence: (Array.isArray(evidence) ? evidence : []).filter(isObject).slice(0, 10),
+ options: (Array.isArray(options) ? options : []).filter((o) => typeof o === 'string' && o.trim()).map((o) => String(o)).slice(0, 10),
+ ...(session ? { session: String(session) } : {}),
+ state: 'open',
+ created_at: stamp(created_at),
+ };
+}
+
+/** The answer, recorded on the open record. Answering anything but an open record is a mistake, said out loud. */
+function answer(record, { answer: text = null, by = null, answered_at } = {}) {
+ if (!isOpen(record)) throw new Error('only an open attention can be answered');
+ if (!isText(text)) throw new Error('an attention answer is non-empty text');
+ return {
+ ...record,
+ state: 'answered',
+ answer: String(text),
+ ...(by !== undefined && by !== null ? { answered_by: String(by) } : {}),
+ answered_at: stamp(answered_at),
+ };
+}
+
+/** Dismissed: the question is withdrawn, and the session is free. */
+function dismiss(record, { note = null } = {}) {
+ if (!isOpen(record)) throw new Error('only an open attention can be dismissed');
+ return { ...record, state: 'dismissed', ...(note ? { dismiss_note: String(note).slice(0, 1000) } : {}) };
+}
+
+/** An unanswered, undismissed record. */
+function isOpen(record) {
+ return !!record && record.state === 'open' && isText(record.question) && isText(record.ticket_id);
+}
+
+/** The session the answer must resume: the record's own, the same session the worker waited in. */
+function resumeSession(record) {
+ if (!isObject(record)) return null;
+ return record.session && String(record.session).trim() ? String(record.session) : null;
+}
+
+/** The contract shape: what attention.v1.json carries, nothing Codes-only. */
+function toContract(record) {
+ if (!isObject(record)) throw new Error('an attention record is an object');
+ const doc = {
+ id: String(record.id || ''),
+ ticket_id: String(record.ticket_id || ''),
+ question: String(record.question || ''),
+ state: record.state,
+ created_at: String(record.created_at || ''),
+ };
+ if (record.answer !== undefined) doc.answer = String(record.answer);
+ return doc;
+}
+
+/** The attention.v1 shape, structurally: the required fields, the closed state list, the optional answer. */
+function isValidContract(doc) {
+ const errors = [];
+ if (!isObject(doc)) return { valid: false, errors: ['an attention must be an object'] };
+ for (const k of ['id', 'ticket_id', 'question', 'state', 'created_at']) {
+ if (!isText(doc[k], k === 'question' ? MAX_TEXT : 500)) errors.push(`attention.${k} must be non-empty text`);
+ }
+ if (!STATES.includes(doc.state)) errors.push(`attention.state must be one of ${STATES.join(', ')}`);
+ if (doc.answer !== undefined && !isText(doc.answer)) errors.push('attention.answer must be non-empty text when present');
+ if (Number.isNaN(Date.parse(doc.created_at))) errors.push('attention.created_at must be a date-time');
+ const allowed = ['id', 'ticket_id', 'question', 'state', 'created_at', 'answer'];
+ for (const k of Object.keys(doc)) if (!allowed.includes(k)) errors.push(`attention.${k} is not an attention field`);
+ return { valid: errors.length === 0, errors };
+}
+
+module.exports = {
+ STATES,
+ raise, answer, dismiss, isOpen, resumeSession, toContract, isValidContract,
+};
@@ -398,7 +398,7 @@ function detect(attempt = {}) {
@@ -398,7 +398,7 @@ function detect(attempt = {}) {
*
*
* @param {object[]} attempts the attempts (annotated or not)
* @param {object[]} attempts the attempts (annotated or not)
* @param {object} [opts] what the caller knows: { cfg, health, counts, models, runners, tier }
* @param {object} [opts] what the caller knows: { cfg, health, counts, models, runners, tier }
- * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, failed
+ * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, attention, failed
*/
*/
function nextStep(attempts, opts = {}) {
function nextStep(attempts, opts = {}) {
const runs = annotate(attempts);
const runs = annotate(attempts);
@@ -432,6 +432,18 @@ function settledParent(runs, end) {
@@ -432,6 +432,18 @@ function settledParent(runs, end) {
/** The next step, given the chain's end. */
/** The next step, given the chain's end. */
function step(runs, end, opts = {}) {
function step(runs, end, opts = {}) {
if (!end) return { action: 'none', why: 'there are no attempts' };
if (!end) return { action: 'none', why: 'there are no attempts' };
+ // An open question waits for the person, never fails the task: the answer resumes the same session, and
+ // authorised independent work may continue meanwhile. It comes first: a worker blocked on a person is waiting.
+ if (end.attention && end.attention.state === 'open') {
+ const session = (end.attention.session && String(end.attention.session)) || facts(end).session || null;
+ return {
+ action: 'attention',
+ ...(end.attention.id ? { attention: end.attention.id } : {}),
+ ...(end.attention.question ? { question: String(end.attention.question).slice(0, 500) } : {}),
+ ...(session ? { session } : {}),
+ why: 'the worker asked a question and waits for the answer; the answer resumes the same session',
+ };
+ }
if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };
if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };
// AL1: a worker's "done" is a proposal, not an outcome. Nothing is done until an independent verifier accepted the
// AL1: a worker's "done" is a proposal, not an outcome. Nothing is done until an independent verifier accepted the
// goal's evidence; a rejection (or a verdict that could not be made) goes back to the same session.
// goal's evidence; a rejection (or a verdict that could not be made) goes back to the same session.
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
* engine.finding one finding in the agent.finding@1 shape: confirmed, a lead that needs validation, or rejected
* engine.finding one finding in the agent.finding@1 shape: confirmed, a lead that needs validation, or rejected
* engine.findings a review's findings across revisions: stable ids, ACTIVE/RESOLVED and the lifecycle line
* engine.findings a review's findings across revisions: stable ids, ACTIVE/RESOLVED and the lifecycle line
* engine.verifier a fresh verifier per confirmed record, with a refute prompt
* engine.verifier a fresh verifier per confirmed record, with a refute prompt
+ * engine.attention one question awaiting a person: raised, answered and resumed (agent.attention@1)
*
*
* The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.
* The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.
*/
*/
@@ -45,6 +46,7 @@ const review = require('./review');
@@ -45,6 +46,7 @@ const review = require('./review');
const finding = require('./finding');
const finding = require('./finding');
const findings = require('./findings');
const findings = require('./findings');
const verifier = require('./verifier');
const verifier = require('./verifier');
+const attention = require('./attention');
const goal = require('./goal');
const goal = require('./goal');
module.exports = {
module.exports = {
@@ -67,6 +69,7 @@ module.exports = {
@@ -67,6 +69,7 @@ module.exports = {
finding,
finding,
findings,
findings,
verifier,
verifier,
+ attention,
goal,
goal,
// The names a caller usually wants, in one place.
// The names a caller usually wants, in one place.
classify: router.classify,
classify: router.classify,
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+
+/**
+ * Release 2 item 5: the ask as a resumable event (agent.attention@1).
+ *
+ * A worker that needs a person raises one structured question instead of failing. The chain answers `attention`
+ * with the session the answer resumes; the answered or dismissed record falls back to the ordinary flow. The record
+ * projects to the attention.v1 contract shape with nothing Codes-only in it.
+ */
+const assert = require('assert');
+const attention = require('../engine/attention');
+const chain = require('../engine/chain');
+const { check, done } = require('./helpers/boot');
+
+const now = Date.now();
+const job = (runner, name, status, at, extra = {}) => ({
+ key: `${runner}:${name}`, runner, name, runnerName: runner.toUpperCase(),
+ status, updated: at, error: status === 'done' ? '' : 'it broke', ...extra,
+});
+
+(async () => {
+ try {
+ await check('an attention is raised open, with its evidence, options and session', () => {
+ const rec = attention.raise({
+ ticket_id: 't-1', question: 'Ship it or fix the test first?',
+ evidence: [{ kind: 'command', summary: 'the red test', command: 'ov test red.test.js' }],
+ options: ['ship it', 'fix first'], session: 'sess-9',
+ });
+ assert.ok(rec.id.startsWith('att_'));
+ assert.strictEqual(rec.state, 'open');
+ assert.strictEqual(rec.session, 'sess-9');
+ assert.deepStrictEqual(rec.options, ['ship it', 'fix first']);
+ assert.strictEqual(rec.evidence.length, 1);
+ assert.ok(!Number.isNaN(Date.parse(rec.created_at)));
+ assert.strictEqual(attention.isOpen(rec), true);
+ assert.strictEqual(attention.resumeSession(rec), 'sess-9');
+ });
+
+ await check('raising needs the ticket and the question', () => {
+ assert.throws(() => attention.raise({ question: 'q?' }), /ticket/);
+ assert.throws(() => attention.raise({ ticket_id: 't' }), /question/);
+ assert.throws(() => attention.raise({ ticket_id: 't', question: ' ' }), /question/);
+ });
+
+ await check('the answer and the dismissal close the record, and only an open one moves', () => {
+ const rec = attention.raise({ ticket_id: 't', question: 'Which region?' });
+ const answered = attention.answer(rec, { answer: 'eu', by: 'owner' });
+ assert.strictEqual(answered.state, 'answered');
+ assert.strictEqual(answered.answer, 'eu');
+ assert.strictEqual(attention.isOpen(answered), false);
+ assert.throws(() => attention.answer(answered, { answer: 'us' }), /only an open attention/);
+ const dismissed = attention.dismiss(attention.raise({ ticket_id: 't', question: 'Still want this?' }), { note: 'asked twice' });
+ assert.strictEqual(dismissed.state, 'dismissed');
+ assert.throws(() => attention.dismiss(dismissed), /only an open attention/);
+ assert.throws(() => attention.answer(rec, { answer: ' ' }), /non-empty/);
+ });
+
+ await check('the record projects to the attention.v1 contract shape', () => {
+ const rec = attention.raise({ id: 'att_1', ticket_id: 't', question: 'Go?', session: 's' });
+ const doc = attention.toContract(rec);
+ assert.deepStrictEqual(Object.keys(doc).sort(), ['created_at', 'id', 'question', 'state', 'ticket_id']);
+ assert.deepStrictEqual(attention.isValidContract(doc), { valid: true, errors: [] });
+ assert.deepStrictEqual(attention.isValidContract(attention.toContract(attention.answer(rec, { answer: 'yes' }))).valid, true);
+ assert.strictEqual(attention.isValidContract({ ...doc, state: 'waiting' }).valid, false);
+ assert.strictEqual(attention.isValidContract({ ...doc, question: '' }).valid, false);
+ assert.strictEqual(attention.isValidContract({ ...doc, session: 's' }).valid, false, 'nothing Codes-only crosses into the contract');
+ });
+
+ await check('an open question is attention with its session, never a failure', () => {
+ const rec = attention.raise({ id: 'att_7', ticket_id: 't', question: 'Ship it?', session: 'sess-9' });
+ const attempts = [job('cx', 'ask', 'failed', now - 1000, { attention: rec, facts: { session: 'sess-9' } })];
+ const s = chain.nextStep(attempts);
+ assert.strictEqual(s.action, 'attention');
+ assert.strictEqual(s.session, 'sess-9');
+ assert.strictEqual(s.attention, 'att_7');
+ assert.ok(s.question.includes('Ship it'));
+ });
+
+ await check('the answer resumes the same session: answered and dismissed fall back to the ordinary flow', () => {
+ const cfg = { subagents: { cx: { enabled: true, max: 2 } }, fallback: { order: ['cx'] } };
+ const ctx = { cfg, health: { cx: { state: 'ok' } }, counts: {}, models: {}, runners: ['cx'] };
+ const rec = attention.raise({ id: 'att_8', ticket_id: 't', question: 'Ship it?' });
+ const answered = [job('cx', 'ask', 'failed', now - 1000, { attention: attention.answer(rec, { answer: 'yes' }) })];
+ assert.notStrictEqual(chain.nextStep(answered, ctx).action, 'attention', 'an answered question is not waited on');
+ const dismissed = [job('cx', 'ask', 'failed', now - 1000, { attention: attention.dismiss(attention.raise({ ticket_id: 't', question: 'q?' })) })];
+ assert.notStrictEqual(chain.nextStep(dismissed, ctx).action, 'attention', 'a dismissed question is not waited on');
+ });
+
+ await check('the open question wins over the failure handling, and carries the run session when its own is missing', () => {
+ const rec = attention.raise({ ticket_id: 't', question: 'Which model?' });
+ const s = chain.nextStep([job('cx', 'ask', 'failed', now - 1000, { attention: rec, facts: { session: 'sess-3' } })]);
+ assert.strictEqual(s.action, 'attention');
+ assert.strictEqual(s.session, 'sess-3', 'the run session carries the resume when the record names none');
+ });
+
+ console.log('attention: one structured question, answered in the same session, contracted');
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ done();
+})();