diff --git a/BUILD-LOG.md b/BUILD-LOG.md index a4108995..76fb7772 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 6246ee00..907aebea 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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). diff --git a/docs/plans/2026-09-12_control-board-mvp.md b/docs/plans/2026-09-12_control-board-mvp.md index a4f39a1c..1b15a244 100644 --- a/docs/plans/2026-09-12_control-board-mvp.md +++ b/docs/plans/2026-09-12_control-board-mvp.md @@ -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 diff --git a/packages/control-board/README.md b/packages/control-board/README.md index 5dffa271..1399f04c 100644 --- a/packages/control-board/README.md +++ b/packages/control-board/README.md @@ -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. | diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index 4593fb2b..64f3c6ce 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -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"; diff --git a/packages/control-board/tests/scan.test.mjs b/packages/control-board/tests/scan.test.mjs index f8d1e4c3..f466ed60 100644 --- a/packages/control-board/tests/scan.test.mjs +++ b/packages/control-board/tests/scan.test.mjs @@ -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 // ---------------------------------------------------------------------------