Skip to content
Draft
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
51 changes: 51 additions & 0 deletions src/routes/tangent/hooks/useResearchProgress.ts
Original file line number Diff line number Diff line change
@@ -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<ResearchProgress>({
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);
},
});
}
243 changes: 243 additions & 0 deletions src/routes/tangent/services/autoresearchOpencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
): Promise<unknown> {
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
Expand Down Expand Up @@ -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<SessionStatusType> {
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<unknown> {
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<ResearchTodo[]> {
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
2 changes: 2 additions & 0 deletions src/routes/tangent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<ScenarioIdeaType, string> = {
feature_engineering: "Feature engineering",
Expand Down Expand Up @@ -101,6 +103,9 @@ function ScenarioRow({
onRunResearch,
isResearchPending,
}: ScenarioRowProps) {
const { data: progress, isLoading: isProgressLoading, isError } =
useResearchProgress(scenario.research);

return (
<TableRow className="cursor-pointer" onClick={onSelect}>
<TableCell>
Expand All @@ -125,14 +130,21 @@ function ScenarioRow({
</TableCell>
<TableCell>
{scenario.research ? (
<Link
external
href={scenario.research.url}
size="sm"
onClick={(event) => event.stopPropagation()}
>
Open research session
</Link>
<BlockStack gap="2" inlineAlign="start">
<ResearchProgressView
progress={progress}
isLoading={isProgressLoading}
isError={isError}
/>
<Link
external
href={scenario.research.url}
size="sm"
onClick={(event) => event.stopPropagation()}
>
Open research session
</Link>
</BlockStack>
) : (
<Button
size="xs"
Expand Down Expand Up @@ -164,6 +176,9 @@ function ScenarioDetail({
onRunResearch,
isResearchPending,
}: ScenarioDetailProps) {
const { data: progress, isLoading: isProgressLoading, isError } =
useResearchProgress(scenario.research);

return (
<BlockStack gap="3" fill className="p-3" inlineAlign="start">
<Breadcrumb>
Expand Down Expand Up @@ -193,9 +208,16 @@ function ScenarioDetail({
</Text>
</InlineStack>
{scenario.research ? (
<Link external href={scenario.research.url} size="sm">
Open research session
</Link>
<BlockStack gap="2" inlineAlign="start">
<ResearchProgressView
progress={progress}
isLoading={isProgressLoading}
isError={isError}
/>
<Link external href={scenario.research.url} size="sm">
Open research session
</Link>
</BlockStack>
) : (
<Button
size="sm"
Expand Down
Loading
Loading