feat: modifying a resource is a job, the same as generating one

Modify held the request open for a library search, a PubMed search, a web
search and a restating model call. That is minutes, and a browser gives up
first — Firefox abandons a non-streaming fetch at five minutes, the same
failure generating was moved off the request to fix in ef574edd. The server
carried on and saved the result while the person watched an error, and closing
the tab killed the work outright.

POST /my-resources/:id/refine now records the request and answers 202 with the
job, exactly as /generate does. The writing moved into refineResource(), which
the job runner dispatches to by kind; the job list, the five-second polling,
the restart recovery and the three-in-flight cap are all the work they already
did, unchanged. Ownership is checked again inside refineResource because the
resource can be deleted while the job waits.

The page follows the job instead of the response. Reporting is unchanged — the
unchanged reply, what was seen and what was searched — it is only said from the
job list now, so it still reaches the person who asked for it after a reload.
This commit is contained in:
Daniel 2026-09-16 23:28:42 +02:00
parent 894ce251bb
commit d2a06b0fcf
8 changed files with 344 additions and 281 deletions

View file

@ -381,6 +381,29 @@
var r = job.result || {};
var g = r.grounding || {};
var title = (r.resource && r.resource.title) || job.topic;
// A modification reports itself exactly as it did when the request waited for
// it; only where the answer arrives has changed.
if (job.kind === 'refine') {
// The model can return the document back unchanged. That is a failed
// modification, and saying "Applied" for it sent people off to download
// an identical file and conclude the feature was broken.
if (r.unchanged) {
status('“' + title + '”: the model returned it unchanged — nothing was modified. ' +
'Try naming the slide or section to change, and what to change about it.', 'bad');
} else {
// Whether it could see the slides is worth saying: it is the difference
// between "slide 4 looks crowded" being actionable and being guesswork,
// and it explains why this took longer.
status('“' + title + '” modified' +
(r.saw ? ', after looking at all ' + r.saw + ' slides' : '') +
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(r.searches);
showIllustrations(r.imageJobs || []);
reportImageFailures(r.imageFailures);
return;
}
status('“' + title + '” is in your library. ' + (g.used
? 'Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.'),
@ -415,11 +438,12 @@
icon.style.color = busy ? 'var(--g600)' : job.status === 'done' ? 'var(--green)' : 'var(--red)';
var label = document.createElement('span');
label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
label.textContent = job.topic + ' · ' + (job.kind === 'article' ? 'article' : 'presentation');
label.textContent = job.topic + ' · ' + (job.kind === 'refine' ? 'modification'
: job.kind === 'article' ? 'article' : 'presentation');
var state = document.createElement('span');
state.style.cssText = 'color:var(--g600);white-space:nowrap;';
state.textContent = job.status === 'queued' ? 'Queued'
: job.status === 'running' ? 'Writing… ' + elapsed(job)
: job.status === 'running' ? (job.kind === 'refine' ? 'Rewriting… ' : 'Writing… ') + elapsed(job)
: job.status === 'done' ? 'Done'
: 'Failed — ' + (job.error || 'Generation failed');
row.appendChild(icon); row.appendChild(label); row.appendChild(state);
@ -864,6 +888,11 @@
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
clearResults();
say('Rewriting…');
// Queued like a generation, not awaited. A revision restates the whole
// resource and can take minutes; a request held open that long is abandoned
// by the browser while the server carries on, which is exactly how a
// modification looked like it had failed and then really had been saved.
// The job list reports it, so leaving the page no longer loses the work.
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
method: 'POST',
headers: getAuthHeaders(),
@ -879,29 +908,9 @@
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
// Same reporting as generating: what it was written from, what was
// searched, and any figure that came back.
var g = data.grounding || {};
// The model can return the document back unchanged. That is a failed
// modification, and saying "Applied" for it sent people off to download
// an identical file and conclude the feature was broken.
if (data.unchanged) {
say('The model returned it unchanged — nothing was modified. ' +
'Try naming the slide or section to change, and what to change about it.', 'bad');
} else {
// Whether it could see the slides is worth saying: it is the
// difference between "slide 4 looks crowded" being actionable and
// being guesswork, and it explains why this took longer.
say('Applied' +
(data.saw ? ', after looking at all ' + data.saw + ' slides' : '') +
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
if (box && !data.unchanged) box.value = '';
loadLibrary();
say('Rewriting in the background. You can leave this page — it keeps going.', 'good');
if (box) box.value = '';
refreshJobs();
})
.catch(function (err) { say(err.message, 'bad'); })
.finally(function () {

View file

@ -574,15 +574,32 @@ function httpError(statusCode, message) {
// job; a runaway click is not.
var MAX_ACTIVE_JOBS = 3;
// One writer at a time, counted across generating and modifying alike: a
// modification does the same library search and the same long model call as a
// generation, so letting ten of them run at once is the cost the cap bounds.
async function assertJobSlot(userId) {
var active = await db.get("SELECT COUNT(*)::int AS n FROM user_resource_jobs WHERE user_id = ? AND status IN ('queued', 'running')", [userId]);
if (active && active.n >= MAX_ACTIVE_JOBS) {
throw httpError(409, MAX_ACTIVE_JOBS + ' resources are already being written. Wait for one to finish.');
}
}
/** Run one queued job to its end and record how it ended. Never throws. */
async function runResourceJob(jobId, userId, body) {
async function runResourceJob(jobId, userId, body, kind, resourceId) {
try {
await db.run("UPDATE user_resource_jobs SET status = 'running', started_at = NOW() WHERE id = ?", [jobId]);
var result = await generateResource(userId, body);
// A modification needs the author, not just their id: the deck is rendered for
// sight and each figure is read back under their ownership. Loaded here rather
// than carried in the request, so the job holds no stale session.
var result = kind === 'refine'
? await refineResource(await db.get('SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?', [userId]), resourceId, body)
: await generateResource(userId, body);
var summary = {
resource: result.resource, grounding: result.grounding, imageJobs: result.imageJobs,
imageFailures: result.imageFailures, searches: result.searches, review: result.review,
deckFallback: result.deckFallback, model: result.model
deckFallback: result.deckFallback, model: result.model,
// Read back by the page for a modification; absent for a generation.
unchanged: result.unchanged, saw: result.saw
};
await db.run("UPDATE user_resource_jobs SET status = 'done', finished_at = NOW(), resource_id = ?, result = ? WHERE id = ?",
[result.resource.id, JSON.stringify(summary), jobId]);
@ -614,10 +631,7 @@ router.post('/my-resources/generate', async function (req, res) {
try {
var topic = String(req.body.topic || '').trim();
if (!topic) return res.status(400).json({ error: 'A topic is required' });
var active = await db.get("SELECT COUNT(*)::int AS n FROM user_resource_jobs WHERE user_id = ? AND status IN ('queued', 'running')", [req.user.id]);
if (active && active.n >= MAX_ACTIVE_JOBS) {
return res.status(409).json({ error: MAX_ACTIVE_JOBS + ' resources are already being written. Wait for one to finish.' });
}
await assertJobSlot(req.user.id);
var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [req.user.id]);
if (count && count.n >= MAX_PER_USER) {
return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' });
@ -626,7 +640,7 @@ router.post('/my-resources/generate', async function (req, res) {
'INSERT INTO user_resource_jobs (user_id, topic, kind, request) VALUES (?, ?, ?, ?) ' +
'RETURNING id, topic, kind, status, created_at',
[req.user.id, topic.slice(0, 500), normalizeKind(req.body.kind), JSON.stringify(req.body || {})]);
runResourceJob(job.id, req.user.id, req.body || {});
runResourceJob(job.id, req.user.id, req.body || {}, 'generate', null);
res.status(202).json({ success: true, job: job });
} catch (err) {
console.error('[my-resources] generate:', err.message);
@ -1026,16 +1040,19 @@ router.put('/my-resources/:id/theme', async function (req, res) {
}
});
router.post('/my-resources/:id/refine', async function (req, res) {
try {
var instructions = String(req.body.instructions || '').trim();
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
// Modify a resource. The same shape as generating: the caller queues the work
// and the page follows it as a job, so the tab can be closed while it runs.
// Ownership is re-checked here rather than only at enqueue, because the
// resource can be deleted while the job waits its turn.
async function refineResource(user, resourceId, body) {
var instructions = String(body.instructions || '').trim();
if (!instructions) throw httpError(400, 'Say what to change');
var existing = await db.get(
'SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
[resourceId, user.id]
);
if (!existing) return res.status(404).json({ error: 'Not found' });
if (!existing) throw httpError(404, 'Not found');
// Modifying can reach for the same sources as generating: "add what the
// 2024 trial showed" is a request for material, not just a rewording, and
@ -1043,7 +1060,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
// subject searched is the resource's own topic plus the instruction, so a
// request about something not in the original still finds it.
var subject = [existing.topic, instructions].filter(Boolean).join(' \u2014 ').slice(0, 500);
var sources = await gatherSources(subject, req.body, existing.topic || instructions);
var sources = await gatherSources(subject, body, existing.topic || instructions);
var material = '';
if (sources.corpus.context) {
@ -1101,7 +1118,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
var visionModel = existingDeck
? String(await db.getSetting('my_resources.review_model', '') || '') : '';
var slideViews = visionModel
? await renderDeckForSight(existingDeck, existing.image_ids, req.user)
? await renderDeckForSight(existingDeck, existing.image_ids, user)
: [];
var sight = !slideViews.length ? '' :
'\n\nYou can see the deck as it renders now: ' + slideViews.length + ' image' +
@ -1134,7 +1151,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
// echoed "make it better" back unchanged and the vision model did not.
// An explicit choice by the author still wins over both.
var options = {
model: req.body.model ? await resolveModel(req.body.model)
model: body.model ? await resolveModel(body.model)
: (visionModel && slideViews.length ? visionModel : await resolveModel('')),
temperature: 0.2, maxTokens: 16000,
// A revision is writing. This is the call a thirteen-slide deck made the
@ -1151,7 +1168,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
var ai = await callAI(messages, callOptions);
if (sources.wantsImages && !existingDeck) {
ai = await resourceImages.dispatch(ai, {
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel,
owner: user.id, body: body, subject: subject, imageModel: sources.imageModel,
messages: messages, options: options, callAI: callAI
});
}
@ -1162,7 +1179,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
// silently drop every layout the deck held.
logRefine({ id: existing.id, path: 'deck', outcome: 'refused',
detail: 'the model did not return a usable deck', instructions: instructions });
return res.status(502).json({ error: 'That change could not be applied. Try wording it differently.' });
throw httpError(502, 'That change could not be applied. Try wording it differently.');
}
if (revisedDeck) {
// Carried through rather than trusted from the reply.
@ -1178,7 +1195,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
// so each new figure belongs to the slide that wanted it.
if (sources.wantsImages) {
var drawn = await deckBuild.drawFigures(revisedDeck, {
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel
owner: user.id, body: body, subject: subject, imageModel: sources.imageModel
});
ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures });
} else {
@ -1216,7 +1233,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
var revised = revisedDeck ? deckSchema.toMarkdown(revisedDeck)
: String((ai && ai.content) || '').trim();
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
if (!revised) throw httpError(502, 'The model returned nothing. Try again.');
// Figures from a modification are added to the ones already there, not
// swapped for them: "add two more diagrams" means more, not instead.
@ -1226,7 +1243,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
'image_ids = image_ids || ?::jsonb, deck = COALESCE(?::jsonb, deck) ' +
'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at',
[revised, firstHeading(revised), JSON.stringify(added),
revisedDeck ? JSON.stringify(revisedDeck) : null, existing.id, req.user.id]
revisedDeck ? JSON.stringify(revisedDeck) : null, existing.id, user.id]
);
// Said out loud, every time. A modification that changes nothing is the
// failure worth catching, and it is invisible from the response: the row
@ -1249,7 +1266,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
instructions: instructions
});
res.json({
return {
success: true, resource: row, markdown: revised, unchanged: unchanged,
saw: slideViews.length,
review: { applied: verified.reviewed, reason: verified.reason },
@ -1259,13 +1276,36 @@ router.post('/my-resources/:id/refine', async function (req, res) {
imageJobs: ai.imageJobs || [],
imageFailures: ai.imageFailures || [],
model: ai && ai.model
});
};
}
// Queue a modification and answer at once, exactly as generating does. The work
// is a job on the server, so a browser that gives up waiting — Firefox abandons
// a non-streaming fetch at five minutes — no longer takes the modification down
// with it, and a reload loses nothing.
router.post('/my-resources/:id/refine', async function (req, res) {
try {
var instructions = String(req.body.instructions || '').trim();
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
var resourceId = parseInt(req.params.id, 10);
var existing = await db.get('SELECT id, topic FROM user_resources WHERE id = ? AND user_id = ?',
[resourceId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Not found' });
await assertJobSlot(req.user.id);
var job = await db.get(
'INSERT INTO user_resource_jobs (user_id, topic, kind, resource_id, request) VALUES (?, ?, ?, ?, ?) ' +
'RETURNING id, topic, kind, status, created_at',
[req.user.id, String(existing.topic || instructions).slice(0, 500), 'refine', resourceId,
JSON.stringify(req.body || {})]);
runResourceJob(job.id, req.user.id, req.body || {}, 'refine', resourceId);
res.status(202).json({ success: true, job: job });
} catch (err) {
console.error('[my-resources] refine:', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Refinement failed' });
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Modification could not be started' });
}
});
// ── Showing the deck to the model that is about to change it ────────────────
// The model that writes a deck never sees it, and that is just as true when it
// is editing one. Most of what people ask for while modifying is about the
@ -1438,4 +1478,5 @@ module.exports = router;
// The job runner's body, reachable for tests that exercise a generation
// end to end without waiting on a detached job.
module.exports.generateResource = generateResource;
module.exports.refineResource = refineResource;
module.exports.runResourceJob = runResourceJob;

View file

@ -148,7 +148,7 @@ test('the reviewer is admin-chosen, off by default, and runs once per change', (
// rendered again — exactly the class of fault the reviewer exists for. The
// old rule assumed refining was a text edit; it is a layout edit as often as
// not.
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
const refine = route.slice(route.indexOf("async function refineResource("));
assert.match(refine, /deckReview\.review\(revisedDeck/);
assert.match(refine, /if \(revisedDeck && visionModel\)/, 'and only when one is configured');
// Still one pass. The verification runs on the result, never in a loop.
@ -174,7 +174,7 @@ test('the review is asked without thinking when the caller says so', () => {
const route = read('src/routes/myResources.js');
const gen = route.slice(route.indexOf('async function generateResource('), route.indexOf('async function runResourceJob('));
assert.match(gen, /reasoningEffort: options\.reasoningEffort,/, 'generation\u2019s review inherits the writing\u2019s rule');
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
const refine = route.slice(route.indexOf("async function refineResource("));
assert.match(refine, /reasoningEffort: 'none'/, 'a revision is writing');
assert.equal((refine.match(/reasoningEffort: options\.reasoningEffort,/g) || []).length, 1,
'and its review is asked the same way');

View file

@ -93,7 +93,7 @@ test('a modification keeps the deck\'s theme', () => {
// The model returns a new deck; the look was never its to choose. The
// column keeps the theme regardless, but the deck field must agree with it.
const route = read('src/routes/myResources.js');
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
const refine = route.slice(route.indexOf("async function refineResource("));
assert.match(refine, /revisedDeck\.theme = existingDeck\.theme \|\| existing\.theme \|\| revisedDeck\.theme/);
assert.match(refine.slice(0, 2500), /SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources/);
});

View file

@ -120,7 +120,18 @@ function router(t, overrides = {}) {
async function generate(body) {
return module.exports.generateResource(7, body);
}
return { request, generate, aiCalls, updates, reviewCalls, reviewOptions };
// Modifying is queued as a job too, so the tests that care what the writing
// does call its body directly, exactly as the generation tests do. The reply
// is shaped like the old synchronous one so the assertions stay about
// behaviour rather than about how the work is scheduled.
async function refine(body, id) {
try {
return { statusCode: 200, body: await module.exports.refineResource({ id: 7 }, id || 5, body) };
} catch (err) {
return { statusCode: err.statusCode || 500, body: { error: err.message } };
}
}
return { request, generate, refine, aiCalls, updates, reviewCalls, reviewOptions };
}
test('modifying a deck asks for a deck, even when illustration is on', async () => {
@ -133,7 +144,7 @@ test('modifying a deck asks for a deck, even when illustration is on', async ()
const r = router(null, {
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
});
const res = await r.request('post', '/my-resources/:id/refine', {
const res = await r.refine( {
instructions: 'add a third feature', withImages: 'true'
});
@ -154,7 +165,7 @@ test('a modification the model returned unchanged says so instead of claiming su
// logged server-side, where the person who could reword the instruction
// could not see it.
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
const res = await r.refine( { instructions: 'make it better' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.unchanged, true, 'the caller is told the deck came back identical');
@ -164,7 +175,7 @@ test('a modification that did change the deck reports itself as changed', async
const r = router(null, {
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
});
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a third feature' });
const res = await r.refine( { instructions: 'add a third feature' });
assert.equal(res.body.unchanged, false);
assert.equal(r.updates.length, 1, 'and the row is written');
@ -173,7 +184,7 @@ test('a modification that did change the deck reports itself as changed', async
test('a presentation with no deck still modifies, through the markdown path', async () => {
const r = router(null, { row: { deck: null }, reply: '# Croup\n\n- Barking cough\n- Stridor\n- Hoarse voice\n' });
const res = await r.request('post', '/my-resources/:id/refine', {
const res = await r.refine( {
instructions: 'add a third feature', withImages: 'true'
});
@ -195,7 +206,9 @@ test('the library says which presentations carry a deck, and the row shows when
assert.match(ui, /row\.updated_at \? new Date\(row\.updated_at\)/);
assert.match(ui, /edited \? 'modified '/);
assert.match(ui, /has_deck === false \? ' · plain text, no slide layout' : ''/);
assert.match(ui, /if \(data\.unchanged\) \{/);
// A modification reports from the job list now that it runs in the background,
// so "it came back unchanged" still reaches the person who asked for it.
assert.match(ui, /if \(r\.unchanged\) \{/);
});
// ── Generating a deck ──────────────────────────────────────────────────────
@ -271,7 +284,7 @@ test('modifying a resource is writing too, and is asked without thinking', async
// a thirteen-slide deck it reasoned for a minute and a half before writing a
// word, which is past the point a browser waits for the request.
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
const res = await r.refine( { instructions: 'make it better' });
assert.equal(res.statusCode, 200);
assert.equal(r.aiCalls[0].options.reasoningEffort, 'none');
@ -329,7 +342,7 @@ test('modifying a deck shows the model what the deck currently looks like', asyn
// "that slide is crowded", "the diagram is in the wrong place" — and none of
// it is answerable from the JSON alone.
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', {
const res = await r.refine( {
instructions: 'slide 2 looks crowded, split it'
});
@ -346,7 +359,7 @@ test('the edited deck is rendered again and checked', async () => {
// The edit was made against how the deck looked *before* it. A slide that
// gained two bullets only overflows once it is rendered again.
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
const res = await r.refine( { instructions: 'add a feature' });
assert.equal(r.reviewCalls.length, 1, 'the result goes back past the reviewer');
assert.equal(res.body.review.applied, true);
@ -356,7 +369,7 @@ test('with no vision model configured, modify still works and never renders', as
// Sight is an upgrade, not a dependency. Nothing here may become a new way
// for a modification to fail.
const r = router(null, { replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
const res = await r.refine( { instructions: 'add a feature' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.saw, 0);
@ -368,7 +381,7 @@ test('a render that fails falls through to editing blind rather than failing', a
// Gotenberg down, LibreOffice wedged, a deck too big: none of them may cost
// the author their modification.
const r = router(null, { visionModel: 'seeing-model', slideImages: [], replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
const res = await r.refine( { instructions: 'add a feature' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.saw, 0);
@ -382,7 +395,7 @@ test('an echo is still reported as an echo, even when the reviewer moved somethi
// achieved nothing look like it had worked.
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS,
replies: [JSON.stringify({ slides: DECK.slides })] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
const res = await r.refine( { instructions: 'make it better' });
assert.equal(res.body.unchanged, true, 'judged on the model edit, before the reviewer ran');
});

View file

@ -184,7 +184,7 @@ test('illustration is opt-in, with its own dispatcher rather than the assistant
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
// And it renders where the person is looking, rather than pointing them at an
// image history this feature does not have.
assert.match(read('public/js/myResources.js'), /showIllustrations\(data\.imageJobs \|\| \[\]\)/);
assert.match(read('public/js/myResources.js'), /showIllustrations\(r\.imageJobs \|\| \[\]\)/);
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');

View file

@ -14,7 +14,7 @@ test('every read route goes through the reader rule; every write route still fil
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
assert.match(body, /await readableResource\(req\.params\.id, req\.user\.id/, marker + ' reads through the rule');
}
for (const marker of ["router.put('/my-resources/:id/theme'", "router.post('/my-resources/:id/refine'", "router.delete('/my-resources/:id'", "router.put('/my-resources/:id'"]) {
for (const marker of ["router.put('/my-resources/:id/theme'", "async function refineResource(", "router.delete('/my-resources/:id'", "router.put('/my-resources/:id'"]) {
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
assert.match(body, /AND user_id = \?/, marker + ' stays the owner\'s');
assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers');

View file

@ -107,7 +107,7 @@ test('searching is the route\u2019s job, not something the model is asked to do'
// different sources or searching them differently.
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
assert.match(route, /var sources = await gatherSources\(topic, body\);/, 'generate');
assert.match(route, /var sources = await gatherSources\(subject, req\.body, existing\.topic \|\| instructions\);/,
assert.match(route, /var sources = await gatherSources\(subject, body, existing\.topic \|\| instructions\);/,
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
// Declared before they are used. They were not, once: `var` hoisting made