Files
oc/tests/act.test.js
T
only-cli 90d5c20ef9 fix: hand over code an agent can actually run
A syntax highlighter gives every token of a command its own element, so
`s3://bucket/` reached the walk as `s3`, `:`, `//`, `bucket`, `/`, and the
rule that reassembles text fragments only glues the ones sharing a parent.
The rest were space-joined. The AWS CLI reference handed over

  aws s3 cp test . txt s3 : // amzn - s3 - demo - bucket / test2 . txt

and Node's fs docs handed over `console . log` and `fd ?. close ()`. Any
command or snippet an agent took from a docs page was wrong, and nothing in
the output said so. Highlighting is not an edge case: of 172 pre elements on
the AWS CLI reference, the Rust book, the Node API docs and the Python
library docs, 159 are split this way.

A pre or code subtree is now read as one string. That costs nothing to
follow, because not one of those 172 blocks contains a link, and it also
fixes inline code, which was inserting a space into `Byte ( u8 only)`.

Two things fall out of it.

Page furniture had to stop riding along. Node puts a language label beside a
copy button inside every code block, so the subtree's text ended in
`javascriptcopy`. A control is chrome, and so is the block-level element
holding it, which is how the label leaves with the button it sits beside.
The test stays on block-level wrappers because a highlighter's own elements
are inline, so a stray control can never take a line of code out with it.

Code blocks keep their lines. Collapsing them was survivable while the code
was already wrecked; once it reads correctly, `// read it back` in front of
the statements that followed it is worse, because the output now looks
trustworthy. Blank runs and the indentation the whole block shares carry no
meaning and still go. A cut lands on a line end for the same reason it
already lands on a sentence end, and a snippet stays one line, because that
is what find's index promises.

Measured over five real pages, the compact view moves by -15, -7, +208, -45
and 0 characters, about 35 tokens in total, all of the growth being Python's
pretty-printed output getting its indentation back.
2026-08-23 09:12:25 -04:00

