Pin the mid-tool-call rule in the control board scanner (#1503)

Acceptance rule (plan page, bf641e22): a seat mid-tool-call is working,
never waiting. The scanner already met it through pi's stopReason values;
deriveState now also checks the content for a toolCall block (working),
after the error stop reasons and before "stop" (waiting). Thinking blocks
do not keep a text turn from being waiting. Three JSONL fixture tests and
three state-table cases pin the rule. Live check on the real board:
orch-01 and rev-code-01 mid-tool-call are working, velma's finished
text-only turn is waiting. Sonnet review: APPROVED.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 09:29:19 -05:00
co-authored by Claude Fable 5.1
parent bf641e220a
commit 6ec253de20
6 changed files with 95 additions and 4 deletions
+15
View File
@@ -2025,3 +2025,18 @@ under the table is unchanged. Page-only change, one static test
(control-board 66/66, packages/mosaic 69/69). Sonnet review:
APPROVED, no findings. Verified in Chrome: "1 of 4", "3 of 4", "4", "13 of 38",
console clean. Next: Jason keeps using it.
## 2026-09-12 — Control board step 3, mid-tool-call rule (#1503)
Before: the acceptance rule "a seat mid-tool-call is working, never
waiting" (plan page, commit bf641e22) had no test of its own. The scanner
met it only through pi's stopReason values.
After: deriveState also checks the message content for a toolCall block
(working), after the error stop reasons (error) and before "stop"
(waiting). Thinking blocks do not affect waiting. Three JSONL fixture tests
and three state-table cases pin it. Suites: control-board 69/69,
packages/mosaic 69/69. Live check at 14:27Z on the real board: orch-01
(assistant + toolCall) working, rev-code-01 (tool result last) working,
velma (text-only, stop) waiting. Sonnet review: APPROVED, no findings.
No seat changed state because of this change; it closes a gap, not a bug.
+1
View File
@@ -218,3 +218,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, second refinement (#1503): Jason asked for a way to recall Seen rows; added a collapsed "Seen (N)" section with Unsee per row. Page-only change plus one static test (64/64); sonnet review APPROVED; live check in Chrome against Jason's two real marks.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, third refinement (#1503): Jason suggested a per-project "Hide seen" checkbox beside "Hide offline"; added, on by default, with a combined hidden-count note. Page-only change plus one static test (65/65); sonnet review APPROVED (one test-rigor FYI fixed); live check in Chrome.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, fourth refinement (#1503): Jason asked for "N of N" in the project header; now "shown of total" while a checkbox hides rows, plain total otherwise. Page-only change plus one static test (66/66); sonnet review APPROVED (no findings); live check in Chrome at all four edges.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, mid-tool-call rule (#1503), asked by the professor session on Jason's behalf: scanner already honoured the rule via stopReason; added a content-level toolCall check, 3 fixture tests + 3 table cases (69/69); live check on orch-01/rev-code-01 (working) and velma (waiting); sonnet review APPROVED (no findings; thinking-block nuance already recorded).
@@ -145,6 +145,19 @@ closed across refreshes.
Seen section exists. Each project header has both boxes, on by default,
and the note under the table reads "N offline hidden · N seen hidden".
**2026-09-12 — Mid-tool-call rule checked and pinned.** Requested by the
professor session on Jason's behalf. Finding: the scanner already honoured
the rule, but only through pi's `stopReason` (`toolUse` and tool results
were `working`, `stop` was `waiting`; `deriveState` in `scan.mjs`). It now
also looks at the content: an assistant message with a `toolCall` block is
`working` whatever its stop reason; an error stop reason still wins; a
`thinking` block does not stop a text turn from being `waiting`. Three
fixture tests (tool call after a question-looking text, tool result last,
finished text-only turn) plus three state-table cases. Live check at
14:27Z: orch-01 mid-tool-call → working, rev-code-01 tool result last →
working, velma finished text-only turn → waiting; all 13 waiting seats had
text-only (or thinking+text) last messages, none had a tool call.
**2026-09-12 — Header count "N of N".** Jason pointed out that "fleet (38)"
sat above a table showing 13 rows. The header now reads "fleet (13 of 38)"
while a checkbox hides something and falls back to "fleet (38)" when nothing
+2 -2
View File
@@ -11,8 +11,8 @@ rewritable; they are not run records and are not evidence.
| State | Plain-words meaning |
|---------|----------------------|
| working | The agent is in the middle of a turn: thinking or running a tool. |
| waiting | The agent finished its turn. It is your move now. |
| working | The agent is in the middle of a turn: thinking or running a tool. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. |
| waiting | The agent finished its turn with a text-only message. It is your move now. |
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. |
| idle | The agent is live but has not had a conversation yet. |
+11 -1
View File
@@ -95,16 +95,26 @@ export function readSession(file) {
return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, skippedLines };
}
// True when an assistant message carries a tool call in its content.
export function hasToolCall(message) {
return Array.isArray(message?.content) && message.content.some((c) => c && c.type === "toolCall");
}
// Pure state rule. alive: true/false, or null when liveness could not be checked.
// A null check is reported as "unknown" rather than assumed alive (fail closed).
// Acceptance rule (plan page, 2026-09-12): a seat mid-tool-call is working,
// never waiting. The newest entry being an assistant message with a tool call,
// or a tool result, means working even if the last text looked like a question.
// waiting needs a text-only assistant message whose turn ended (stopReason stop).
export function deriveState({ alive, session }) {
if (alive === false) return "offline";
if (alive !== true) return "unknown";
if (!session || !session.lastMessage) return "idle";
const m = session.lastMessage;
if (m.role === "assistant") {
if (m.stopReason === "stop") return "waiting";
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
if (hasToolCall(m)) return "working";
if (m.stopReason === "stop") return "waiting";
return "working";
}
return "working";
+53 -1
View File
@@ -52,12 +52,14 @@ function writeFile(path, content) {
function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", id, timestamp, cwd });
}
function messageLine({ timestamp, role, stopReason, texts }) {
function messageLine({ timestamp, role, stopReason, texts, blocks }) {
const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason;
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 });
}
const TOOL_CALL = { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } };
// Same transform readSession applies, kept here as an independent
// expectation rather than importing an unexported helper.
@@ -237,6 +239,9 @@ test("deriveState: full state table", () => {
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
@@ -247,6 +252,53 @@ test("deriveState: full state table", () => {
}
});
// ---------------------------------------------------------------------------
// 4b. Acceptance rule: a seat mid-tool-call is working, never waiting
// (docs/plans/2026-09-12_control-board-mvp.md). Fixtures mirror real pi logs.
// ---------------------------------------------------------------------------
function ruleSpec(root) {
return { agent: "a", project: "p", sessionsDir: join(root, "sessions"), tmux: {} };
}
test("rule: newest entry is an assistant message with a tool call, after a question-looking text, is working", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["go"] }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "toolUse", texts: ["Should I run the suite now?"], blocks: [TOOL_CALL] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "working");
assert.equal(rec.waitingOnYou, false);
assert.equal(rec.lastAssistantText, "Should I run the suite now?", "the question is still shown, it just does not mean waiting");
});
test("rule: newest entry is a tool result with no assistant text after it is working", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "toolUse", blocks: [{ type: "thinking", thinking: "x" }, TOOL_CALL] }),
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "toolResult", texts: ["file1\nfile2"] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "working");
assert.equal(rec.waitingOnYou, false);
});
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "toolUse", blocks: [TOOL_CALL] }),
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "toolResult", texts: ["ok"] }),
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "waiting");
assert.equal(rec.waitingOnYou, true);
});
// ---------------------------------------------------------------------------
// 5. scanAgent
// ---------------------------------------------------------------------------