From 15c5cf5309d58828ca437971debe47500977502a Mon Sep 17 00:00:00 2001 From: Tasks Agent Date: Tue, 31 Mar 2026 18:26:45 +0000 Subject: [PATCH] Add typed event payloads to replace Record casts Define typed interfaces for all known event data payloads (orchestrator, agent, human, automation) and an isEventType() type guard that narrows Event.data to the correct shape. Updates task-detail, orchestrator page, and automation-detail to use typed access instead of manual field extraction with `as` casts. Closes #521 Co-Authored-By: Claude Opus 4.6 --- web/src/lib/types.ts | 174 +++++++++++++++++++++++++++- web/src/pages/automation-detail.tsx | 39 +++---- web/src/pages/orchestrator/page.tsx | 60 +++++----- web/src/pages/task-detail.tsx | 51 ++++---- 4 files changed, 242 insertions(+), 82 deletions(-) diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e9cf4893..6e941c3a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -82,15 +82,185 @@ export interface MergeQueueEntry { export type Actor = "human" | "orchestrator" | "scheduler" | "agent" | "system"; -export interface Event { +// --------------------------------------------------------------------------- +// Typed event payloads +// --------------------------------------------------------------------------- + +/** orchestrator:decision */ +export interface OrchestratorDecisionData { + entry_id?: string; + approved: boolean; + reasoning?: string; + task_id?: string; +} + +/** orchestrator:feedback */ +export interface OrchestratorFeedbackData { + feedback?: string; + context?: string; + task_id?: string; + // Conflict-resolution variant + action?: string; + entry_id?: string; + pr_url?: string; + success?: boolean; +} + +/** orchestrator:escalation */ +export interface OrchestratorEscalationData { + action?: string; + reasoning?: string; + reason?: string; + message?: string; + entry_id?: string; + pr_url?: string; + from?: string; + to?: string; + details?: Record; +} + +/** orchestrator:message / orchestrator:response / orchestrator:thought */ +export interface OrchestratorMessageData { + message?: string; + error?: boolean; +} + +/** human:message */ +export interface HumanMessageData { + message?: string; + source?: string; +} + +/** agent:message */ +export interface AgentMessageData { + text?: string; + stream?: string; + completion_hint?: boolean; + source?: string; +} + +/** agent:error */ +export interface AgentErrorData { + text?: string; + source?: string; +} + +/** agent:question */ +export interface AgentQuestionData { + question?: string; + message?: string; + text?: string; + source?: string; +} + +/** automation:run:output */ +export interface AutomationRunOutputData { + automation_id?: string; + chunk?: string; +} + +/** Content block inside a parsed agent message (JSON in agent:message text) */ +export interface AgentContentBlockText { + type: "text"; + text: string; +} + +export interface AgentToolInput { + file_path?: string; + filePath?: string; + path?: string; + pattern?: string; + command?: string; + description?: string; + [key: string]: unknown; +} + +export interface AgentContentBlockToolUse { + type: "tool_use"; + name?: string; + input?: AgentToolInput; +} + +export interface AgentContentBlockToolResult { + type: "tool_result"; + content?: string; +} + +export interface AgentContentBlockThinking { + type: "thinking"; +} + +export type AgentContentBlock = + | AgentContentBlockText + | AgentContentBlockToolUse + | AgentContentBlockToolResult + | AgentContentBlockThinking; + +/** Parsed JSON message from agent stdout (the text field parsed as JSON) */ +export interface AgentParsedMessage { + type?: string; + result?: { text?: string }; + message?: { content?: AgentContentBlock[] }; + content?: AgentContentBlock[]; +} + +// --------------------------------------------------------------------------- +// Event type map — maps event.type string to its data shape +// --------------------------------------------------------------------------- + +export interface EventDataMap { + "orchestrator:decision": OrchestratorDecisionData; + "orchestrator:feedback": OrchestratorFeedbackData; + "orchestrator:escalation": OrchestratorEscalationData; + "orchestrator:message": OrchestratorMessageData; + "orchestrator:response": OrchestratorMessageData; + "orchestrator:thought": OrchestratorMessageData; + "human:message": HumanMessageData; + "agent:message": AgentMessageData; + "agent:error": AgentErrorData; + "agent:question": AgentQuestionData; + "automation:run:output": AutomationRunOutputData; +} + +export type KnownEventType = keyof EventDataMap; + +// --------------------------------------------------------------------------- +// Event — discriminated by `type` +// --------------------------------------------------------------------------- + +interface EventBase { id: string; - type: string; task: string; actor: Actor; ts: string; +} + +/** A typed event whose `type` is one of the known event types. */ +export type TypedEvent = EventBase & { + type: T; + data: EventDataMap[T]; +}; + +/** An event with an unrecognized type (e.g. task:state:*, future additions). */ +export interface UntypedEvent extends EventBase { + type: string; data: Record; } +/** Union of all events. Consumer code can narrow via `isEventType()`. */ +export type Event = UntypedEvent; + +/** + * Type guard to narrow an Event to a specific known type with typed data. + * Returns the event with its `data` field typed according to the EventDataMap. + */ +export function isEventType( + event: Event, + type: T, +): event is Event & { type: T; data: EventDataMap[T] } { + return event.type === type; +} + export interface SlotUtilization { active: number; max: number; diff --git a/web/src/pages/automation-detail.tsx b/web/src/pages/automation-detail.tsx index 705cc3ad..b1a2149e 100644 --- a/web/src/pages/automation-detail.tsx +++ b/web/src/pages/automation-detail.tsx @@ -39,7 +39,8 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import type { Automation, AutomationRun, AutomationState, Event } from "@/lib/types"; +import type { Automation, AutomationRun, AutomationState, Event, AgentContentBlock, AgentParsedMessage, AgentToolInput } from "@/lib/types"; +import { isEventType } from "@/lib/types"; // --------------------------------------------------------------------------- // State badge configuration @@ -189,9 +190,9 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { for (const event of events) { // Handle automation lifecycle events if (event.type.startsWith("automation:run:")) { - if (event.type === "automation:run:output") { + if (isEventType(event, "automation:run:output")) { // Streaming output chunk - const chunk = event.data?.chunk as string | undefined; + const chunk = event.data.chunk; if (chunk) { blocks.push({ kind: "output_chunk", content: chunk, timestamp: event.ts }); } @@ -205,22 +206,23 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } // Handle agent errors - if (event.type === "agent:error") { + if (isEventType(event, "agent:error")) { + const d = event.data; blocks.push({ kind: "error", - content: typeof event.data?.text === "string" ? event.data.text : JSON.stringify(event.data), + content: d.text ?? JSON.stringify(event.data), timestamp: event.ts, }); continue; } // Handle agent messages (same parsing as task-detail) - const raw = event.data?.text; + const raw = (event.data as Record)?.text; if (typeof raw !== "string") continue; - let msg: Record; + let msg: AgentParsedMessage; try { - msg = JSON.parse(raw); + msg = JSON.parse(raw) as AgentParsedMessage; } catch { if (raw.trim()) blocks.push({ kind: "text", content: raw }); continue; @@ -229,24 +231,19 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { if (msg.type === "system") continue; if (msg.type === "result") { - const result = msg.result as Record | undefined; - if (typeof result?.text === "string" && result.text) { - blocks.push({ kind: "text", content: result.text }); + if (msg.result?.text) { + blocks.push({ kind: "text", content: msg.result.text }); } continue; } - const message = msg.message as Record | undefined; - const contentBlocks = (message?.content ?? msg.content) as unknown[] | undefined; + const contentBlocks: AgentContentBlock[] | undefined = msg.message?.content ?? msg.content; if (!Array.isArray(contentBlocks)) continue; - for (const block of contentBlocks) { - if (typeof block !== "object" || block === null) continue; - const b = block as Record; - + for (const b of contentBlocks) { if (b.type === "thinking") continue; - if (b.type === "text" && typeof b.text === "string") { + if (b.type === "text") { if (b.text.trim()) { blocks.push({ kind: "text", content: b.text, timestamp: event.ts }); } @@ -254,8 +251,8 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_use") { - const name = typeof b.name === "string" ? b.name : "tool"; - const input = (b.input ?? {}) as Record; + const name = b.name ?? "tool"; + const input: AgentToolInput = b.input ?? {}; const filePath = input.file_path ?? input.filePath ?? input.path ?? input.pattern; const command = input.command; const description = input.description; @@ -271,7 +268,7 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_result") { - const content = typeof b.content === "string" ? b.content : ""; + const content = b.content ?? ""; if (!content) continue; const lines = content.split("\n"); const preview = diff --git a/web/src/pages/orchestrator/page.tsx b/web/src/pages/orchestrator/page.tsx index 17fbd71c..0aef94af 100644 --- a/web/src/pages/orchestrator/page.tsx +++ b/web/src/pages/orchestrator/page.tsx @@ -17,6 +17,7 @@ import { cn, formatRelativeTime, projectLabel } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { Event, Task, Project } from "@/lib/types"; +import { isEventType } from "@/lib/types"; // --------------------------------------------------------------------------- // Task helpers: resolve task ID → title, number, repo info @@ -90,80 +91,75 @@ function parseOrchestratorEvents( ): OrchestratorBlock[] { const blocks: OrchestratorBlock[] = []; for (const event of events) { - if (event.type === "orchestrator:decision") { - const taskId = typeof event.data?.task_id === "string" ? event.data.task_id : event.task; + if (isEventType(event, "orchestrator:decision")) { + const d = event.data; + const taskId = d.task_id ?? event.task; blocks.push({ kind: "decision", id: event.id, timestamp: event.ts, - approved: event.data?.approved === true, - reasoning: typeof event.data?.reasoning === "string" ? event.data.reasoning : undefined, + approved: d.approved === true, + reasoning: d.reasoning, taskId, taskInfo: getTaskInfo(taskId, tasks, projects) ?? undefined, - entryId: typeof event.data?.entry_id === "string" ? event.data.entry_id : undefined, + entryId: d.entry_id, }); - } else if (event.type === "orchestrator:feedback") { - const taskId = typeof event.data?.task_id === "string" ? event.data.task_id : event.task; - const context = typeof event.data?.context === "string" ? event.data.context : undefined; + } else if (isEventType(event, "orchestrator:feedback")) { + const d = event.data; + const taskId = d.task_id ?? event.task; // Distinguish question answers from regular feedback blocks.push({ - kind: context === "question_answer" ? "question_answer" : "feedback", + kind: d.context === "question_answer" ? "question_answer" : "feedback", id: event.id, timestamp: event.ts, - feedback: typeof event.data?.feedback === "string" ? event.data.feedback : undefined, + feedback: d.feedback, taskId, taskInfo: getTaskInfo(taskId, tasks, projects) ?? undefined, - content: context, + content: d.context, }); - } else if (event.type === "orchestrator:escalation") { - const action = typeof event.data?.action === "string" ? event.data.action : undefined; + } else if (isEventType(event, "orchestrator:escalation")) { + const d = event.data; // Extract reasoning - backend sends "reasoning" for conflicts, "reason" for mode changes - const reasoning = - typeof event.data?.reasoning === "string" ? event.data.reasoning : - typeof event.data?.reason === "string" ? event.data.reason : - undefined; + const reasoning = d.reasoning ?? d.reason; const taskId = event.task; - // For agent_question escalations, the message field contains the question - const message = typeof event.data?.message === "string" ? event.data.message : undefined; - blocks.push({ kind: "escalation", id: event.id, timestamp: event.ts, - content: action === "agent_question" ? message : reasoning, + content: d.action === "agent_question" ? d.message : reasoning, taskId, taskInfo: getTaskInfo(taskId, tasks, projects) ?? undefined, - entryId: typeof event.data?.entry_id === "string" ? event.data.entry_id : undefined, - escalationAction: action, - prUrl: typeof event.data?.pr_url === "string" ? event.data.pr_url : undefined, - fromMode: typeof event.data?.from === "string" ? event.data.from : undefined, - toMode: typeof event.data?.to === "string" ? event.data.to : undefined, + entryId: d.entry_id, + escalationAction: d.action, + prUrl: d.pr_url, + fromMode: d.from, + toMode: d.to, }); - } else if (event.type === "orchestrator:message") { + } else if (isEventType(event, "orchestrator:message")) { // Human message to orchestrator blocks.push({ kind: "message", id: event.id, timestamp: event.ts, - content: typeof event.data?.message === "string" ? event.data.message : undefined, + content: event.data.message, actor: "human", }); - } else if (event.type === "orchestrator:response") { + } else if (isEventType(event, "orchestrator:response")) { // Orchestrator response to human blocks.push({ kind: "message", id: event.id, timestamp: event.ts, - content: typeof event.data?.message === "string" ? event.data.message : undefined, + content: event.data.message, actor: "orchestrator", }); - } else if (event.type === "orchestrator:thought") { + } else if (isEventType(event, "orchestrator:thought")) { // Stream-of-consciousness narration from the orchestrator blocks.push({ kind: "thought", id: event.id, timestamp: event.ts, - content: typeof event.data?.message === "string" ? event.data.message : undefined, + content: event.data.message, }); } } diff --git a/web/src/pages/task-detail.tsx b/web/src/pages/task-detail.tsx index ce7bc4a6..e0ea86fb 100644 --- a/web/src/pages/task-detail.tsx +++ b/web/src/pages/task-detail.tsx @@ -43,7 +43,8 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { Event, FailureInfo, Task, TaskState } from "@/lib/types"; +import type { Event, FailureInfo, Task, TaskState, AgentContentBlock, AgentParsedMessage, AgentToolInput } from "@/lib/types"; +import { isEventType } from "@/lib/types"; import { taskStateMeta } from "./tasks/columns"; // --------------------------------------------------------------------------- @@ -149,43 +150,44 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { continue; } - if (event.type === "agent:question") { - const question = (event.data?.question ?? event.data?.message ?? event.data?.text) as string | undefined; + if (isEventType(event, "agent:question")) { + const d = event.data; + const question = d.question ?? d.message ?? d.text; if (question) { blocks.push({ kind: "agent_question", content: question, timestamp: event.ts }); } continue; } - if (event.type === "human:message") { - const message = event.data?.message as string | undefined; - const source = event.data?.source as string | undefined; - if (message) { + if (isEventType(event, "human:message")) { + const d = event.data; + if (d.message) { // Orchestrator answers come as human:message with source "orchestrator_answer" blocks.push({ - kind: source === "orchestrator_answer" ? "orchestrator_answer" : "human_message", - content: message, + kind: d.source === "orchestrator_answer" ? "orchestrator_answer" : "human_message", + content: d.message, timestamp: event.ts, }); } continue; } - if (event.type === "agent:error") { + if (isEventType(event, "agent:error")) { + const d = event.data; blocks.push({ kind: "error", - content: typeof event.data?.text === "string" ? event.data.text : JSON.stringify(event.data), + content: d.text ?? JSON.stringify(event.data), timestamp: event.ts, }); continue; } - const raw = event.data?.text; + const raw = (event.data as Record)?.text; if (typeof raw !== "string") continue; - let msg: Record; + let msg: AgentParsedMessage; try { - msg = JSON.parse(raw); + msg = JSON.parse(raw) as AgentParsedMessage; } catch { if (raw.trim()) blocks.push({ kind: "text", content: raw }); continue; @@ -194,24 +196,19 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { if (msg.type === "system") continue; if (msg.type === "result") { - const result = msg.result as Record | undefined; - if (typeof result?.text === "string" && result.text) { - blocks.push({ kind: "text", content: result.text }); + if (msg.result?.text) { + blocks.push({ kind: "text", content: msg.result.text }); } continue; } - const message = msg.message as Record | undefined; - const contentBlocks = (message?.content ?? msg.content) as unknown[] | undefined; + const contentBlocks: AgentContentBlock[] | undefined = msg.message?.content ?? msg.content; if (!Array.isArray(contentBlocks)) continue; - for (const block of contentBlocks) { - if (typeof block !== "object" || block === null) continue; - const b = block as Record; - + for (const b of contentBlocks) { if (b.type === "thinking") continue; - if (b.type === "text" && typeof b.text === "string") { + if (b.type === "text") { if (b.text.trim()) { blocks.push({ kind: "text", content: b.text, timestamp: event.ts }); } @@ -219,8 +216,8 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_use") { - const name = typeof b.name === "string" ? b.name : "tool"; - const input = (b.input ?? {}) as Record; + const name = b.name ?? "tool"; + const input: AgentToolInput = b.input ?? {}; const filePath = input.file_path ?? input.filePath ?? input.path ?? input.pattern; const command = input.command; const description = input.description; @@ -236,7 +233,7 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_result") { - const content = typeof b.content === "string" ? b.content : ""; + const content = b.content ?? ""; if (!content) continue; const lines = content.split("\n"); const preview =