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: (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", APPROVED, no findings. Verified in Chrome: "1 of 4", "3 of 4", "4", "13 of 38",
console clean. Next: Jason keeps using it. 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, 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, 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, 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, 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". 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)" **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)" 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 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 | | State | Plain-words meaning |
|---------|----------------------| |---------|----------------------|
| working | The agent is in the middle of a turn: thinking or running a tool. | | 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. It is your move now. | | 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. | | 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`. | | 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. | | 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 }; 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. // 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). // 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 }) { export function deriveState({ alive, session }) {
if (alive === false) return "offline"; if (alive === false) return "offline";
if (alive !== true) return "unknown"; if (alive !== true) return "unknown";
if (!session || !session.lastMessage) return "idle"; if (!session || !session.lastMessage) return "idle";
const m = session.lastMessage; const m = session.lastMessage;
if (m.role === "assistant") { if (m.role === "assistant") {
if (m.stopReason === "stop") return "waiting";
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error"; 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";
} }
return "working"; return "working";
+53 -1
View File
@@ -52,12 +52,14 @@ function writeFile(path, content) {
function sessionLine({ id, timestamp, cwd }) { function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", 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 }; const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason; if (stopReason !== undefined) message.stopReason = stopReason;
if (texts) message.content = texts.map((text) => ({ type: "text", text })); 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 }); 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 // Same transform readSession applies, kept here as an independent
// expectation rather than importing an unexported helper. // 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+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+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+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: "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: "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" }, { 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 // 5. scanAgent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------