feat: normalized external-agent status (#10 scoped) (v2.7.6)

Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status
BooCoder already observes (the config-injection notify-hook is the documented
follow-on, clean-room from superset ELv2).

- agent_status_updated WS frame (working|blocked|idle|error), server+web parity.
- Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty:
  working at start, idle/error at end) + the permission flow (blocked/working).
  Best-effort, never breaks a turn.
- Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked
  /Stop collapse, event names as facts) + 25 tests — reused by the follow-on.
- AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per
  (chat,agent) by a useAgentStatus map in CoderPane.

Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294
tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears
the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5.
This commit is contained in:
2026-06-01 14:04:04 +00:00
parent 5d38dc13e4
commit 68875b7abc
14 changed files with 623 additions and 7 deletions

View File

@@ -0,0 +1,62 @@
import { useCallback, useMemo, useState } from 'react';
// Normalized external-agent status (#10). Consumed from the
// `agent_status_updated` WS frame the coder backend publishes:
// { type: 'agent_status_updated'; chat_id; agent; status; reason?; at }
// BooCoder collapses ~30 vendor lifecycle events into these four buckets:
// working — turn in flight
// blocked — waiting on a permission / approval
// idle — clean completion
// error — crash / failure
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error';
export interface AgentStatusEntry {
status: AgentStatus;
reason?: string;
at: string;
}
const key = (chatId: string, agent: string): string => `${chatId}:${agent}`;
// Per-(chat,agent) live status map. The dot reflects the latest frame for the
// active agent in the current chat; entries are reset when the chat switches so
// a stale "working"/"blocked" from a previous chat never leaks into the next.
export function useAgentStatus() {
const [map, setMap] = useState<Record<string, AgentStatusEntry>>({});
const record = useCallback(
(chatId: string, agent: string, entry: AgentStatusEntry) => {
setMap((prev) => ({ ...prev, [key(chatId, agent)]: entry }));
},
[],
);
// Drop every entry for a chat (called on chat switch). No-op when nothing
// matches so it's safe to call unconditionally from an effect.
const reset = useCallback((chatId: string | undefined) => {
setMap((prev) => {
if (!chatId) return prev;
const prefix = `${chatId}:`;
let changed = false;
const next: Record<string, AgentStatusEntry> = {};
for (const [k, v] of Object.entries(prev)) {
if (k.startsWith(prefix)) {
changed = true;
continue;
}
next[k] = v;
}
return changed ? next : prev;
});
}, []);
const get = useCallback(
(chatId: string | undefined, agent: string | undefined): AgentStatusEntry | null => {
if (!chatId || !agent) return null;
return map[key(chatId, agent)] ?? null;
},
[map],
);
return useMemo(() => ({ record, reset, get }), [record, reset, get]);
}

View File

@@ -189,6 +189,12 @@ function applyFrame(state: State, frame: WsFrame): State {
// duplicating async work inside a synchronous reducer.
return state;
}
case 'agent_status_updated': {
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
// own WS handler, not BooChat's native message reducer. No-op here to keep
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
}
}