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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user