Files
boocode/apps/coder/src/services/normalize-agent-status.ts
indifferentketchup 59cf082e06 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>
2026-06-01 14:04:04 +00:00

93 lines
3.1 KiB
TypeScript

/**
* 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<readonly [AgentEventBucket, readonly string[]]>,
): Map<string, AgentEventBucket> {
const map = new Map<string, AgentEventBucket>();
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;
}