diff --git a/AGENTS.md b/AGENTS.md index 98b2230..dfa61da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,6 +253,14 @@ Basil, …) — picked per launch, never shared by two running agents, shown as are held in memory, so a restarted board falls back to plain "Agent" for sessions that predate it. +Beside that name, wherever it identifies a run — the sessions list, the +session and Focus headers, the working card's agent line — sits the model +the launch rode: a small mono chip in the id hash's dim register +(`opus-4-8`, the vendor's whole string on hover). Which brain did the work +is a review question, not a state, so the chip takes no colour. A launch +that inherited the vendor default, and a session replayed from disk, wear +no chip at all — the board says nothing rather than guessing. + **▸ start work** launches a headless `claude -p` on the task. It exists only on `in-progress/` cards: moving a card to in-progress is the commitment, and only then does work start — the server refuses launches from anywhere else. diff --git a/manager/core/board.html b/manager/core/board.html index cd403e3..a6026b6 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -101,6 +101,17 @@ .livechip[hidden]{display:none} .dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none} .dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite} + /* the model chip: which brain did this, beside the name that did it. + Machine-produced, so mono; a model is not a state, so it borrows no + colour — it lives in the session-id hash's register wherever names + appear, tracking each site's hash size. cursor:help because the + unshortened string is on the title. */ + .mchip{font-family:var(--mono);font-size:10.5px;font-weight:400;color:var(--dim);white-space:nowrap;cursor:help} + /* the card's row is the tight one: there the chip gives way like the + mono fact beside it rather than pushing the row wider */ + .card .whorow .mchip{font-size:11px;overflow:hidden;text-overflow:ellipsis} + .f-head .s-title .mchip{font-size:12px} + .refline .mchip{font-size:11.5px} /* ── kanban ── */ main{flex:1;display:flex;min-height:0} @@ -697,6 +708,24 @@ function isLiveSession(m) { } function allTasks() { return (S.state?.board.stages || []).flatMap(s => s.tasks); } +/* Which model a run rode, shortened for the chip: the provider path an + opencode-style id carries (anthropic/model-x) and the vendor word a + claude one repeats (claude-opus-4-8) are both redundant beside a board + that already knows its vendor. Nothing else is touched — an unknown + name is shown as recorded rather than guessed at. */ +function shortModel(model) { + return String(model).split('/').pop().replace(/^claude-/, ''); +} + +/* One chip, every place a name identifies a run. A launch that never knew + its model (it inherited the vendor default, or the session was replayed + from disk after a restart) gets nothing at all: no chip is the honest + answer, a placeholder would read as a model named "unknown". */ +function modelChip(agent) { + if (!agent || !agent.model) return ''; + return `${esc(shortModel(agent.model))}`; +} + function connectStream() { const es = new EventSource('/api/stream'); let hadError = false; @@ -1060,6 +1089,7 @@ function cardFor(task) { `
${top.join('')}
` + `
${esc(task.title)}
` + `
${esc(initial)}${esc(who)}` + + modelChip(agent) + `${esc(meta)}${extras.length ? ' · ' + extras.join(' · ') : ''}
` + chipRow + driveWell + liveLine; el.querySelectorAll('a.chip2').forEach(a => @@ -1474,7 +1504,7 @@ function renderFlight() { return `
` + `` + `${esc((m.label || m.id.slice(0, 8)).split(' · ')[0])}` + - `${esc(m.id.slice(0, 8))}` + + `${esc(m.id.slice(0, 8))}${modelChip(agentFor(m.id))}` + `${m.task ? 'on ' + esc(m.task) + ' · ' : ''}${m.count || 0} events · ${m.last ? ago(m.last) + ' ago' : ''}` + (m.lastSummary ? `${esc(m.lastSummary)}` : '') + `
`; @@ -1498,12 +1528,14 @@ function renderFlight() { const stopBtn = agent && agent.status === 'running' ? `` : ''; const branch = agent && agent.branch ? ` · ${esc(agent.branch)}` : ''; - // Honesty about what the run actually rode: the configured model, or - // the vendor default it inherited. Interactive sessions say nothing. - const model = agent ? ` · ${agent.model ? esc(agent.model) : 'model inherited'}` : ''; + // Honesty about what the run actually rode. A known model is the chip's + // job now, beside the name; the line keeps only what the chip cannot + // say — that this launch inherited the vendor default. Interactive + // sessions say nothing. + const model = agent && !agent.model ? ` · model inherited` : ''; $('#fsession').innerHTML = `
${esc((meta.label || sid).split(' · ')[0])}` + - `${esc(sid.slice(0, 8))}
` + + `${esc(sid.slice(0, 8))}${modelChip(agent)}
` + `
${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` + `started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` + `${files.size} files edited · ${checks} check runs${branch}${model}
` + @@ -1690,9 +1722,11 @@ function renderFocus() { const refBits = []; if (task) { refBits.push(task.number ? '#' + esc(task.number) : esc(task.file)); - refBits.push(`${esc((meta.label || '').split(' · ')[0])}`); + refBits.push(`${esc((meta.label || '').split(' · ')[0])}` + + modelChip(agent)); if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch)); - if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited'); + // the chip says which model; the line is left saying only what it can't + if (agent && !agent.model) refBits.push('model inherited'); refBits.push(esc(task.stage) + '/' + esc(task.file)); } else { refBits.push(esc(sid.slice(0, 8)), 'no task attached'); diff --git a/tests/test_model_chip.py b/tests/test_model_chip.py new file mode 100644 index 0000000..73c8931 --- /dev/null +++ b/tests/test_model_chip.py @@ -0,0 +1,171 @@ +"""The model chip beside every agent name (task 24). + +Task 12 recorded which model each launch rode; this puts it where eyes +land — one chip, in the session-id hash's register, beside the name that +identifies a run. + +Two halves. The chip's behaviour (shortening, escaping, and the silence +that means "this launch never knew") is exercised for real: the two +functions are lifted out of board.html and run in node, skipped where +node is absent. The placement — which four render sites wear it, and +that they all wear the same one — is a source-level invariant, board.html +being a single file with inline JS and no frontend test runner. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import unittest +from pathlib import Path + +BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html" + +# The pieces the chip is made of, lifted from the page as written. +PARTS = ( + r"const esc = \(s\) =>.*?\}\[c\]\)\);", + r"function shortModel\(model\) \{.*?\n\}", + r"function modelChip\(agent\) \{.*?\n\}", +) + + +def _harness() -> str: + html = BOARD.read_text(encoding="utf-8") + out = [] + for pattern in PARTS: + m = re.search(pattern, html, re.S) + if m is None: + raise AssertionError(f"board.html no longer defines {pattern!r}") + out.append(m.group(0)) + return "\n".join(out) + + +class ChipBehaviour(unittest.TestCase): + """What the chip actually renders, run as the browser would run it.""" + + @classmethod + def setUpClass(cls): + cls.node = shutil.which("node") + if not cls.node: + raise unittest.SkipTest("node not available — chip behaviour unrun") + cls.src = _harness() + + def chips(self, agents: list) -> list: + """modelChip(a) for each given agent record.""" + script = (self.src + "\nconsole.log(JSON.stringify(" + + json.dumps(agents) + ".map(modelChip)));") + out = subprocess.run([self.node, "-e", script], + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def test_a_launch_that_never_knew_its_model_shows_nothing(self): + """No chip is the honest answer for an inherited launch, a session + with no agent record (yours, or one replayed from disk after a + restart), and a pre-task-12 record: a placeholder would read as a + model actually named that.""" + self.assertEqual( + self.chips([None, {}, {"model": None}, {"model": ""}, + {"name": "Wren"}]), + ["", "", "", "", ""]) + + def test_the_vendor_prefix_is_dropped_and_kept_on_hover(self): + """claude-opus-4-8 → opus-4-8 on the face, whole on the title.""" + chip, = self.chips([{"model": "claude-opus-4-8"}]) + self.assertIn(">opus-4-8", chip) + self.assertIn('title="claude-opus-4-8"', chip) + self.assertIn('class="mchip"', chip) + + def test_a_provider_path_is_dropped_the_same_way(self): + """The opencode adapter's ids are provider/model.""" + chip, = self.chips([{"model": "anthropic/claude-opus-4-8"}]) + self.assertIn(">opus-4-8", chip) + self.assertIn('title="anthropic/claude-opus-4-8"', chip) + + def test_an_unfamiliar_name_is_shown_as_recorded_not_guessed_at(self): + """Shortening only removes what this board knows is redundant — + it never eats the first word of a name it does not recognise.""" + for model in ("gpt-4o", "some-model", "opus-4-8"): + chip, = self.chips([{"model": model}]) + self.assertIn(f">{model}", chip) + + def test_the_model_string_is_escaped_on_both_face_and_title(self): + chip, = self.chips([{"model": 'a"${esc(m.id.slice(0, 8))}', + "the session-detail header": + '${esc(sid.slice(0, 8))}', + "the Focus header": + '${esc((meta.label || \'\').split(\' · \')[0])}', + "the working card's agent line": + '${esc(who)}', + } + + def test_every_name_that_identifies_a_run_wears_the_chip(self): + for where, anchor in self.SITES.items(): + pos = self.html.find(anchor) + self.assertNotEqual(pos, -1, f"{where} no longer renders as expected") + window = self.html[pos + len(anchor):pos + len(anchor) + 40] + self.assertIn("modelChip(", window, + f"{where} lost the model chip beside its name") + + def test_there_is_one_chip_component_not_four(self): + """One component means one register: a second inline copy is how + the four drift apart.""" + self.assertEqual(self.html.count('class="mchip"'), 1, + "the chip's markup must live only in modelChip()") + self.assertEqual(self.html.count("function modelChip("), 1) + + def test_the_chip_is_the_id_hashs_register_and_means_no_state(self): + """Mono because a model name is machine-produced, dim because it + is a footnote to the name — and no state colour, because a model + is not a state.""" + rule = re.search(r"\n \.mchip\{([^}]*)\}", self.html) + self.assertIsNotNone(rule, "the .mchip rule is gone") + self.assertIn("font-family:var(--mono)", rule.group(1)) + self.assertIn("color:var(--dim)", rule.group(1)) + for state_colour in ("--accent", "--calm", "--alarm"): + self.assertNotIn(state_colour, rule.group(1), + f"the chip took on {state_colour}: colour " + "would start meaning 'model' as well as state") + + def test_the_chip_never_wraps_a_line_it_joins(self): + """It joins flex rows carrying names and timestamps; a wrapping + chip would move them.""" + rule = re.search(r"\n \.mchip\{([^}]*)\}", self.html) + self.assertIn("white-space:nowrap", rule.group(1)) + + def test_the_metadata_lines_no_longer_repeat_a_known_model(self): + """The chip says which model; the two lines that used to carry it + keep only what the chip cannot say — that a launch inherited the + vendor default — and say it nowhere else.""" + for m in re.finditer(r"model inherited", self.html): + window = self.html[max(0, m.start() - 200):m.start()] + self.assertIn("!agent.model", window, + "'model inherited' must be reached only when the " + "model is genuinely unknown") + self.assertEqual(self.html.count("model inherited"), 2, + "the session-detail line and the Focus refline are " + "the two places that say it") + + +if __name__ == "__main__": + unittest.main()