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:
@@ -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.
|
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
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js
|
|||||||
import { probeAgents } from './services/agent-probe.js';
|
import { probeAgents } from './services/agent-probe.js';
|
||||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||||
|
import { publishAgentStatus } from './services/agent-status-publish.js';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -82,6 +83,21 @@ async function main() {
|
|||||||
// Broker: in-memory pub/sub for session + user channel streaming.
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||||
const broker = createBroker(app.log);
|
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({
|
setPermissionHooks({
|
||||||
onPrompt: async (prompt) => {
|
onPrompt: async (prompt) => {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -96,6 +112,18 @@ async function main() {
|
|||||||
...(prompt.input ? { input: prompt.input } : {}),
|
...(prompt.input ? { input: prompt.input } : {}),
|
||||||
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
||||||
} as WsFrame);
|
} 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) => {
|
onResolved: async (taskId, sessionId) => {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -106,6 +134,18 @@ async function main() {
|
|||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
} as WsFrame);
|
} 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/coder/src/services/agent-status-publish.ts
Normal file
55
apps/coder/src/services/agent-status-publish.ts
Normal file
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import { ClaudeSdkBackend } from './backends/claude-sdk.js';
|
|||||||
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
|
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
|
||||||
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
|
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
|
||||||
import type { AgentBackend, AgentEvent } from './agent-backend.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 {
|
interface InferenceRunner {
|
||||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
@@ -66,6 +68,21 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
return task.session_id ?? `task:${task.id}`;
|
return task.session_id ?? `task:${task.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agent-status-normalize (#10): publish a normalized per-(chat,agent) status on
|
||||||
|
// the session channel. Every external-agent path (warm-acp / opencode / claude-sdk /
|
||||||
|
// pty one-shot) reports `working` at turn start, `idle` on clean completion, and
|
||||||
|
// `error` on the failure path through this single helper so the four paths stay
|
||||||
|
// DRY and consistent. Best-effort — publishAgentStatus never throws.
|
||||||
|
function emitAgentStatus(
|
||||||
|
sessionId: string,
|
||||||
|
chatId: string,
|
||||||
|
agent: string,
|
||||||
|
status: AgentStatus,
|
||||||
|
reason: string,
|
||||||
|
): void {
|
||||||
|
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||||
|
}
|
||||||
|
|
||||||
async function poll(): Promise<void> {
|
async function poll(): Promise<void> {
|
||||||
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||||
// concurrently) so we never double-select a task. It does NOT serialize task
|
// 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<v
|
|||||||
// Create an abort controller for this task
|
// Create an abort controller for this task
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
// #10: hoisted above the try so the catch block can report `error` status with
|
||||||
|
// the (chat, agent) key. Empty until resolved below; guarded before use.
|
||||||
|
let sessionId = '';
|
||||||
|
let chatId = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mark running
|
// Mark running
|
||||||
await sql`
|
await sql`
|
||||||
@@ -306,9 +328,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let sessionId: string;
|
|
||||||
let chatId: string;
|
|
||||||
|
|
||||||
if (task.session_id) {
|
if (task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
const chats = await sql<{ id: string }[]>`
|
const chats = await sql<{ id: string }[]>`
|
||||||
@@ -384,6 +403,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: external-agent turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -558,6 +580,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||||
|
// #10: external-agent turn completed cleanly.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -570,6 +594,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
|
||||||
|
// #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
|
// Best-effort cleanup
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
@@ -624,6 +653,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
// #10: hoisted so the catch can report `error` with the (chat, agent) key.
|
||||||
|
let sessionId = '';
|
||||||
|
let chatId = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
||||||
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
|
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
|
||||||
@@ -640,8 +673,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
// it directly. Session-less creators (arena, MCP, new_task, generic
|
// it directly. Session-less creators (arena, MCP, new_task, generic
|
||||||
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
||||||
// ensureSession never receives a degenerate (null, agent) key.
|
// ensureSession never receives a degenerate (null, agent) key.
|
||||||
let sessionId: string;
|
|
||||||
let chatId: string;
|
|
||||||
if (task.chat_id && task.session_id) {
|
if (task.chat_id && task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
chatId = task.chat_id;
|
chatId = task.chat_id;
|
||||||
@@ -714,6 +745,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: opencode-server turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -873,6 +907,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
||||||
|
// #10: clean completion → idle; backend-reported failure → error.
|
||||||
|
emitAgentStatus(
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
agent,
|
||||||
|
result.ok ? 'idle' : 'error',
|
||||||
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
|
);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -882,6 +924,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
// #10: turn crashed.
|
||||||
|
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
@@ -982,6 +1026,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: warm-ACP turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -1123,6 +1170,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
|
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
|
||||||
|
// #10: clean completion → idle; backend-reported failure → error.
|
||||||
|
emitAgentStatus(
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
agent,
|
||||||
|
result.ok ? 'idle' : 'error',
|
||||||
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
|
);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1132,6 +1187,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
// #10: turn crashed.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
@@ -1224,6 +1281,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: claude-SDK turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -1364,6 +1424,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)');
|
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)');
|
||||||
|
// #10: clean completion → idle; backend-reported failure → error.
|
||||||
|
emitAgentStatus(
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
agent,
|
||||||
|
result.ok ? 'idle' : 'error',
|
||||||
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
|
);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1373,6 +1441,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
// #10: turn crashed.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
|
|||||||
92
apps/coder/src/services/normalize-agent-status.ts
Normal file
92
apps/coder/src/services/normalize-agent-status.ts
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
|
|||||||
'error',
|
'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([
|
const ErrorReasonValue = z.enum([
|
||||||
'llm_provider_error',
|
'llm_provider_error',
|
||||||
'doom_loop',
|
'doom_loop',
|
||||||
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
|
|||||||
commands: z.array(AgentCommandShape),
|
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 ---------------------------------------------------
|
// ---- discriminated union ---------------------------------------------------
|
||||||
|
|
||||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||||
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
|
|||||||
PermissionRequestedFrame,
|
PermissionRequestedFrame,
|
||||||
PermissionResolvedFrame,
|
PermissionResolvedFrame,
|
||||||
AgentCommandsFrame,
|
AgentCommandsFrame,
|
||||||
|
AgentStatusUpdatedFrame,
|
||||||
// per-user
|
// per-user
|
||||||
ChatStatusFrame,
|
ChatStatusFrame,
|
||||||
SessionUpdatedFrame,
|
SessionUpdatedFrame,
|
||||||
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
|||||||
'permission_requested',
|
'permission_requested',
|
||||||
'permission_resolved',
|
'permission_resolved',
|
||||||
'agent_commands',
|
'agent_commands',
|
||||||
|
'agent_status_updated',
|
||||||
'chat_status',
|
'chat_status',
|
||||||
'session_updated',
|
'session_updated',
|
||||||
'session_renamed',
|
'session_renamed',
|
||||||
|
|||||||
@@ -596,4 +596,16 @@ export type WsFrame =
|
|||||||
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
|
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
|
||||||
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
||||||
// over `error` text when present).
|
// 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',
|
'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([
|
const ErrorReasonValue = z.enum([
|
||||||
'llm_provider_error',
|
'llm_provider_error',
|
||||||
'doom_loop',
|
'doom_loop',
|
||||||
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
|
|||||||
commands: z.array(AgentCommandShape),
|
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 ---------------------------------------------------
|
// ---- discriminated union ---------------------------------------------------
|
||||||
|
|
||||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||||
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
|
|||||||
PermissionRequestedFrame,
|
PermissionRequestedFrame,
|
||||||
PermissionResolvedFrame,
|
PermissionResolvedFrame,
|
||||||
AgentCommandsFrame,
|
AgentCommandsFrame,
|
||||||
|
AgentStatusUpdatedFrame,
|
||||||
// per-user
|
// per-user
|
||||||
ChatStatusFrame,
|
ChatStatusFrame,
|
||||||
SessionUpdatedFrame,
|
SessionUpdatedFrame,
|
||||||
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
|||||||
'permission_requested',
|
'permission_requested',
|
||||||
'permission_resolved',
|
'permission_resolved',
|
||||||
'agent_commands',
|
'agent_commands',
|
||||||
|
'agent_status_updated',
|
||||||
'chat_status',
|
'chat_status',
|
||||||
'session_updated',
|
'session_updated',
|
||||||
'session_renamed',
|
'session_renamed',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'luci
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||||
|
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||||
import { providerIcon } from '@/components/coder/providerIcons';
|
import { providerIcon } from '@/components/coder/providerIcons';
|
||||||
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
||||||
import {
|
import {
|
||||||
@@ -183,6 +184,11 @@ interface Props {
|
|||||||
// True once the chat has at least one prior turn — gates the chip so it stays
|
// 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).
|
// hidden on a brand-new chat. Defaults to false (no chip).
|
||||||
hasPriorTurn?: boolean;
|
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".
|
// 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`;
|
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);
|
const allEntries = useProviderSnapshot(projectPath);
|
||||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
// 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
|
{/* 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. */}
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
<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 && (
|
{connected !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
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 { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
||||||
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
||||||
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
||||||
|
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,6 +81,14 @@ interface WsHandlers {
|
|||||||
onAssistantComplete?: () => void;
|
onAssistantComplete?: () => void;
|
||||||
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
||||||
onConnectedChange?: (connected: boolean) => 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 = {
|
type RawCoderMessage = {
|
||||||
@@ -326,6 +335,19 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
|||||||
description: c.description,
|
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 {
|
} catch {
|
||||||
// ignore unparseable frames
|
// ignore unparseable frames
|
||||||
@@ -642,6 +664,8 @@ export function CoderPane({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [agentCommands, skillItems, agentConfig.provider]);
|
}, [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, {
|
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
onPermissionRequested: (prompt) => {
|
onPermissionRequested: (prompt) => {
|
||||||
@@ -661,7 +685,21 @@ export function CoderPane({
|
|||||||
onAgentCommands: (_taskId, commands) => {
|
onAgentCommands: (_taskId, commands) => {
|
||||||
setLiveTaskCommands(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 { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||||
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -968,6 +1006,7 @@ export function CoderPane({
|
|||||||
connected={connected}
|
connected={connected}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
hasPriorTurn={hasPriorTurn}
|
hasPriorTurn={hasPriorTurn}
|
||||||
|
agentStatus={currentAgentStatus}
|
||||||
/>
|
/>
|
||||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<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.
|
// duplicating async work inside a synchronous reducer.
|
||||||
return state;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
openspec/changes/agent-status-normalize/proposal.md
Normal file
61
openspec/changes/agent-status-normalize/proposal.md
Normal file
@@ -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`
|
||||||
Reference in New Issue
Block a user