Drop fill and submit from the actions footer, they always throw

The footer is the line an agent reads to pick its next command, so a name
in it that cannot run costs a turn and returns nothing. fill and submit are
still stubs, so every page with an input was offering two commands that
throw NotImplemented. Removing them also takes 7 tokens off the rendered
news fixture and 6 off login.

The test probes act.js for handlers that throw NotImplemented rather than
listing them by name, so the next stub to land is covered without anyone
remembering to come back here. It checks all three footers: the render, and
both of find's paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Z.
2026-09-02 00:28:51 +02:00
co-authored by Claude Opus 5
parent 65322bc5b6
commit 21566143d1
2 changed files with 41 additions and 4 deletions
+3 -4
View File
@@ -101,7 +101,6 @@ export function render(page, { budget = 500, from = 0 } = {}) {
const lines = [...head];
let spent = estimateTokens(lines.join('\n'));
let hasLinks = false;
let hasInputs = false;
let i = Math.max(0, from);
// What the rest of the page would cost if it were all printed. When that is
@@ -129,7 +128,6 @@ export function render(page, { budget = 500, from = 0 } = {}) {
spent += cost;
lines.push(line);
if (block.type === 'link' || block.type === 'button') hasLinks = true;
if (block.type === 'input') hasInputs = true;
}
const rest = blocks.slice(i);
@@ -140,10 +138,11 @@ export function render(page, { budget = 500, from = 0 } = {}) {
if (rest.length) {
lines.push(`... ${num(rest.length)} more blocks (~${num(leftTokens)} tokens): 'oc next' for the next ~${num(budget)}, 'oc raw' for all`);
}
// An input on the page adds no action. 'fill' and 'submit' are still stubs
// that throw, and this footer is the line an agent trusts for what to run
// next, so naming one of them costs a turn and returns nothing.
const actions = [
hasLinks && 'do <n>',
hasInputs && 'fill <n> <text>',
hasInputs && 'submit',
'read <n>',
rest.length && 'next',
'raw',
+38
View File
@@ -244,3 +244,41 @@ test('a snippet stays one line even when the block it came from is code', () =>
assert.match(lines[0], /^2 matches for "needle"/);
assert.equal(lines[1], '[1] first(); needle(); third();');
});
test('no footer names a command that is not available yet', async () => {
// The footer is the line an agent reads to decide what to run next, so a
// name in it that always throws costs a turn and returns nothing. Which
// commands are stubs is probed here rather than listed, so the next stub to
// land is covered without anyone remembering to come back and add it.
const act = await import('../src/act.js');
const stubs = Object.entries(act)
.filter(([, value]) => typeof value === 'function')
.filter(([, fn]) => {
try {
fn();
return false;
} catch (err) {
return err instanceof act.NotImplemented;
}
})
.map(([name]) => name);
assert.ok(stubs.length, 'the probe found no stubs, so it is no longer testing anything');
open();
const loginHTML = readFileSync(new URL('./pages/login.html', import.meta.url), 'utf8');
// One output per place that builds a footer: a render with inputs, which is
// what used to offer fill and submit, and both of find's paths.
const outputs = [
render(page(), { budget: 500 }).text,
render(distill(loginHTML, 'https://example.test/login'), { budget: 500 }).text,
find('postgres'),
find('a'),
];
const footers = outputs.flatMap((out) => out.split('\n').filter((line) => line.startsWith('actions:')));
assert.equal(footers.length, outputs.length, `every output should carry one footer:\n${footers.join('\n')}`);
for (const footer of footers) {
for (const stub of stubs) {
assert.ok(!footer.includes(stub), `footer offers '${stub}', which throws NotImplemented:\n${footer}`);
}
}
});