feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)
One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:
- Row 18, Discord connector rows on the board (#1509): R3 approved by
Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
Dewey, candidate manifest 47769fad. All seven source files match it.
Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).
packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.
Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.
Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.
Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { scan, markSeen, deriveState } from '../src/scan.mjs';
|
||||
|
||||
test('explicit request, Seen, ordinary completion and a new request have distinct attention states', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-attention-flow-'));
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions');
|
||||
mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
writeFileSync(file, JSON.stringify({ type: 'session', id: 'fixture', cwd: root, timestamp: '2026-09-13T00:00:00Z' }) + '\n');
|
||||
const specs = [{ agent: 'researcher', project: 'fixture', sessionsDir, tmux: {} }];
|
||||
const boardDir = join(root, 'board');
|
||||
const get = () => scan(specs, { boardDir, isAlive: () => true }).sessions[0];
|
||||
const append = (second, text) => appendFileSync(file, JSON.stringify({
|
||||
type: 'message', timestamp: `2026-09-13T00:00:0${second}Z`,
|
||||
message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] },
|
||||
}) + '\n');
|
||||
append(1, 'Input needed: Choose A or B.');
|
||||
let r = get();
|
||||
assert.equal(r.state, 'waiting');
|
||||
assert.equal(r.waitingOnYou, true);
|
||||
markSeen(boardDir, { project: 'fixture', agent: 'researcher', lastActivity: r.lastActivity, seen: true });
|
||||
r = get();
|
||||
assert.equal(r.state, 'waiting', 'acknowledgment does not resolve an input request');
|
||||
assert.equal(r.seen, true);
|
||||
assert.equal(r.waitingOnYou, false);
|
||||
append(2, 'BOARD_REPLY_OK');
|
||||
r = get();
|
||||
assert.equal(r.state, 'idle');
|
||||
assert.equal(r.waitingOnYou, false);
|
||||
assert.equal(r.seen, false, 'ordinary completion needs no Seen action');
|
||||
append(3, 'Input needed: Confirm the revised choice.');
|
||||
r = get();
|
||||
assert.equal(r.state, 'waiting');
|
||||
assert.equal(r.waitingOnYou, true, 'old Seen does not suppress a new request');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('attention convention ignores reasoning/quoted examples and permits leading blank lines', () => {
|
||||
const derive = (content, alive = true) => deriveState({ alive, session: { lastMessage: { role: 'assistant', stopReason: 'stop', content } } });
|
||||
const text = (value) => [{ type: 'text', text: value }];
|
||||
for (const value of ['> Input needed: Choose A.', ' Input needed: Choose A.', '`Input needed: Choose A.`', 'Input needed:\tChoose A.']) {
|
||||
assert.equal(derive(text(value)), 'idle', value);
|
||||
}
|
||||
assert.equal(derive([{ type: 'thinking', thinking: 'Input needed: Choose A.' }, ...text('Done.')]), 'idle');
|
||||
assert.equal(derive(text('\n\r\nInput needed: Choose A.')), 'waiting');
|
||||
assert.equal(derive(text('Input needed: Choose A.'), false), 'offline');
|
||||
assert.equal(derive(text('Input needed: Choose A.'), null), 'unknown');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deriveState } from '../src/scan.mjs';
|
||||
|
||||
const state = (text, overrides = {}) => deriveState({ alive: true, session: {
|
||||
lastMessage: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }], ...overrides },
|
||||
} });
|
||||
|
||||
test('completed smoke replies and ordinary questions are idle, not human blockers', () => {
|
||||
for (const text of ['BOARD_REPLY_OK', 'RESEARCHER_NATIVE_SMOKE_OK', 'Done.', 'Shall I continue?']) {
|
||||
assert.equal(state(text), 'idle', text);
|
||||
}
|
||||
});
|
||||
|
||||
test('only an explicit first-line input request makes a finished reply waiting', () => {
|
||||
assert.equal(state('Input needed: Choose the release target.\nDetails follow.'), 'waiting');
|
||||
for (const text of ['Input needed:', 'Input needed: ', 'Example:\nInput needed: Choose A.', '```\nInput needed: Choose A.\n```']) {
|
||||
assert.equal(state(text), 'idle', text);
|
||||
}
|
||||
});
|
||||
|
||||
test('tool activity, user text, errors and unfinished turns override attention text', () => {
|
||||
const text = 'Input needed: Choose A.';
|
||||
assert.equal(state(text, { content: [{ type: 'text', text }, { type: 'toolCall', name: 'read' }] }), 'working');
|
||||
assert.equal(state(text, { role: 'user' }), 'working');
|
||||
assert.equal(state(text, { role: 'toolResult' }), 'working');
|
||||
assert.equal(state(text, { stopReason: 'error' }), 'error');
|
||||
assert.equal(state(text, { stopReason: undefined }), 'working');
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
|
||||
import { scan, scanAgent } from '../src/scan.mjs';
|
||||
import { replyToRow, startServer } from '../src/serve.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { envelope } from '../../discord/src/context.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
|
||||
test('STOP access failure is unknown, not absence, under a non-root identity', () => {
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { inspectDiscord } from ${JSON.stringify(new URL('../src/discord.mjs', import.meta.url).href)};
|
||||
assert.notEqual(process.getuid(), 0, 'permission regression must not run as root');
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-stop-permission-'));
|
||||
const dir = join(root, 'journal'), connector = { binding: 'fixture', journalDir: dir };
|
||||
mkdirSync(dir);
|
||||
try {
|
||||
assert.equal(inspectDiscord(connector).braked, false, 'verified absence');
|
||||
writeFileSync(join(dir, 'STOP'), 'private contents never read');
|
||||
assert.equal(inspectDiscord(connector).braked, true, 'verified presence');
|
||||
chmodSync(dir, 0o000);
|
||||
const inaccessible = inspectDiscord(connector);
|
||||
assert.equal(inaccessible.braked, null, 'inaccessible STOP is unknown');
|
||||
assert.equal(inaccessible.alive, false);
|
||||
chmodSync(dir, 0o700);
|
||||
assert.equal(inspectDiscord(connector).braked, true, 'restored access sees STOP');
|
||||
rmSync(join(dir, 'STOP'));
|
||||
assert.equal(inspectDiscord(connector).braked, false, 'verified removal');
|
||||
} finally { chmodSync(dir, 0o700); rmSync(root, { recursive: true, force: true }); }
|
||||
`], { encoding: 'utf8', timeout: 10000, ...(process.getuid() === 0 ? { uid: 65534, gid: 65534 } : {}) });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
|
||||
test('connector Task never inherits Discord routing envelopes; ordinary Task still uses user text', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const text = envelope({ guildName: 'Synthetic guild', channelName: 'synthetic-channel', authorId: '111111111111111111', messageId: '222222222222222222', text: 'Synthetic user request' });
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo');
|
||||
writeFileSync(join(sessionsDir, 's.jsonl'), JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n');
|
||||
const connector = scanAgent(discoverDiscordAgents(f.root).specs[0]);
|
||||
assert.equal(connector.task, 'Discord connector');
|
||||
assert.equal(connector.taskSource, 'connector');
|
||||
for (const secret of ['111111111111111111', '222222222222222222', 'Synthetic user request']) assert.equal(connector.task.includes(secret), false);
|
||||
const ordinary = scanAgent({ agent: 'native', project: 'repo', sessionsDir, tmux: {} }, { isAlive: () => true });
|
||||
assert.equal(ordinary.task, text.replace(/\s+/g, ' ').trim());
|
||||
assert.equal(ordinary.taskSource, 'first-user-message');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-discord-'));
|
||||
mkdirSync(join(root, 'discord', 'demo', 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'demo.json'), JSON.stringify({ name: 'demo', seat: 'researcher', tokenFile: '/never-read-secret', guildId: 'PRIVATE-ID', users: ['PRIVATE-USER'] }), { mode: 0o600 });
|
||||
const sessions = join(root, 'sessions', 'discord-demo'); mkdirSync(sessions, { recursive: true });
|
||||
writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'done' }] } }) + '\n');
|
||||
const owner = join(root, 'discord', 'demo', 'run.lock', 'owner.json');
|
||||
writeFileSync(owner, JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
return { root, owner, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const result = discoverDiscordAgents(f.root);
|
||||
assert.equal(result.specs.length, 1); assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.specs[0].agent, 'researcher (discord: demo)');
|
||||
assert.equal(JSON.stringify(result).includes('PRIVATE'), false); assert.equal(JSON.stringify(result).includes('tokenFile'), false);
|
||||
writeFileSync(join(f.root, 'discord', 'bad.json'), '{"name":"bad","seat":"../../escape","secret":"PRIVATE"}', { mode: 0o600 });
|
||||
symlinkSync(join(f.root, 'discord', 'demo.json'), join(f.root, 'discord', 'linked.json'));
|
||||
writeFileSync(join(f.root, 'discord', 'other.json'), '{"name":"wrong","seat":"x"}', { mode: 0o600 });
|
||||
chmodSync(join(f.root, 'discord', 'demo.json'), 0o644);
|
||||
const bad = discoverDiscordAgents(f.root); assert.equal(bad.specs.length, 0); assert.equal(bad.errors.length, 4);
|
||||
assert.equal(JSON.stringify(bad).includes('PRIVATE'), false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('canonical owner identity and STOP are independent; no tmux fallback or forged registration', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const spec = discoverDiscordAgents(f.root).specs[0];
|
||||
const row = () => scanAgent(spec, { isAlive: () => { throw Error('must not ask tmux'); }, registration: { pid: process.pid, task: 'forged', taskSetBy: 'forged-setter', tmux: { session: 'forged' } } });
|
||||
assert.equal(row().alive, true); assert.equal(row().state, 'idle'); assert.equal(row().registered, null);
|
||||
// #1511: the connector's fixed task borrows no attribution from a native registration.
|
||||
assert.equal(row().task, 'Discord connector'); assert.equal(row().taskSource, 'connector'); assert.equal(row().taskSetBy, null);
|
||||
assert.equal(JSON.stringify(row()).includes('forged-setter'), false);
|
||||
writeFileSync(join(f.root, 'discord', 'demo', 'STOP'), 'PRIVATE BRAKE CONTENT');
|
||||
assert.equal(row().connector.braked, true); assert.equal(row().state, 'idle');
|
||||
assert.equal(JSON.stringify(row()).includes('PRIVATE'), false);
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: '1', boot: identityOf(process.pid).boot }) }).alive, false);
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: null, boot: null }) }).ownerState, 'unknown');
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: identityOf(process.pid).start, boot: '00000000-0000-0000-0000-000000000000' }) }).ownerState, 'mismatch');
|
||||
for (const value of ['broken PRIVATE', JSON.stringify({ pid: process.pid }), JSON.stringify({ pid: 2147483647, ...identityOf(process.pid) })]) {
|
||||
writeFileSync(f.owner, value); assert.equal(row().alive, false); assert.equal(row().state, 'offline');
|
||||
}
|
||||
rmSync(f.owner); assert.equal(row().connector.ownerState, 'absent');
|
||||
symlinkSync('/never-read-secret', f.owner); assert.equal(row().alive, false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector reply refusal precedes forged live tmux registration; ordinary agent still sends', () => {
|
||||
let calls = 0; const exec = () => { calls++; return { status: 0, stdout: '', stderr: '' }; };
|
||||
const r = { agent: 'researcher (discord: demo)', project: 'fleet', connector: { binding: 'demo' }, registered: { alive: true, tmux: { session: 'forged' } } };
|
||||
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
|
||||
delete r.connector;
|
||||
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
|
||||
assert.equal(calls, 0);
|
||||
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
|
||||
const f = fixture(); let server;
|
||||
try {
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
|
||||
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
let response = await fetch(base + '/api/board'); const board = await response.json();
|
||||
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
|
||||
assert.equal(response.status, 409);
|
||||
rmSync(join(f.root, 'discord', 'demo.json'));
|
||||
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector session links and linked directories are not read', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const sessions = join(f.root, 'sessions', 'discord-demo'); rmSync(sessions, { recursive: true });
|
||||
symlinkSync(join(f.root, 'discord'), sessions);
|
||||
const result = scan([], { boardDir: join(f.root, 'board'), discordDataRoot: f.root });
|
||||
assert.equal(result.sessions[0].sessionFile, null);
|
||||
rmSync(join(f.root, 'discord'), { recursive: true }); symlinkSync('/never-read-secret', join(f.root, 'discord'));
|
||||
assert.equal(discoverDiscordAgents(f.root).specs.length, 0);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { scanAgent } from '../src/scan.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
|
||||
const oldAt = '2026-09-14T10:00:00.000Z';
|
||||
const launchAt = '2026-09-15T10:00:00.000Z';
|
||||
const newAt = '2026-09-15T10:01:00.000Z';
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-relaunch-'));
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 'session.jsonl');
|
||||
const message = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
|
||||
writeFileSync(file, message(oldAt, 'Input needed: Historical request.'));
|
||||
const spec = { agent: 'fixture', project: 'repo', sessionsDir, tmux: {} };
|
||||
const registration = { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Registered task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' };
|
||||
const read = (extra = {}) => scanAgent(spec, { isAlive: () => true, isPidAlive: () => true, registration, now: () => new Date(newAt), ...extra });
|
||||
return { root, file, spec, registration, read, message, close: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const original = readFileSync(f.file, 'utf8');
|
||||
const row = f.read();
|
||||
assert.equal(row.relaunchedAt, launchAt);
|
||||
assert.equal(row.lastActivity, oldAt); assert.equal(row.lastAssistantText, 'Input needed: Historical request.');
|
||||
assert.equal(row.state, 'waiting'); assert.equal(row.waitingOnYou, true);
|
||||
assert.equal(row.taskSetBy, 'fixture-setter'); assert.equal(row.taskSource, 'registration');
|
||||
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).seen, true);
|
||||
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).waitingOnYou, false);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), original, 'scanning never rewrites historical messages');
|
||||
appendFileSync(f.file, f.message(newAt, 'Fresh completion.'));
|
||||
const next = f.read(); assert.equal(next.relaunchedAt, null); assert.equal(next.state, 'idle');
|
||||
assert.equal(next.lastAssistantText, 'Fresh completion.'); assert.equal(next.lastActivity, newAt);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('CLI print uses the relaunch notice instead of old current preview', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const state = join(f.root, '.pi', 'state', 'fixture'); mkdirSync(state, { recursive: true });
|
||||
const sessionsDir = join(state, 'sessions'); renameSync(f.spec.sessionsDir, sessionsDir);
|
||||
const dataRoot = join(f.root, 'data');
|
||||
writeRegistration(join(dataRoot, 'seats'), { ...f.registration, sessionsDir });
|
||||
const config = join(f.root, 'config.json'); writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
const run = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'scan', '--config', config, '--repo', f.root, '--fleet', 'none', '--liveness', 'assume-alive', '--print'], { encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.ok(run.stdout.includes(`relaunched at ${launchAt}, no messages since`));
|
||||
assert.equal(run.stdout.includes('Historical request.'), false);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('connector owner and fixed task never inherit a native relaunch notice', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const journalDir = join(f.root, 'journal'); mkdirSync(join(journalDir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(journalDir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
const row = scanAgent({ ...f.spec, connector: { binding: 'fixture', journalDir } }, { registration: f.registration, isAlive: () => { throw Error('connector cannot use native liveness'); } });
|
||||
assert.equal(row.alive, true); assert.equal(row.relaunchedAt, null); assert.equal(row.task, 'Discord connector');
|
||||
assert.equal(row.taskSetBy, null); assert.equal(row.registered, null);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.equal(f.read({ registration: { ...f.registration, startedAt: oldAt } }).relaunchedAt, null);
|
||||
for (const value of [false, null]) assert.equal(f.read({ isPidAlive: () => value }).relaunchedAt, null);
|
||||
for (const value of [false, null]) assert.equal(f.read({ isAlive: () => value }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: null }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: { ...f.registration, sessionsDir: join(f.root, 'different') } }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: { ...f.registration, startedAt: 'invalid' } }).relaunchedAt, null);
|
||||
writeFileSync(f.file, f.message('invalid', 'Undated history.'));
|
||||
assert.equal(f.read().relaunchedAt, null);
|
||||
writeFileSync(f.file, JSON.stringify({ type: 'message', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'No timestamp.' }] } }) + '\n');
|
||||
assert.equal(f.read().relaunchedAt, null);
|
||||
rmSync(f.file); assert.equal(f.read().relaunchedAt, null);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
@@ -284,13 +284,13 @@ test("deriveState: full state table", () => {
|
||||
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
|
||||
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
|
||||
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
|
||||
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
|
||||
{ name: "assistant+stop is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "idle" },
|
||||
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
|
||||
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
|
||||
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
|
||||
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
|
||||
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
|
||||
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
|
||||
{ name: "assistant+stop with thinking and text (no tool call) is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "idle" },
|
||||
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
|
||||
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
|
||||
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
|
||||
@@ -335,7 +335,7 @@ test("rule: newest entry is a tool result with no assistant text after it is wor
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
|
||||
test("rule: a finished ordinary turn is idle, even if it says your move", () => {
|
||||
const root = makeRoot();
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
|
||||
@@ -344,8 +344,8 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
|
||||
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
|
||||
]);
|
||||
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.waitingOnYou, true);
|
||||
assert.equal(rec.state, "idle");
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -764,7 +764,7 @@ test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", ()
|
||||
mkdirSync(errorDir, { recursive: true });
|
||||
writeSessionFile(waitingDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
writeSessionFile(workingDir, "s.jsonl", [
|
||||
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
|
||||
@@ -866,7 +866,7 @@ test("scan: writes per-agent files and index.json, rerun overwrites, no leftover
|
||||
sessionPath,
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
|
||||
@@ -938,7 +938,7 @@ test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes
|
||||
join(sessionsDir, "s.jsonl"),
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
|
||||
@@ -1131,7 +1131,7 @@ test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
@@ -1146,7 +1146,7 @@ test("scanAgent: a stale mark (agent wrote something newer) is not seen and wait
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
@@ -1197,7 +1197,7 @@ test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewr
|
||||
const lastActivity = "2026-09-01T00:00:01Z";
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
const seenPath = join(boardDir, "seen.json");
|
||||
@@ -1219,3 +1219,87 @@ test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", ()
|
||||
writeFile(join(boardDir, "seen.json"), "{ bad json");
|
||||
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task attribution (#1511): taskSetBy on the row, tied to the task source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function attributionFixture(root, { taskSetBy, task = "registered task", pid = process.pid } = {}) {
|
||||
const sessionsDir = join(root, "sessions");
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
|
||||
]);
|
||||
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
|
||||
const base = makeRegistration({
|
||||
resolved: { seat: "a", project: "rp", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/w" },
|
||||
task, tmux: { socket: null, session: "a" }, pid, now: () => new Date("2026-09-12T13:00:00Z"),
|
||||
});
|
||||
const registration = taskSetBy === undefined ? base : { ...base, taskSetBy };
|
||||
return { spec, sessionsDir, registration };
|
||||
}
|
||||
|
||||
test("taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered", () => {
|
||||
const named = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const rec = scanAgent(named.spec, { isAlive: () => true, now: GATE_NOW, registration: named.registration });
|
||||
assert.equal(rec.taskSource, "registration");
|
||||
assert.equal(rec.taskSetBy, "darkwing");
|
||||
assert.equal("taskSetBy" in rec.registered, false, "attribution lives on the row next to taskSource only");
|
||||
|
||||
const old = attributionFixture(makeRoot());
|
||||
assert.equal("taskSetBy" in old.registration, false);
|
||||
const oldRec = scanAgent(old.spec, { isAlive: () => true, now: GATE_NOW, registration: old.registration });
|
||||
assert.equal(oldRec.taskSource, "registration");
|
||||
assert.equal(oldRec.taskSetBy, "unknown");
|
||||
|
||||
const literal = attributionFixture(makeRoot(), { taskSetBy: "unknown" });
|
||||
assert.equal(scanAgent(literal.spec, { isAlive: () => true, now: GATE_NOW, registration: literal.registration }).taskSetBy, "unknown");
|
||||
});
|
||||
|
||||
test("taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present", () => {
|
||||
const none = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const withoutReg = scanAgent(none.spec, { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(withoutReg.taskSource, "first-user-message");
|
||||
assert.equal(withoutReg.taskSetBy, null);
|
||||
assert.ok("taskSetBy" in withoutReg);
|
||||
|
||||
const empty = attributionFixture(makeRoot(), { taskSetBy: "darkwing", task: "" });
|
||||
const emptyRec = scanAgent(empty.spec, { isAlive: () => true, now: GATE_NOW, registration: empty.registration });
|
||||
assert.equal(emptyRec.task, "derived task");
|
||||
assert.equal(emptyRec.taskSource, "first-user-message");
|
||||
assert.equal(emptyRec.taskSetBy, null, "a setter on a record whose task is empty is not attached to the transcript's task");
|
||||
assert.notEqual(emptyRec.registered, null);
|
||||
|
||||
const stale = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const staleRec = scanAgent(stale.spec, { isAlive: () => true, now: GATE_NOW, registration: stale.registration, isPidAlive: () => false });
|
||||
assert.equal(staleRec.task, "derived task");
|
||||
assert.equal(staleRec.taskSource, "first-user-message");
|
||||
assert.equal(staleRec.taskSetBy, null, "a stale registration lends neither its task nor its setter");
|
||||
assert.equal(staleRec.registered.alive, false);
|
||||
|
||||
const noSession = scanAgent({ agent: "b", project: "p", sessionsDir: join(makeRoot(), "missing"), tmux: {} }, { isAlive: () => false, now: GATE_NOW });
|
||||
assert.equal(noSession.task, null);
|
||||
assert.equal(noSession.taskSetBy, null);
|
||||
});
|
||||
|
||||
test("taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value", () => {
|
||||
const root = makeRoot();
|
||||
const seatsDir = join(root, "seats");
|
||||
const boardDir = join(root, "board");
|
||||
const f = attributionFixture(root, { taskSetBy: "jason@host:1" });
|
||||
writeRegistration(seatsDir, f.registration);
|
||||
const index = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(index.sessions[0].taskSetBy, "jason@host:1");
|
||||
assert.equal(JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")).taskSetBy, "jason@host:1");
|
||||
assert.deepEqual(index.registrationErrors, []);
|
||||
|
||||
// A hand-edited record with markup in the field does not reach the board.
|
||||
writeFileSync(join(seatsDir, "repo", "a", "registration.json"), JSON.stringify({ ...f.registration, taskSetBy: "<img src=x onerror=alert(1)>" }));
|
||||
const rejected = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(rejected.sessions[0].taskSource, "first-user-message");
|
||||
assert.equal(rejected.sessions[0].taskSetBy, null);
|
||||
assert.equal(rejected.sessions[0].registered, null);
|
||||
assert.equal(rejected.registrationErrors.length, 1);
|
||||
assert.match(rejected.registrationErrors[0], /taskSetBy/);
|
||||
assert.equal(JSON.stringify(rejected).includes("onerror"), false, "the offending value is not echoed anywhere on the index");
|
||||
});
|
||||
|
||||
@@ -176,8 +176,8 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
|
||||
assert.equal(first.headers.get("content-type"), "application/json");
|
||||
assert.equal(first.headers.get("cache-control"), "no-store");
|
||||
const firstBody = await first.json();
|
||||
assert.equal(firstBody.sessions[0].state, "waiting");
|
||||
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
assert.equal(firstBody.sessions[0].state, "idle");
|
||||
assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
|
||||
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
|
||||
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
|
||||
@@ -397,7 +397,7 @@ test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false
|
||||
const sessionsDir = join(root, "sessions");
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const boardDir = join(root, "board");
|
||||
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||
@@ -557,7 +557,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
|
||||
join(sessionsDir, "s.jsonl"),
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
const boardDir = join(dataRoot, "board");
|
||||
@@ -881,3 +881,64 @@ test("page.html: the reply box appears only where canReply() holds (live registr
|
||||
assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
|
||||
assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task attribution (#1511): serialized by the server, rendered by page.html.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
const derivedDir = join(root, "derived-sessions");
|
||||
for (const dir of [sessionsDir, derivedDir]) {
|
||||
writeSessionFile(dir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
|
||||
]);
|
||||
}
|
||||
const seatsDir = join(root, "seats");
|
||||
const reg = makeRegistration({
|
||||
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
|
||||
task: "registered task",
|
||||
});
|
||||
writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" });
|
||||
const specs = [
|
||||
{ agent: "agent1", project: "proj", sessionsDir, tmux: {} },
|
||||
{ agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} },
|
||||
];
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "<html></html>" });
|
||||
try {
|
||||
const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json();
|
||||
const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r]));
|
||||
assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
assert.deepEqual(body.registrationErrors, []);
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => {
|
||||
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
||||
const lines = html.split("\n");
|
||||
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
|
||||
assert.ok(rowPair);
|
||||
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
|
||||
assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
|
||||
const tagLine = lines.find((l) => l.includes("function setByTag(rec)"));
|
||||
assert.ok(tagLine);
|
||||
assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
|
||||
assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
|
||||
assert.match(tagLine, /not verified/);
|
||||
const textLine = lines.find((l) => l.includes("function setByText(rec)"));
|
||||
assert.ok(textLine);
|
||||
assert.match(textLine, /esc\(rec\.taskSetBy\)/);
|
||||
assert.match(textLine, /not verified/);
|
||||
// Attribution never enables a reply: canReply() reads the registration only.
|
||||
const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
|
||||
assert.ok(canReplyFn);
|
||||
assert.equal(canReplyFn[0].includes("taskSetBy"), false);
|
||||
assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user