diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index 1499e0b..49e0803 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -1,9 +1,11 @@ import type { AgentTicketState, AgentWorkState, + BackgroundCompletion, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, + InboxItem, ProjectWorkState, TicketWorkSource, TicketWorkStatus, @@ -24,10 +26,22 @@ function numberValue(value: unknown, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function arrayValue(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + function normalizeSource(value: unknown): TicketWorkSource { if (!isRecord(value)) return { kind: "human" }; if (value.kind === "agent") { @@ -66,6 +80,57 @@ function normalizeTicket(value: unknown, index: number): AgentTicketState { }; } +function normalizeBackgroundStatus( + value: unknown, +): BackgroundCompletion["status"] { + const raw = typeof value === "string" ? value.toLowerCase() : ""; + if (raw === "completed" || raw === "success" || raw === "succeeded") return "completed"; + if (raw === "failed" || raw === "failure") return "failed"; + if (raw === "cancelled" || raw === "canceled" || raw === "expired") return "cancelled"; + if (raw === "running" || raw === "started") return "running"; + if (raw === "delivered") return "delivered"; + return "pending"; +} + +function normalizeBackgroundTask( + value: unknown, + index: number, + fallbackAgentId: string, +): BackgroundCompletion { + const task = isRecord(value) ? value : {}; + return { + taskId: stringValue(task.taskId, `legacy-task-${index}`), + ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId), + projectId: stringValue(task.projectId), + kind: stringValue(task.kind, "background"), + status: normalizeBackgroundStatus(task.status ?? task.state), + exitCode: nullableNumber(task.exitCode), + stdoutTail: nullableString(task.stdoutTail), + stderrTail: nullableString(task.stderrTail), + finishedAtMs: nullableNumber(task.finishedAtMs), + }; +} + +function normalizeInboxItem( + value: unknown, + index: number, + fallbackAgentId: string, +): InboxItem { + const item = isRecord(value) ? value : {}; + return { + id: stringValue(item.id, `legacy-inbox-${index}`), + agentId: stringValue(item.agentId ?? item.agent_id, fallbackAgentId), + source: stringValue(item.source, "unknown"), + kind: stringValue(item.kind, "message"), + body: stringValue(item.body), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms, 0), + ...(optionalString(item.correlationId ?? item.correlation_id) + ? { correlationId: optionalString(item.correlationId ?? item.correlation_id) } + : {}), + priority: numberValue(item.priority, 0), + }; +} + function normalizeAgent(value: unknown, index: number): AgentWorkState { const agent = isRecord(value) ? value : {}; const liveKind: "pty" | "structured" = @@ -78,13 +143,26 @@ function normalizeAgent(value: unknown, index: number): AgentWorkState { } : undefined; + const agentId = stringValue(agent.agentId, `legacy-agent-${index}`); + const inbox = arrayValue(agent.inbox).map((item, itemIndex) => + normalizeInboxItem(item, itemIndex, agentId), + ); + const backgroundTasks = arrayValue( + agent.backgroundTasks ?? agent.backgroundCompletions, + ).map((task, taskIndex) => + normalizeBackgroundTask(task, taskIndex, agentId), + ); + return { - agentId: stringValue(agent.agentId, `legacy-agent-${index}`), + agentId, name: stringValue(agent.name, "Unnamed agent"), profileId: stringValue(agent.profileId, "unknown"), ...(live ? { live } : {}), busy: normalizeBusy(agent.busy), tickets: arrayValue(agent.tickets).map(normalizeTicket), + inboxDepth: numberValue(agent.inboxDepth, inbox.length), + inbox, + backgroundTasks, }; } @@ -116,10 +194,6 @@ function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPrevi }; } -function nullableString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - function normalizeConversation( value: unknown, index: number, @@ -138,8 +212,33 @@ function normalizeConversation( export function normalizeProjectWorkState(value: unknown): ProjectWorkState { const state = isRecord(value) ? value : {}; + const agents = arrayValue(state.agents).map(normalizeAgent); + const topLevelTasks = arrayValue( + state.backgroundTasks ?? state.backgroundCompletions, + ).map((task, index) => normalizeBackgroundTask(task, index, "")); + for (const task of topLevelTasks) { + const owner = agents.find((agent) => agent.agentId === task.ownerAgentId); + if (!owner) continue; + owner.backgroundTasks ??= []; + if (!owner.backgroundTasks.some((item) => item.taskId === task.taskId)) { + owner.backgroundTasks.push(task); + } + } + const topLevelInbox = arrayValue(state.inboxItems).map((item, index) => + normalizeInboxItem(item, index, ""), + ); + for (const item of topLevelInbox) { + const owner = agents.find((agent) => agent.agentId === item.agentId); + if (!owner) continue; + owner.inbox ??= []; + owner.inboxDepth ??= 0; + if (!owner.inbox.some((entry) => entry.id === item.id)) { + owner.inbox.push(item); + owner.inboxDepth = Math.max(owner.inboxDepth, owner.inbox.length); + } + } return { - agents: arrayValue(state.agents).map(normalizeAgent), + agents, conversations: arrayValue(state.conversations).map(normalizeConversation), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index ae67206..5b8c260 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -20,6 +20,26 @@ export type DomainEvent = | { type: "agentExited"; agentId: string; code: number } | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentBusyChanged"; agentId: string; busy: boolean } + | { + type: "backgroundTaskChanged"; + projectId: string; + taskId: string; + agentId: string; + state: string; + } + | { + type: "agentInboxChanged"; + agentId: string; + depth: number; + action: string; + } + | { + type: "agentWakeChanged"; + projectId: string; + agentId: string; + action: string; + reason?: string; + } | { /** * A delegation is ready to be injected into the agent's native terminal @@ -170,6 +190,31 @@ export interface AgentTicketState { taskLen: number; } +/** One first-class background task visible in the work-state read-model. */ +export interface BackgroundCompletion { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered"; + exitCode: number | null; + stdoutTail: string | null; + stderrTail: string | null; + finishedAtMs: number | null; +} + +/** One item currently pending in an agent's inbox. */ +export interface InboxItem { + id: string; + agentId: string; + source: string; + kind: string; + body: string; + createdAtMs: number; + correlationId?: string; + priority: number; +} + /** Availability of a compact conversation summary in the work-state read-model. */ export type ConversationPreviewStatus = | "ready" @@ -205,6 +250,9 @@ export interface AgentWorkState { live?: LiveWorkSession; busy: WorkBusyState; tickets: AgentTicketState[]; + inboxDepth?: number; + inbox?: InboxItem[]; + backgroundTasks?: BackgroundCompletion[]; } /** Minimal read-only live-state surface for a project. */ diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index a586320..2637afa 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -317,6 +317,16 @@ function LeafView({ ? workState?.agents.find((row) => row.agentId === agentId) : undefined; const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress"); + const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? []; + const inboxDepth = Math.max( + agentWork?.inboxDepth ?? 0, + agentWork?.inbox?.length ?? 0, + queuedTickets.length, + ); + const completedBackgroundTasks = + agentWork?.backgroundTasks?.filter((task) => + task.status === "completed" || task.status === "failed" || task.status === "cancelled", + ) ?? []; const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const historyConversationId = @@ -661,7 +671,7 @@ function LeafView({ )} - {agentId && (busyByWorkState || historyConversationId) && ( + {agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
+ {shortTicket(item.id)}
+
+
+ {shortTicket(task.taskId)}
+
+
+ {task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
+ {task.stdoutTail && task.stderrTail ? "\n\n" : ""}
+ {task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
+
+ )}
+
)}
+ {inbox.length > 0 && (
+
+
+ Inbox
+
+
+ {inbox.map((item) => (
+
+ ))}
+
+
+ )}
+ {backgroundTasks.length > 0 && (
+
+
+ Background tasks
+
+
+ {backgroundTasks.map((task) => (
+
+ ))}
+
+
+ )}
);
}
diff --git a/frontend/src/features/workstate/useProjectWorkState.ts b/frontend/src/features/workstate/useProjectWorkState.ts
index 4c0ffd9..018d4fc 100644
--- a/frontend/src/features/workstate/useProjectWorkState.ts
+++ b/frontend/src/features/workstate/useProjectWorkState.ts
@@ -56,7 +56,10 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
event.type === "agentExited" ||
event.type === "agentBusyChanged" ||
event.type === "delegationReady" ||
- event.type === "orchestratorRequestProcessed"
+ event.type === "orchestratorRequestProcessed" ||
+ event.type === "backgroundTaskChanged" ||
+ event.type === "agentInboxChanged" ||
+ event.type === "agentWakeChanged"
) {
void refresh();
}