Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 172 additions & 2 deletions web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/** 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<T extends KnownEventType = KnownEventType> = EventBase & {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Priority: Code Quality

TypedEvent(T) is defined but Event = UntypedEvent means it's never the inferred type of any value — no function signature accepts or returns it, and isEventType narrows to Event & { type: T; data: EventDataMap[T] } rather than TypedEvent(T). It's currently dead-exported.

Two clean options:

  1. Remove TypedEvent(T) — the isEventType guard + intersection approach is self-contained.
  2. Make Event a proper union: export type Event = TypedEvent | UntypedEvent — this would let consumers see the full discriminated union and TypeScript could help exhaustiveness-check event.type branches.

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<string, unknown>;
}

/** 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<T extends KnownEventType>(
event: Event,
type: T,
): event is Event & { type: T; data: EventDataMap[T] } {
return event.type === type;
}

export interface SlotUtilization {
active: number;
max: number;
Expand Down
39 changes: 18 additions & 21 deletions web/src/pages/automation-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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<string, unknown>)?.text;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPORTANT] Priority: Code Quality / Type Safety

Same issue as in task-detail.tsx: the agent:message path wasn't narrowed and still falls back to (event.data as Record(string, unknown))?.text. Both files share the same parseAgentEvents logic and have the same gap — adding isEventType(event, "agent:message") before this fallthrough would close it in both places.

if (typeof raw !== "string") continue;

let msg: Record<string, unknown>;
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;
Expand All @@ -229,33 +231,28 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] {
if (msg.type === "system") continue;

if (msg.type === "result") {
const result = msg.result as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>;

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 });
}
continue;
}

if (b.type === "tool_use") {
const name = typeof b.name === "string" ? b.name : "tool";
const input = (b.input ?? {}) as Record<string, unknown>;
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;
Expand All @@ -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 =
Expand Down
Loading
Loading