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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -596,4 +596,16 @@ export type WsFrame =
|
||||
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
|
||||
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
||||
// over `error` text when present).
|
||||
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason };
|
||||
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason }
|
||||
// agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
|
||||
// lifecycle status for external coding agents on the per-session channel. The
|
||||
// CoderPane tracks the latest status per (chat_id, agent) and resets on chat
|
||||
// switch; AgentComposerBar renders the dot (distinct from the WS-liveness dot).
|
||||
| {
|
||||
type: 'agent_status_updated';
|
||||
chat_id: string;
|
||||
agent: string;
|
||||
status: 'working' | 'blocked' | 'idle' | 'error';
|
||||
reason?: string;
|
||||
at: string;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
|
||||
'error',
|
||||
]);
|
||||
|
||||
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
|
||||
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
|
||||
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
|
||||
// dispatcher + permission flow on the per-session channel.
|
||||
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
|
||||
|
||||
const ErrorReasonValue = z.enum([
|
||||
'llm_provider_error',
|
||||
'doom_loop',
|
||||
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
|
||||
commands: z.array(AgentCommandShape),
|
||||
});
|
||||
|
||||
// agent-status-normalize (#10): published by BooCoder on the per-session channel
|
||||
// when an external agent's normalized status changes (turn start/end, permission
|
||||
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
|
||||
// pair and resets on chat switch. `reason` is a free-form discriminator
|
||||
// (turn_start / turn_complete / failed / crashed / permission_request /
|
||||
// permission_resolved).
|
||||
export const AgentStatusUpdatedFrame = z.object({
|
||||
type: z.literal('agent_status_updated'),
|
||||
chat_id: Uuid,
|
||||
agent: z.string().min(1),
|
||||
status: AgentStatusValue,
|
||||
reason: z.string().optional(),
|
||||
at: IsoTimestamp,
|
||||
});
|
||||
|
||||
// ---- discriminated union ---------------------------------------------------
|
||||
|
||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
PermissionRequestedFrame,
|
||||
PermissionResolvedFrame,
|
||||
AgentCommandsFrame,
|
||||
AgentStatusUpdatedFrame,
|
||||
// per-user
|
||||
ChatStatusFrame,
|
||||
SessionUpdatedFrame,
|
||||
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
||||
'permission_requested',
|
||||
'permission_resolved',
|
||||
'agent_commands',
|
||||
'agent_status_updated',
|
||||
'chat_status',
|
||||
'session_updated',
|
||||
'session_renamed',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'luci
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||
import { providerIcon } from '@/components/coder/providerIcons';
|
||||
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
||||
import {
|
||||
@@ -183,6 +184,11 @@ interface Props {
|
||||
// True once the chat has at least one prior turn — gates the chip so it stays
|
||||
// hidden on a brand-new chat. Defaults to false (no chip).
|
||||
hasPriorTurn?: boolean;
|
||||
// #10: normalized status (working|blocked|idle|error) for the active external
|
||||
// agent in this chat, or null for native boocode / before any frame. Renders
|
||||
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
|
||||
// non-coder callers — no dot.
|
||||
agentStatus?: AgentStatusEntry | null;
|
||||
}
|
||||
|
||||
// Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M".
|
||||
@@ -210,7 +216,42 @@ function relativeTime(iso: string | null): string {
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) {
|
||||
// #10: normalized external-agent status dot. Mirrors StatusDot's visual
|
||||
// language but on the four normalized buckets (working|blocked|idle|error),
|
||||
// and is DISTINCT from the WS-liveness `connected` dot beside it:
|
||||
// working — emerald spinning ring (subtle motion, like chat streaming)
|
||||
// blocked — amber dot (matches the permission/blocked state colour)
|
||||
// idle — gray dot
|
||||
// error — red dot
|
||||
function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: string }) {
|
||||
const title =
|
||||
`${agent}: ${entry.status}` + (entry.reason ? ` — ${entry.reason}` : '');
|
||||
|
||||
if (entry.status === 'working') {
|
||||
return (
|
||||
<span
|
||||
aria-label={`Agent status: working${entry.reason ? ` — ${entry.reason}` : ''}`}
|
||||
title={title}
|
||||
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bg =
|
||||
entry.status === 'blocked' ? 'bg-amber-500'
|
||||
: entry.status === 'error' ? 'bg-destructive'
|
||||
: 'bg-muted-foreground/40';
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={`Agent status: ${entry.status}${entry.reason ? ` — ${entry.reason}` : ''}`}
|
||||
title={title}
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn, agentStatus }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
||||
@@ -434,6 +475,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{/* #10: normalized agent status — only for an external agent with a
|
||||
live status frame. Distinct from the WS-liveness dot that follows. */}
|
||||
{agentStatus && value.provider !== 'boocode' && (
|
||||
<AgentStatusDot entry={agentStatus} agent={value.provider} />
|
||||
)}
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mergeWireToolCall } from '@/lib/coder-tools';
|
||||
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
||||
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
||||
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
||||
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -80,6 +81,14 @@ interface WsHandlers {
|
||||
onAssistantComplete?: () => void;
|
||||
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
// #10: normalized external-agent status (working|blocked|idle|error) for the
|
||||
// (chat,agent) carried on the frame. CoderPane records it in a live map and
|
||||
// feeds the active agent's status to AgentComposerBar's status dot.
|
||||
onAgentStatus?: (
|
||||
chatId: string,
|
||||
agent: string,
|
||||
entry: AgentStatusEntry,
|
||||
) => void;
|
||||
}
|
||||
|
||||
type RawCoderMessage = {
|
||||
@@ -326,6 +335,19 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
description: c.description,
|
||||
})),
|
||||
);
|
||||
} else if (frame.type === 'agent_status_updated') {
|
||||
// #10: { chat_id, agent, status, reason?, at }. The chat_id guard
|
||||
// above already dropped cross-chat frames; record per (chat,agent).
|
||||
const chatId = (frame.chat_id ?? scopedChatId) as string | undefined;
|
||||
const agent = frame.agent as string | undefined;
|
||||
const status = frame.status as AgentStatus | undefined;
|
||||
if (chatId && agent && status) {
|
||||
handlersRef.current.onAgentStatus?.(chatId, agent, {
|
||||
status,
|
||||
...(frame.reason ? { reason: frame.reason as string } : {}),
|
||||
at: (frame.at as string) ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore unparseable frames
|
||||
@@ -642,6 +664,8 @@ export function CoderPane({
|
||||
return groups;
|
||||
}, [agentCommands, skillItems, agentConfig.provider]);
|
||||
|
||||
// #10: live normalized status per (chat,agent), reset on chat switch below.
|
||||
const agentStatus = useAgentStatus();
|
||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||
onConnectedChange,
|
||||
onPermissionRequested: (prompt) => {
|
||||
@@ -661,7 +685,21 @@ export function CoderPane({
|
||||
onAgentCommands: (_taskId, commands) => {
|
||||
setLiveTaskCommands(commands);
|
||||
},
|
||||
onAgentStatus: agentStatus.record,
|
||||
});
|
||||
|
||||
// Clear any stale status for the previous chat when the pane switches chats so
|
||||
// a lingering working/blocked dot never carries into the next conversation.
|
||||
useEffect(() => {
|
||||
return () => agentStatus.reset(chatId);
|
||||
}, [chatId, agentStatus]);
|
||||
|
||||
// The active agent's normalized status for this chat. null for native boocode
|
||||
// (no external status published) or before any frame arrives — gates the dot.
|
||||
const currentAgentStatus: AgentStatusEntry | null =
|
||||
agentConfig.provider && agentConfig.provider !== 'boocode'
|
||||
? agentStatus.get(chatId, agentConfig.provider)
|
||||
: null;
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
||||
const [input, setInput] = useState('');
|
||||
@@ -968,6 +1006,7 @@ export function CoderPane({
|
||||
connected={connected}
|
||||
sessionId={sessionId}
|
||||
hasPriorTurn={hasPriorTurn}
|
||||
agentStatus={currentAgentStatus}
|
||||
/>
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
|
||||
62
apps/web/src/hooks/useAgentStatus.ts
Normal file
62
apps/web/src/hooks/useAgentStatus.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user