From 3e24a46c5d7d957fa6b518721f96282ac8933e39 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 4 Jun 2026 11:09:14 -0700 Subject: [PATCH] feat: v2 - tangent - progress update poll --- .../tangent/hooks/useResearchProgress.ts | 51 ++++ .../tangent/services/autoresearchOpencode.ts | 243 ++++++++++++++++++ src/routes/tangent/types.ts | 2 + .../MlExperimentPlannerContent.tsx | 44 +++- .../components/ResearchProgressView.tsx | 132 ++++++++++ 5 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 src/routes/tangent/hooks/useResearchProgress.ts create mode 100644 src/routes/v2/shared/components/MlExperimentPlanner/components/ResearchProgressView.tsx diff --git a/src/routes/tangent/hooks/useResearchProgress.ts b/src/routes/tangent/hooks/useResearchProgress.ts new file mode 100644 index 000000000..de95daa42 --- /dev/null +++ b/src/routes/tangent/hooks/useResearchProgress.ts @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + deriveResearchProgress, + fetchSessionMessages, + fetchSessionStatus, + fetchSessionTodos, + isResearchPhaseTerminal, + type ResearchProgress, +} from "@/routes/tangent/services/autoresearchOpencode"; +import { TangentQueryKeys } from "@/routes/tangent/types"; + +const POLL_INTERVAL_MS = 4000; + +interface ResearchRef { + instanceId: string; + sessionId: string; +} + +/** + * Polls the OpenCode session backing a scenario's automated research and + * derives a progress snapshot. Polling stops once the agent reaches a terminal + * phase (completed or failed); the query is disabled when no research exists. + */ +export function useResearchProgress(research?: ResearchRef) { + return useQuery({ + queryKey: TangentQueryKeys.Research( + research?.instanceId ?? "", + research?.sessionId ?? "", + ), + enabled: Boolean(research), + refetchOnWindowFocus: true, + refetchInterval: (query) => { + const phase = query.state.data?.phase; + if (phase && isResearchPhaseTerminal(phase)) return false; + return POLL_INTERVAL_MS; + }, + queryFn: async () => { + if (!research) { + throw new Error("useResearchProgress queried without a research ref"); + } + const { instanceId, sessionId } = research; + const [statusType, messages, todos] = await Promise.all([ + fetchSessionStatus(instanceId, sessionId), + fetchSessionMessages(instanceId, sessionId), + fetchSessionTodos(instanceId, sessionId), + ]); + return deriveResearchProgress(statusType, messages, todos); + }, + }); +} diff --git a/src/routes/tangent/services/autoresearchOpencode.ts b/src/routes/tangent/services/autoresearchOpencode.ts index f521f0668..a36f7ed69 100644 --- a/src/routes/tangent/services/autoresearchOpencode.ts +++ b/src/routes/tangent/services/autoresearchOpencode.ts @@ -50,6 +50,30 @@ async function opencodeProxyPost( return data; } +/** + * GET through the backend OpenCode reverse proxy. Mirrors {@link opencodeProxyPost} + * but for reads; calls the hey-api client directly so the `{path}` slashes are + * preserved and the workspace `directory` query is forwarded. + */ +async function opencodeProxyGet( + instanceId: string, + path: string, + query?: Record, +): Promise { + const { data, error } = await client.get({ + url: `/api/tangent/instances/${instanceId}/opencode/api/${path}`, + query, + }); + + if (error) { + throw new Error( + `OpenCode request failed (${path}): ${JSON.stringify(error)}`, + ); + } + + return data; +} + /** * Resolve a Tangent OpenCode instance to talk to, mirroring the backend * `/api/tangent/go` logic: reuse the earliest existing instance, otherwise @@ -124,3 +148,222 @@ export async function sendAutoresearchMessage( body: { parts: [{ type: "text", text }] }, }); } + +// region Progress polling + +/** Live execution state of an OpenCode session (see `session/status.ts`). */ +export type SessionStatusType = "idle" | "busy" | "retry"; + +export type ResearchTodoStatus = + | "pending" + | "in_progress" + | "completed" + | "cancelled"; + +export interface ResearchTodo { + id: string; + content: string; + status: ResearchTodoStatus; +} + +export type ResearchPhase = + | "starting" + | "working" + | "retrying" + | "completed" + | "failed"; + +export interface ResearchProgress { + phase: ResearchPhase; + statusType: SessionStatusType; + /** Trimmed snippet of the latest assistant text part. */ + latestText?: string; + /** Title (or name) of a tool the agent is currently running. */ + currentTool?: string; + todos: ResearchTodo[]; + messageCount: number; + /** Latest assistant message timestamp (completed or created), if any. */ + lastUpdated?: number; + error?: string; +} + +function isSessionStatusType(value: unknown): value is SessionStatusType { + return value === "idle" || value === "busy" || value === "retry"; +} + +function isResearchTodoStatus(value: unknown): value is ResearchTodoStatus { + return ( + value === "pending" || + value === "in_progress" || + value === "completed" || + value === "cancelled" + ); +} + +/** + * Read the execution status of a single session. OpenCode prunes idle sessions + * from the `/session/status` map, so a missing entry is treated as idle. + */ +export async function fetchSessionStatus( + instanceId: string, + sessionId: string, +): Promise { + const data = await opencodeProxyGet(instanceId, "session/status", { + directory: OPENCODE_WORKSPACE_DIR, + }); + + if (!isRecord(data)) return "idle"; + const entry = data[sessionId]; + if (!isRecord(entry) || !isSessionStatusType(entry.type)) return "idle"; + return entry.type; +} + +/** + * Read the most recent messages (with parts) for a session, newest last. The + * `limit` keeps the payload small since we only need the latest assistant turn. + */ +export async function fetchSessionMessages( + instanceId: string, + sessionId: string, + limit = 2, +): Promise { + return opencodeProxyGet(instanceId, `session/${sessionId}/message`, { + directory: OPENCODE_WORKSPACE_DIR, + limit: String(limit), + }); +} + +/** Read the agent's todo checklist for a session. */ +export async function fetchSessionTodos( + instanceId: string, + sessionId: string, +): Promise { + const data = await opencodeProxyGet(instanceId, `session/${sessionId}/todo`, { + directory: OPENCODE_WORKSPACE_DIR, + }); + + if (!Array.isArray(data)) return []; + + const todos: ResearchTodo[] = []; + for (const item of data) { + if (!isRecord(item)) continue; + const { id, content, status } = item; + if (typeof id !== "string") continue; + if (typeof content !== "string") continue; + if (!isResearchTodoStatus(status)) continue; + todos.push({ id, content, status }); + } + return todos; +} + +interface ParsedAssistantMessage { + completed: boolean; + error?: string; + latestText?: string; + runningTool?: string; + updatedAt?: number; +} + +function extractErrorMessage(error: unknown): string | undefined { + if (!isRecord(error)) return undefined; + if (typeof error.message === "string") return error.message; + if (isRecord(error.data) && typeof error.data.message === "string") { + return error.data.message; + } + if (typeof error.name === "string") return error.name; + return "Unknown error"; +} + +/** Pull the narration / running tool / completion state from message parts. */ +function parseAssistantMessage(message: unknown): ParsedAssistantMessage | null { + if (!isRecord(message)) return null; + const { info, parts } = message; + if (!isRecord(info) || info.role !== "assistant") return null; + + const time = isRecord(info.time) ? info.time : undefined; + const completedAt = + time && typeof time.completed === "number" ? time.completed : undefined; + const createdAt = + time && typeof time.created === "number" ? time.created : undefined; + + let latestText: string | undefined; + let runningTool: string | undefined; + if (Array.isArray(parts)) { + for (const part of parts) { + if (!isRecord(part)) continue; + if (part.type === "text" && typeof part.text === "string") { + const text = part.text.trim(); + if (text.length > 0) latestText = text; + continue; + } + if (part.type === "tool" && isRecord(part.state)) { + if (part.state.status === "running") { + const title = + typeof part.state.title === "string" ? part.state.title : undefined; + const tool = typeof part.tool === "string" ? part.tool : undefined; + runningTool = title ?? tool; + } + } + } + } + + return { + completed: completedAt !== undefined, + error: extractErrorMessage(info.error), + latestText, + runningTool, + updatedAt: completedAt ?? createdAt, + }; +} + +function findLatestAssistantMessage( + messages: unknown, +): ParsedAssistantMessage | null { + if (!Array.isArray(messages)) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const parsed = parseAssistantMessage(messages[i]); + if (parsed) return parsed; + } + return null; +} + +function derivePhase( + statusType: SessionStatusType, + assistant: ParsedAssistantMessage | null, +): ResearchPhase { + if (statusType === "busy") return "working"; + if (statusType === "retry") return "retrying"; + if (assistant?.error) return "failed"; + if (assistant?.completed) return "completed"; + return "starting"; +} + +/** + * Combine session status, the latest messages, and todos into a single + * progress snapshot for the UI. Pure and tolerant of unknown shapes. + */ +export function deriveResearchProgress( + statusType: SessionStatusType, + messages: unknown, + todos: ResearchTodo[], +): ResearchProgress { + const assistant = findLatestAssistantMessage(messages); + const messageCount = Array.isArray(messages) ? messages.length : 0; + + return { + phase: derivePhase(statusType, assistant), + statusType, + latestText: assistant?.latestText, + currentTool: assistant?.runningTool, + todos, + messageCount, + lastUpdated: assistant?.updatedAt, + error: assistant?.error, + }; +} + +export function isResearchPhaseTerminal(phase: ResearchPhase): boolean { + return phase === "completed" || phase === "failed"; +} + +// endregion diff --git a/src/routes/tangent/types.ts b/src/routes/tangent/types.ts index 596e763bb..65e840390 100644 --- a/src/routes/tangent/types.ts +++ b/src/routes/tangent/types.ts @@ -113,4 +113,6 @@ export const TangentQueryKeys = { Stats: () => ["tangent", "stats"] as const, Pipelines: () => ["tangent", "pipelines"] as const, Pipeline: (runId: string) => ["tangent", "pipeline", runId] as const, + Research: (instanceId: string, sessionId: string) => + ["tangent", "research", instanceId, sessionId] as const, } as const; diff --git a/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx b/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx index 5a1014580..e065fbb60 100644 --- a/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx +++ b/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx @@ -23,6 +23,7 @@ import { TableRow, } from "@/components/ui/table"; import { Paragraph, Text } from "@/components/ui/typography"; +import { useResearchProgress } from "@/routes/tangent/hooks/useResearchProgress"; import { useRunAutomatedResearch } from "@/routes/tangent/hooks/useRunAutomatedResearch"; import { useRunScenarios } from "@/routes/tangent/hooks/useRunScenarios"; import type { @@ -31,6 +32,7 @@ import type { ScenarioIdeaType, ScenarioImpact, } from "@/routes/tangent/idb/tangentDb"; +import { ResearchProgressView } from "@/routes/v2/shared/components/MlExperimentPlanner/components/ResearchProgressView"; const IDEA_TYPE_LABEL: Record = { feature_engineering: "Feature engineering", @@ -101,6 +103,9 @@ function ScenarioRow({ onRunResearch, isResearchPending, }: ScenarioRowProps) { + const { data: progress, isLoading: isProgressLoading, isError } = + useResearchProgress(scenario.research); + return ( @@ -125,14 +130,21 @@ function ScenarioRow({ {scenario.research ? ( - event.stopPropagation()} - > - Open research session - + + + event.stopPropagation()} + > + Open research session + + ) : (