234 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Every session helper reads OC_HOME when it runs, so pointing it at a temp
// directory here keeps the suite offline and out of the real home directory.
process.env.OC_HOME = mkdtempSync(join(tmpdir(), 'oc-test-'));
const { distill } = await import('../src/distill.js');
const { activate, read, next, find } = await import('../src/act.js');
const { sessionFromPage, saveSession, loadSession, resolveHref } = await import('../src/session.js');
const { render } = await import('../src/render.js');
const html = readFileSync(new URL('./pages/news.html', import.meta.url), 'utf8');
const searchHTML = readFileSync(new URL('./pages/search.html', import.meta.url), 'utf8');
const page = () => distill(html, 'https://example.test/news');
const open = (name = 'default', budget = 500) => {
const p = page();
saveSession(name, sessionFromPage(p, loadSession(name), { cursor: render(p, { budget }).stats.next }));
};
test('a rendered page is remembered with absolute URLs for every handle', () => {
open();
const state = loadSession('default');
assert.equal(state.url, 'https://example.test/news');
assert.equal(state.blocks.find((b) => b.n === 2).href, 'https://example.test/item?id=1');
assert.equal(state.blocks.length, page().blocks.length, 'the whole page must survive for read and next');
});
test('do follows the link behind a number without the agent seeing a URL', () => {
open();
assert.deepEqual(activate(2), { url: 'https://example.test/item?id=1', text: 'Show HN: I built a tiny CSV toolkit' });
});
test('do still works on a session saved by an older version', () => {
saveSession('legacy', { url: 'https://example.test/old', handles: { 1: { type: 'link', text: 'a', href: 'https://example.test/a' } } });
assert.equal(activate(1, { session: 'legacy' }).url, 'https://example.test/a');
assert.throws(() => next({ session: 'legacy' }), /older oc, run 'oc open https:\/\/example.test\/old'/);
});
test('read prints the region at a number in full, uncut', () => {
open();
const out = read(9);
assert.ok(out.includes('safely does'), 'read must not stop at the compact cap');
assert.ok(!out.includes('+144 chars'), 'read must not print a cut marker for text it printed whole');
assert.ok(out.includes('## [8] About'), 'the heading above the block gives it context');
assert.ok(!out.includes('Postgres 18 released'), 'the section before it is not part of the region');
});
test('read of a heading takes the section under it', () => {
open();
const out = read(8);
assert.ok(out.startsWith('## [8] About'));
assert.ok(out.includes('safely does'));
});
test('a region too big for the budget says where to pick it up', () => {
open();
const out = read(8, { budget: 20 });
assert.match(out, /region cut at ~20 tokens, continue with 'oc read \d+'/);
});
test('read and next explain themselves when the number or the page is missing', () => {
open();
assert.throws(() => read(9999), /no \[9999\].*oc open/s);
assert.throws(() => read(0), /usage: oc read <n>/);
assert.throws(() => read(1, { session: 'never-opened' }), /oc open <url>' first/);
});
test('find reports where a string is, with a number to read it by', () => {
open();
const out = find('postgres');
assert.ok(out.startsWith('1 match for "postgres"'));
assert.ok(out.includes('[4] Postgres 18 released'), `wrong hit line:\n${out}`);
assert.ok(out.includes('actions: do <n>'), 'a link hit should offer do');
});
test('find opens the snippet on the match, not on the start of a long block', () => {
// Several long blocks holding the same term is what puts find on its
// snippet path: too much to print whole, too many to be the one answer.
const filler = 'x'.repeat(300);
saveSession('long', {
url: 'https://example.test/long',
blocks: [1, 2, 3].map((n) => ({ n, type: 'text', text: `${filler} needle ${filler}` })),
cursor: null,
});
const out = find('needle', { session: 'long', budget: 40 });
assert.match(out, /\[1\] \.\.\. .*needle/, 'the window must open on the match');
assert.ok(!out.includes('x'.repeat(250)), `snippet was not trimmed:\n${out.slice(0, 200)}`);
});
test('find answers with the whole match when the matches fit', () => {
open();
// The point of the whole path: the text an agent would have spent a `read
// <n>` on arrives in the command that found it.
const many = find('fixture');
assert.ok(!many.includes('...'), `nothing should be elided:\n${many}`);
assert.ok(many.includes('which this sentence now safely does'), 'the block must arrive whole');
});
test('a single match is read, not pointed at', () => {
open();
// One hit means the agent has already said where it wants to look, so the
// number alone would cost a turn to resolve into the region behind it.
const out = find('lazy dog');
assert.match(out, /1 match for "lazy dog", region \[9\]/);
assert.ok(out.includes('which this sentence now safely does'), 'the region must arrive with it');
assert.ok(out.includes('## [8] About'), 'and with the heading that gives it context');
});
test('a phrase that matches nothing falls back to the words, and says so', () => {
open();
const out = find('dog quick');
assert.ok(out.includes('matching the words separately'));
assert.ok(out.includes('[9]'));
assert.match(find('nothing here at all'), /no match .* as a phrase or as separate words/);
});
test('find caps its own output and says how many it held back', () => {
open();
const out = find('comments', { budget: 3 });
assert.match(out, /\.\.\. \d+ more matches/);
});
test('find needs a query and a page', () => {
open();
assert.throws(() => find(' '), /usage: oc find <query>/);
assert.throws(() => find('x', { session: 'never-opened' }), /oc open <url>' first/);
});
test('next continues where the budget stopped, then says the page is done', () => {
// Small enough that the fixture stays well past the budget, or the finish
// rule would hand the whole page over in one go and there would be no paging.
open('paged', 25);
const first = next({ session: 'paged', budget: 25 });
assert.ok(first.startsWith('# Fixture News (continued)'));
assert.ok(!first.includes('Show HN'), 'next must not reprint what open already charged for');
let out = first;
for (let i = 0; i < 10 && loadSession('paged').cursor != null; i++) {
out = next({ session: 'paged', budget: 25 });
}
assert.equal(loadSession('paged').cursor, null, 'paging must reach the end of the page');
assert.ok(out.includes('newest'), 'the last block of the page must come out eventually');
assert.match(next({ session: 'paged' }), /end of https:\/\/example.test\/news/);
});
test('search result redirectors resolve to the page they wrap', () => {
const wrapped = 'https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F&rut=abc';
assert.equal(resolveHref(wrapped, 'https://html.duckduckgo.com/html/'), 'https://doc.rust-lang.org/book/');
assert.equal(resolveHref('https://www.google.com/url?q=https://example.test/a', ''), 'https://example.test/a');
// A normal link that merely has a url-shaped query parameter is left alone.
assert.equal(
resolveHref('/search?url=https://example.test/a', 'https://example.test/'),
'https://example.test/search?url=https://example.test/a',
);
});
test('links a browser cannot follow are not offered as handles', () => {
assert.equal(resolveHref('javascript:void(0)', 'https://example.test/'), null);
assert.equal(resolveHref('', 'https://example.test/'), null);
});
test('every failure names the command that fixes it', () => {
open();
assert.throws(() => activate(9999), /handles 1-\d+.*oc open/s);
assert.throws(() => activate(0), /usage: oc do <n>/);
assert.throws(() => activate(1, { session: 'never-opened' }), /oc open <url>' first/);
const input = page().blocks.find((b) => b.type === 'input');
assert.throws(() => activate(input.n), /is an input.*oc fill/s);
const button = page().blocks.find((b) => b.type === 'button');
assert.throws(() => activate(button.n), /no link to follow/);
});
test('do on a heading or a text block reads it instead of refusing', () => {
open();
// Nothing to fetch, so the caller is told to read rather than to open. An
// error here would cost a turn to say what the next command should be.
assert.deepEqual(activate(1), { read: 1, text: 'Fixture News' });
assert.equal(activate(9).read, 9);
assert.ok(read(activate(9).read).includes('safely does'), 'the read must be the full text');
});
test('do on a search result title opens it instead of reading it back', () => {
// What this costs when it goes wrong: `do` on the most obvious number on a
// results page, the title, used to print the title back, so the agent spent
// one turn learning nothing and another finding the number that navigates.
const p = distill(searchHTML, 'https://fixture.test/html/?q=s3+cp+recursive');
saveSession('search', sessionFromPage(p, null, { cursor: render(p, { budget: 500 }).stats.next }));
const target = activate(1, { session: 'search' });
assert.equal(target.read, undefined, 'a title that is a link must not be read back');
// The engine wraps its results in a click tracker whose landing page is a
// script, so the handle has to resolve to the destination itself.
assert.equal(target.url, 'https://docs.example.test/s3/cp.html');
const pilcrow = p.blocks.find((b) => b.type === 'heading' && b.text.startsWith('Options'));
assert.equal(activate(pilcrow.n, { session: 'search' }).read, pilcrow.n, 'a permalink heading still reads');
});
test('named sessions keep separate page state', () => {
open('work');
saveSession('other', { url: 'https://example.test/other', blocks: [], cursor: null });
assert.equal(activate(2, { session: 'work' }).url, 'https://example.test/item?id=1');
assert.throws(() => activate(2, { session: 'other' }), /no \[2\]/);
});
test('history grows with each page and stays bounded', () => {
let state = null;
for (let i = 0; i < 25; i++) state = sessionFromPage(distill(html, `https://example.test/p${i}`), state);
assert.equal(state.history.length, 20);
assert.equal(state.history.at(-1), 'https://example.test/p24');
});
test('a snippet stays one line even when the block it came from is code', () => {
// Code blocks keep their newlines. An index that prints one match per line
// cannot, or the header's count stops matching what is on screen. Long
// filler beside it is what keeps find on the snippet path.
const filler = 'x'.repeat(600);
saveSession('code', {
url: 'https://fixture.test/c',
blocks: [
{ n: 1, type: 'text', text: ['first();', 'needle();', 'third();'].join('\n') },
{ n: 2, type: 'text', text: `${filler} needle ${filler}` },
],
cursor: null,
});
const out = find('needle', { session: 'code', budget: 20 });
const lines = out.split('\n');
assert.match(lines[0], /^2 matches for "needle"/);
assert.equal(lines[1], '[1] first(); needle(); third();');
});