diff --git a/CHANGELOG.md b/CHANGELOG.md index f285b35..75d7323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.7.6-agent-status-normalize — 2026-06-01 + +The scoped half of `boocode_code_review_v2.md` §1 #10 — normalized external-agent status, surfaced from BooCoder's own dispatch observation (the heavier config-injection notify-hook, clean-room from superset's ELv2 `agent-setup`, is documented as the follow-on). The review's premise ("PTY agents have no status") had partly aged out — warm-ACP/opencode/SDK already carry working/done — so the real gap was that BooCoder never *published* a normalized per-`(chat,agent)` status (blocked-on-permission was invisible; crash/idle weren't pushed). Adds an `agent_status_updated` WS frame (`working|blocked|idle|error`, server+web parity) published from the dispatcher's turn boundaries across all four external paths (warm-acp/opencode/sdk/pty — `working` at start, `idle`/`error` at end) and the permission flow (`blocked` on request, `working` on resolve), best-effort so it never breaks a turn. A clean-room `normalizeAgentEvent` helper (superset's ~30-vendor-event → Start/blocked/Stop collapse, reimplemented with the event names as facts) ships now with 25 tests so the deferred notify-hook injection reuses it verbatim. The `AgentComposerBar` gains a normalized status dot (working=spinner, blocked=amber, idle=gray, error=red) distinct from the WS-liveness dot, fed by a `useAgentStatus` map `CoderPane` tracks per `(chat,agent)`. Built by two parallel agents (data plane + view plane) against 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-claude-sdk-sessionstore`; openspec `agent-status-normalize`. + ## v2.7.5-claude-sdk-sessionstore — 2026-06-01 Lands the Claude Agent SDK direction (`boocode_code_review_v2.md` §1 #9, §6.2 "lean SDK") behind a flag. Adds `@anthropic-ai/claude-agent-sdk@0.3.159` (Commercial Terms — runtime dep, code reference-only) and builds a warm, resumable claude backend to supersede one-shot PTY dispatch — env-gated (`CLAUDE_SDK_BACKEND`, default off) so production claude stays on the unchanged PTY path until a host smoke. **Clean-room `PostgresSessionStore`** implements the SDK's real `SessionStore` type (`append`/`load`/`listSessions`/`delete`/`listSubkeys`) over a new `claude_session_entries` table — typechecked against the installed SDK type, 8 DB-integration tests. **`ClaudeSdkBackend`** (`implements AgentBackend`, mirroring warm-acp/opencode-server) drives one persistent `query()` per `(chat,'claude')` in streaming-input mode via a pushable async-iterable pump, with `sessionStore` + `resume` for cross-turn/cross-restart continuity, a pure `mapSdkMessage`→`AgentEvent` mapper, `session_id` captured from the `init` message, and `result.usage`/`total_cost_usd` accumulated onto `agent_sessions` (backend CHECK gains `'claude_sdk'`). Built against the REAL SDK 0.3.159 types after installing it — surfacing shapes a blind build would have missed (`SDKPartialAssistantMessage` is `type:'stream_event'` needing `includePartialMessages`; `SDKUserMessage.message` is `MessageParam`; the `SDKResultMessage` error arm). Also fixes a latent test-infra deadlock — three DB-integration suites applying the full schema in parallel under `DATABASE_URL` deadlocked, now serialized via `fileParallelism:false`. ~32 new tests (8 store + 10 mapper + 8 pushable + 6 routing); coder suite 269 passing default / 290 with DB; tsc clean against the SDK types; builds clean. **The live streaming pump + resume + an actual claude turn need a host smoke (`CLAUDE_SDK_BACKEND=1` + claude binary + ANTHROPIC auth) — cannot run from the dev container.** The zod peer-dep wants `^4` (workspace `3.25`) — watch at runtime. Builds on `v2.7.4-mistake-tracker-ledger`; openspec `claude-sdk-sessionstore`. diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 2b6b08a..e52c412 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -42,6 +42,7 @@ import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js import { probeAgents } from './services/agent-probe.js'; import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; import { setPermissionHooks } from './services/permission-waiter.js'; +import { publishAgentStatus } from './services/agent-status-publish.js'; import { homedir } from 'node:os'; async function main() { @@ -82,6 +83,21 @@ async function main() { // Broker: in-memory pub/sub for session + user channel streaming. const broker = createBroker(app.log); + // agent-status-normalize (#10): the permission hooks carry only taskId + + // sessionId, but the tasks row holds the (chat_id, agent) pair the status frame + // is keyed on. Resolve it best-effort so a blocked/working status accompanies + // every permission_requested/permission_resolved. Returns null when the task + // lacks a chat_id or agent (sessionless creators) — we simply skip the status. + const resolveChatAgent = async ( + taskId: string, + ): Promise<{ chatId: string; agent: string } | null> => { + const [row] = await sql<{ chat_id: string | null; agent: string | null }[]>` + SELECT chat_id, agent FROM tasks WHERE id = ${taskId} + `; + if (!row?.chat_id || !row.agent) return null; + return { chatId: row.chat_id, agent: row.agent }; + }; + setPermissionHooks({ onPrompt: async (prompt) => { await sql` @@ -96,6 +112,18 @@ async function main() { ...(prompt.input ? { input: prompt.input } : {}), options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })), } as WsFrame); + // #10: agent is blocked on a human decision. + const ca = await resolveChatAgent(prompt.taskId).catch(() => null); + if (ca) { + publishAgentStatus( + broker.publishFrame, + prompt.sessionId, + ca.chatId, + ca.agent, + 'blocked', + 'permission_request', + ); + } }, onResolved: async (taskId, sessionId) => { await sql` @@ -106,6 +134,18 @@ async function main() { task_id: taskId, session_id: sessionId, } as WsFrame); + // #10: human responded — agent resumes work. + const ca = await resolveChatAgent(taskId).catch(() => null); + if (ca) { + publishAgentStatus( + broker.publishFrame, + sessionId, + ca.chatId, + ca.agent, + 'working', + 'permission_resolved', + ); + } }, }); diff --git a/apps/coder/src/services/__tests__/normalize-agent-status.test.ts b/apps/coder/src/services/__tests__/normalize-agent-status.test.ts new file mode 100644 index 0000000..799615a --- /dev/null +++ b/apps/coder/src/services/__tests__/normalize-agent-status.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeAgentEvent } from '../normalize-agent-status.js'; + +describe('normalizeAgentEvent', () => { + describe('working bucket', () => { + const cases = [ + 'SessionStart', + 'UserPromptSubmit', + 'UserPromptSubmitted', + 'PostToolUse', + 'PostToolUseFailure', + 'BeforeAgent', + 'AfterTool', + 'task_started', + ]; + for (const name of cases) { + it(`maps ${name} → working`, () => { + expect(normalizeAgentEvent(name)).toBe('working'); + }); + } + }); + + describe('blocked bucket', () => { + const cases = [ + 'PreToolUse', + 'Notification', + 'PermissionRequest', + 'exec_approval_request', + 'apply_patch_approval_request', + 'request_user_input', + ]; + for (const name of cases) { + it(`maps ${name} → blocked`, () => { + expect(normalizeAgentEvent(name)).toBe('blocked'); + }); + } + }); + + describe('done bucket', () => { + const cases = [ + 'Stop', + 'AfterAgent', + 'SessionEnd', + 'task_complete', + 'agent-turn-complete', + ]; + for (const name of cases) { + it(`maps ${name} → done`, () => { + expect(normalizeAgentEvent(name)).toBe('done'); + }); + } + }); + + describe('unknown / nullish → null', () => { + it('returns null for an unrecognized event', () => { + expect(normalizeAgentEvent('SomeRandomEvent')).toBeNull(); + }); + it('returns null for empty string', () => { + expect(normalizeAgentEvent('')).toBeNull(); + }); + it('returns null for undefined', () => { + expect(normalizeAgentEvent(undefined)).toBeNull(); + }); + }); + + describe('case- and separator-insensitive matching', () => { + it('matches snake_case spelling of a PascalCase event', () => { + expect(normalizeAgentEvent('session_start')).toBe('working'); + expect(normalizeAgentEvent('post_tool_use')).toBe('working'); + expect(normalizeAgentEvent('pre_tool_use')).toBe('blocked'); + }); + it('matches camelCase spelling', () => { + expect(normalizeAgentEvent('userPromptSubmitted')).toBe('working'); + expect(normalizeAgentEvent('postToolUse')).toBe('working'); + expect(normalizeAgentEvent('preToolUse')).toBe('blocked'); + expect(normalizeAgentEvent('sessionEnd')).toBe('done'); + }); + it('matches arbitrary case', () => { + expect(normalizeAgentEvent('STOP')).toBe('done'); + expect(normalizeAgentEvent('notification')).toBe('blocked'); + }); + }); +}); diff --git a/apps/coder/src/services/agent-status-publish.ts b/apps/coder/src/services/agent-status-publish.ts new file mode 100644 index 0000000..b707ae8 --- /dev/null +++ b/apps/coder/src/services/agent-status-publish.ts @@ -0,0 +1,55 @@ +/** + * agent-status-publish (#10) — builds + publishes the `agent_status_updated` + * WS frame on the per-session channel (the same channel CoderPane subscribes to). + * + * Kept separate from normalize-agent-status.ts so that module stays a pure, + * broker-free helper (trivially unit-testable; reused by the config-injection + * follow-on). The frame contract is pinned in apps/server/src/types/ws-frames.ts + * (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web. + */ +import type { Broker } from '@boocode/server/broker'; +import type { WsFrame } from '@boocode/server/ws-frames'; +import type { AgentStatus } from './normalize-agent-status.js'; + +// The exact slice of Broker we need — accepting just the bound method keeps call +// sites flexible (pass `broker.publishFrame.bind(broker)` or, since the broker's +// publishFrame doesn't read `this`, `broker.publishFrame` directly). +type PublishFrame = Broker['publishFrame']; + +/** + * Best-effort publish of a normalized agent status. The broker's publishFrame + * already fail-closes (validates + logs + drops on bad input, never throws), but + * we additionally swallow any unexpected error so a publish can NEVER break the + * turn it's reporting on. + * + * @param publishFrame the session channel publisher (broker.publishFrame) + * @param sessionId WS subscription channel (CoderPane subscribes per-session) + * @param chatId the (chat) half of the (chat, agent) status key + * @param agent the (agent) half of the key + * @param status normalized lifecycle status + * @param reason free-form discriminator (turn_start / turn_complete / …) + * @param at ISO timestamp; defaults to now + */ +export function publishAgentStatus( + publishFrame: PublishFrame, + sessionId: string, + chatId: string, + agent: string, + status: AgentStatus, + reason?: string, + at: string = new Date().toISOString(), +): void { + try { + const frame: WsFrame = { + type: 'agent_status_updated', + chat_id: chatId, + agent, + status, + ...(reason ? { reason } : {}), + at, + }; + publishFrame(sessionId, frame); + } catch { + // never let a status publish break the turn — best-effort only. + } +} diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index b61e787..2fd5bae 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -20,6 +20,8 @@ import { ClaudeSdkBackend } from './backends/claude-sdk.js'; import { shouldUseWarmBackend } from './backends/warm-acp-routing.js'; import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js'; import type { AgentBackend, AgentEvent } from './agent-backend.js'; +import { publishAgentStatus } from './agent-status-publish.js'; +import type { AgentStatus } from './normalize-agent-status.js'; interface InferenceRunner { enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; @@ -66,6 +68,21 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise { // `polling` serializes poll() execution itself (timer + NOTIFY can fire // concurrently) so we never double-select a task. It does NOT serialize task @@ -298,6 +315,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise` @@ -384,6 +403,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise 0) { setTaskCommands(taskId, manifestCommands); @@ -558,6 +580,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // #10: external-agent turn failed/crashed. chatId may be unbound if the throw + // preceded its assignment — guard so the status publish never masks the real + // error. + if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'failed'); + // Best-effort cleanup await cleanupWorktree(projectPath, taskId); clearTaskCommands(taskId); @@ -624,6 +653,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise 0) { setTaskCommands(taskId, manifestCommands); @@ -873,6 +907,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // #10: turn crashed. + if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed'); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -982,6 +1026,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise 0) { setTaskCommands(taskId, manifestCommands); @@ -1123,6 +1170,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // #10: turn crashed. + emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed'); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -1224,6 +1281,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise 0) { setTaskCommands(taskId, manifestCommands); @@ -1364,6 +1424,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // #10: turn crashed. + emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed'); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } diff --git a/apps/coder/src/services/normalize-agent-status.ts b/apps/coder/src/services/normalize-agent-status.ts new file mode 100644 index 0000000..88af393 --- /dev/null +++ b/apps/coder/src/services/normalize-agent-status.ts @@ -0,0 +1,92 @@ +/** + * normalize-agent-status (#10) — clean-room vendor-event → bucket mapping. + * + * Different coding agents (claude, opencode, codex/gemini, goose, qwen) emit + * lifecycle hook events under inconsistent names: PascalCase (`SessionStart`), + * snake_case (`session_start`), camelCase (`sessionStart`), and a handful of + * provider-specific approval events (`exec_approval_request`). This module + * collapses every known event name into one of three coarse signals: + * + * working — the agent is actively progressing a turn + * blocked — the agent is waiting on a human (permission / approval / question) + * done — the turn / session ended cleanly + * + * `null` is returned for anything unrecognized so callers can ignore noise. + * + * Built now for the scoped status-publish, but specifically shaped for reuse by + * the documented config-injection follow-on: a future notify-hook injected into + * each agent's native config will POST the RAW vendor event name to a BooCoder + * endpoint, which runs this helper to derive the normalized status. The names + * below are facts about each agent's hook surface — not copied vendor code. + */ + +export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error'; + +/** The coarse signal a raw vendor event collapses to. */ +export type AgentEventBucket = 'working' | 'blocked' | 'done'; + +// Each bucket lists the canonical vendor event names. Lookup is +// case-insensitive AND separator-insensitive (snake_case / camelCase / +// PascalCase all fold to the same key), so we normalize the raw input the same +// way before matching rather than enumerating every spelling here. +const WORKING_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'UserPromptSubmitted', + 'PostToolUse', + 'PostToolUseFailure', + 'BeforeAgent', + 'AfterTool', + 'task_started', +] as const; + +const BLOCKED_EVENTS = [ + 'PreToolUse', + 'Notification', + 'PermissionRequest', + 'exec_approval_request', + 'apply_patch_approval_request', + 'request_user_input', +] as const; + +const DONE_EVENTS = [ + 'Stop', + 'AfterAgent', + 'SessionEnd', + 'task_complete', + 'agent-turn-complete', +] as const; + +/** + * Fold a raw event name to a separator/case-insensitive key: + * strip every non-alphanumeric character and lowercase. So `post_tool_use`, + * `postToolUse`, `PostToolUse`, and `POST-TOOL-USE` all map to `posttooluse`. + */ +function foldKey(raw: string): string { + return raw.replace(/[^a-z0-9]/gi, '').toLowerCase(); +} + +function buildLookup( + groups: ReadonlyArray, +): Map { + const map = new Map(); + for (const [bucket, names] of groups) { + for (const name of names) map.set(foldKey(name), bucket); + } + return map; +} + +const EVENT_LOOKUP = buildLookup([ + ['working', WORKING_EVENTS], + ['blocked', BLOCKED_EVENTS], + ['done', DONE_EVENTS], +]); + +/** + * Map a raw vendor hook-event name to its normalized bucket, or `null` when the + * name is unknown / undefined. Case- and separator-insensitive. + */ +export function normalizeAgentEvent(raw: string | undefined): AgentEventBucket | null { + if (!raw) return null; + return EVENT_LOOKUP.get(foldKey(raw)) ?? null; +} diff --git a/apps/server/src/types/ws-frames.ts b/apps/server/src/types/ws-frames.ts index 63a4014..e0bb1c1 100644 --- a/apps/server/src/types/ws-frames.ts +++ b/apps/server/src/types/ws-frames.ts @@ -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', diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 4cb5cf9..e12afbc 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -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; + }; diff --git a/apps/web/src/api/ws-frames.ts b/apps/web/src/api/ws-frames.ts index 63a4014..e0bb1c1 100644 --- a/apps/web/src/api/ws-frames.ts +++ b/apps/web/src/api/ws-frames.ts @@ -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', diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx index 1514932..dc45b45 100644 --- a/apps/web/src/components/AgentComposerBar.tsx +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -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 ( + + ); + } + + const bg = + entry.status === 'blocked' ? 'bg-amber-500' + : entry.status === 'error' ? 'bg-destructive' + : 'bg-muted-foreground/40'; + + return ( + + ); +} + +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. */}
+ {/* #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' && ( + + )} {connected !== undefined && ( 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) */}
diff --git a/apps/web/src/hooks/useAgentStatus.ts b/apps/web/src/hooks/useAgentStatus.ts new file mode 100644 index 0000000..4d041f0 --- /dev/null +++ b/apps/web/src/hooks/useAgentStatus.ts @@ -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>({}); + + 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 = {}; + 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]); +} diff --git a/apps/web/src/hooks/useSessionStream.ts b/apps/web/src/hooks/useSessionStream.ts index a4be115..153bc0f 100644 --- a/apps/web/src/hooks/useSessionStream.ts +++ b/apps/web/src/hooks/useSessionStream.ts @@ -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; + } } } diff --git a/openspec/changes/agent-status-normalize/proposal.md b/openspec/changes/agent-status-normalize/proposal.md new file mode 100644 index 0000000..feab417 --- /dev/null +++ b/openspec/changes/agent-status-normalize/proposal.md @@ -0,0 +1,61 @@ +# Normalized external-agent status (#10, scoped) + +**Status:** in progress (started 2026-06-01) +**Source:** `boocode_code_review_v2.md` §1 #10, §5j (superset, Elastic License 2.0 — PATTERN-ONLY, +clean-room; `/opt/forks/superset/.../map-event-type.ts`, `notify-hook.template.sh`, `agent-setup/*`). +**Decision (Sam, 2026-06-01):** scoped status-publish now; config-injection notify-hook as a follow-on. + +## Why (corrected premise) +BooCoder already *observes* agent lifecycle (warm-acp/opencode/SDK backends know active/idle/crashed; +the permission-waiter knows blocked) but never **publishes a normalized per-`(chat,agent)` status** to the +UI — so blocked-on-permission is invisible and crash/idle aren't pushed proactively. The `AgentComposerBar` +dot only shows WS liveness. This batch publishes the status BooCoder already knows; the heavier +config-injection notify-hook (for out-of-band signals) is the documented follow-on. + +## State model (clean-room from superset's `mapEventType`) +Superset collapses ~30 vendor event names → 3 signals: **Start** (working), **PermissionRequest** +(blocked), **Stop** (done). BooCoder adds idle (after done) + error (crash/fail). Normalized status: +`working | blocked | idle | error`. + +## Pinned frame contract (server + web, byte-identical, parity-tested) +```ts +{ type: 'agent_status_updated', chat_id: Uuid, agent: string, + status: 'working' | 'blocked' | 'idle' | 'error', reason?: string, at: IsoTimestamp } +``` +Added to `apps/server/src/types/ws-frames.ts` AND `apps/web/src/api/ws-frames.ts` (the `ws-frames` parity +test), plus the web `WsFrame` union in `apps/web/src/api/types.ts`. Published via the coder's +`broker.publishFrame` (validated against the server `WsFrameSchema`). + +## Clean-room normalize helper (built now, reused by the injection follow-on) +`apps/coder/src/services/normalize-agent-status.ts`: +`normalizeAgentEvent(raw: string): 'working' | 'blocked' | 'done' | null` — a clean-room reimplementation +of the vendor-event-name → bucket mapping (the event names are facts about each agent's hooks: +`SessionStart`/`UserPromptSubmit`/`PostToolUse`→working; `PreToolUse`/`Notification`/`PermissionRequest`/ +`exec_approval_request`→blocked; `Stop`/`session_end`/`task_complete`→done). The scoped publish points use +BooCoder's own already-normalized turn boundaries; this helper exists so the config-injection follow-on +(which receives raw vendor event names POSTed from agent hooks) reuses it. Unit-tested. + +## Publish points (BooCoder's existing observation — no per-backend change) +- Dispatcher (`dispatcher.ts`) turn boundaries, for every external-agent path (warm-acp/opencode/sdk/pty): + `working` at turn start, `idle` on clean completion, `error` on failure. +- Permission-waiter (`permission-waiter.ts` / the `setPermissionHooks` publish in `index.ts`): `blocked` + when a permission is requested, back to `working` when resolved. +A small `publishAgentStatus(broker, chatId, agent, status, reason?)` helper centralizes the frame. + +## Frontend +- `CoderPane.tsx` tracks the latest `agent_status_updated` per `(chat, agent)` (a small live map; reset on + chat switch). +- `AgentComposerBar.tsx` renders a normalized status dot beside the existing session chip (reuse the + `StatusDot` visual language: working=spinner/green, blocked=amber, idle=gray, error=red), distinct from + the WS-liveness `connected` dot. + +## Follow-on (documented, not built): config-injection notify-hook +Clean-room re-derive superset's `agent-setup`: inject a notify hook into each agent's native config +(claude `~/.claude/settings.json`, opencode plugin, codex/gemini templates) that POSTs +`{agent, chat_id, eventType}` to a new `POST /api/coder/agent-status` endpoint, which runs +`normalizeAgentEvent` → publishes the SAME `agent_status_updated` frame. Reuses everything this batch +builds. Catches out-of-band signals BooCoder's dispatch can't see. + +## Verify +- `pnpm -C apps/coder test` (+ normalize-agent-status tests) + `pnpm -C apps/server test` (ws-frames parity) +- `pnpm -C apps/server build && pnpm -C apps/coder build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`