control board: show the model each seat is running (#1503)

Jason asked for the running model (sonnet, opus, gpt-6-astra) on the
board. readSession keeps model and provider from the log's latest
model_change entry or assistant turn, whichever is later, so a /model
switch shows on the next scan; scanAgent exposes both; the page shows the
model under the agent name with the provider in the hover and a Model
detail row. Blank when the log names none.

Live check on a scratch board against the real data root: all 42 rows
carried a model. Board 91/91. Sonnet review caught a double-escaped hover
title; fixed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 11:13:32 -05:00
co-authored by Claude Fable 5.1
parent d0fb5b0f20
commit 0bed9ba3db
8 changed files with 99 additions and 4 deletions
+42 -1
View File
@@ -60,9 +60,11 @@ function writeFile(path, content) {
function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", id, timestamp, cwd });
}
function messageLine({ timestamp, role, stopReason, texts, blocks }) {
function messageLine({ timestamp, role, stopReason, texts, blocks, model, provider }) {
const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason;
if (model !== undefined) message.model = model;
if (provider !== undefined) message.provider = provider;
if (texts) message.content = texts.map((text) => ({ type: "text", text }));
if (blocks) message.content = (message.content || []).concat(blocks);
return JSON.stringify({ type: "message", timestamp, message });
@@ -190,6 +192,45 @@ test("readSession: extracts fields, collapses/truncates text, counts a truncated
assert.equal(result.skippedLines, 1);
});
test("readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them", () => {
const root = makeRoot();
const modelChange = (timestamp, provider, modelId) => JSON.stringify({ type: "model_change", id: "m", parentId: null, timestamp, provider, modelId });
const switchedDir = join(root, "switched");
writeSessionFile(switchedDir, "model.jsonl", [
sessionLine({ id: "s", timestamp: "2026-09-12T16:06:52.940Z", cwd: "/x" }),
modelChange("2026-09-12T16:06:52.988Z", "zai", "glm-5.3-flash"),
messageLine({ timestamp: "2026-09-12T16:07:00Z", role: "user", texts: ["hi"] }),
messageLine({ timestamp: "2026-09-12T16:07:05Z", role: "assistant", stopReason: "stop", texts: ["hello"], model: "glm-5.3-flash", provider: "zai" }),
modelChange("2026-09-12T16:08:52.233Z", "openai-codex", "gpt-6-astra"),
]);
const switched = readSession(join(switchedDir, "model.jsonl"));
assert.equal(switched.model, "gpt-6-astra", "a /model switch after the last answer wins");
assert.equal(switched.provider, "openai-codex");
const answered = writeSessionFile(root, "answered.jsonl", [
sessionLine({ id: "s", timestamp: "2026-09-12T16:06:52.940Z", cwd: "/x" }),
modelChange("2026-09-12T16:06:52.988Z", "zai", "glm-5.3-flash"),
messageLine({ timestamp: "2026-09-12T16:07:05Z", role: "assistant", stopReason: "stop", texts: ["hello"], model: "claude-sonnet-5", provider: "anthropic" }),
messageLine({ timestamp: "2026-09-12T16:07:06Z", role: "user", texts: ["and?"] }),
]);
assert.deepEqual([readSession(answered).model, readSession(answered).provider], ["claude-sonnet-5", "anthropic"], "the assistant turn names the model that produced it");
const noneDir = join(root, "none");
writeSessionFile(noneDir, "none.jsonl", [
sessionLine({ id: "s", timestamp: "2026-09-12T16:06:52.940Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-12T16:07:00Z", role: "user", texts: ["hi"] }),
]);
assert.equal(readSession(join(noneDir, "none.jsonl")).model, null);
assert.equal(readSession(join(noneDir, "none.jsonl")).provider, null);
const rec = scanAgent({ agent: "a", project: "p", sessionsDir: switchedDir, tmux: {} }, { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.model, "gpt-6-astra", "scanAgent carries model through from the newest log");
assert.equal(rec.provider, "openai-codex");
const bare = scanAgent({ agent: "a", project: "p", sessionsDir: noneDir, tmux: {} }, { isAlive: () => true, now: GATE_NOW });
assert.equal(bare.model, null);
assert.equal(bare.provider, null);
});
test("readSession: lastError carries the assistant errorMessage only when the last assistant turn errored", () => {
const root = makeRoot();
const errored = JSON.stringify({