Merge branch 'main' into timothyjlaurent/Anthropic-error

This commit is contained in:
gsxdsm
2026-05-09 10:42:33 -07:00
committed by GitHub
690 changed files with 59296 additions and 22734 deletions

View File

@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
addChatRoomMember,
createChatRoom,
deleteChatRoom,
deleteChatRoomMessage,
fetchChatRoom,
fetchChatRoomMembers,
fetchChatRoomMessages,
fetchChatRooms,
postChatRoomMessage,
removeChatRoomMember,
updateChatRoom,
} from "../legacy";
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
describe("chat room legacy API client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("builds request for fetchChatRooms", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ rooms: [] }));
await fetchChatRooms({ status: "active", agentId: "agent-1" }, "proj-1");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/api/chat/rooms?");
expect(url).toContain("projectId=proj-1");
expect(url).toContain("status=active");
expect(url).toContain("agentId=agent-1");
expect(init.method).toBeUndefined();
});
it("builds CRUD room endpoints", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
await fetchChatRoom("room-1", "proj-1");
await createChatRoom({ name: "Engineering" }, "proj-1");
await updateChatRoom("room-1", { description: "desc" }, "proj-1");
await deleteChatRoom("room-1", "proj-1");
expect((fetchMock.mock.calls[0] as [string])[0]).toContain("/api/chat/rooms/room-1?projectId=proj-1");
const [, createInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
expect(createInit.body).toBe(JSON.stringify({ name: "Engineering", projectId: "proj-1" }));
const [, updateInit] = fetchMock.mock.calls[2] as [string, RequestInit];
expect(updateInit.method).toBe("PATCH");
expect(updateInit.body).toBe(JSON.stringify({ description: "desc" }));
const [, deleteInit] = fetchMock.mock.calls[3] as [string, RequestInit];
expect(deleteInit.method).toBe("DELETE");
});
it("builds member endpoints", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
await fetchChatRoomMembers("room-1", "proj-2");
await addChatRoomMember("room-1", { agentId: "agent-2", role: "owner" }, "proj-2");
await removeChatRoomMember("room-1", "agent-2", "proj-2");
expect((fetchMock.mock.calls[0] as [string])[0]).toContain("/api/chat/rooms/room-1/members?projectId=proj-2");
const [, addInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(addInit.method).toBe("POST");
expect(addInit.body).toBe(JSON.stringify({ agentId: "agent-2", role: "owner" }));
expect((fetchMock.mock.calls[2] as [string])[0]).toContain("/api/chat/rooms/room-1/members/agent-2?projectId=proj-2");
});
it("builds message endpoints", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
await fetchChatRoomMessages("room-1", { limit: 2, offset: 1, before: "2026-01-01" }, "proj-3");
await postChatRoomMessage("room-1", { content: "hello", mentions: ["agent-x"] }, "proj-3");
await deleteChatRoomMessage("room-1", "msg-1", "proj-3");
const [listUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(listUrl).toContain("/api/chat/rooms/room-1/messages?");
expect(listUrl).toContain("projectId=proj-3");
expect(listUrl).toContain("limit=2");
expect(listUrl).toContain("offset=1");
expect(listUrl).toContain("before=2026-01-01");
const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(postInit.method).toBe("POST");
expect(postInit.body).toBe(JSON.stringify({ content: "hello", mentions: ["agent-x"] }));
const [, delInit] = fetchMock.mock.calls[2] as [string, RequestInit];
expect(delInit.method).toBe("DELETE");
});
});

View File

@@ -120,4 +120,23 @@ describe("streamChatResponse SSE parser", () => {
expect(donePayloads).toEqual([{ messageId: "" }]);
});
});
it("fires onError when no stream events arrive before timeout", async () => {
vi.useFakeTimers();
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(": connected\n\n"));
},
}), { status: 200 }));
const onError = vi.fn();
streamChatResponse("s-1", "hi", { onError }, undefined, undefined, { firstEventTimeoutMs: 1_000 });
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1_100);
expect(onError).toHaveBeenCalledWith("Timed out waiting for first response event");
vi.useRealTimers();
});
});

View File

@@ -1,6 +1,7 @@
import type {
Task,
TaskDetail,
TaskReviewData,
TaskAttachment,
TaskComment,
TaskCreateInput,
@@ -41,21 +42,12 @@ import type {
AgentRating,
AgentRatingSummary,
AgentRatingInput,
ChatAttachment,
ChatMessage,
ChatRoom,
ChatRoomMember,
ChatRoomMessage,
EnrichedChatSession,
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
TodoList,
TodoItem,
TodoListWithItems,
@@ -68,6 +60,8 @@ import type {
InsightStatus,
InsightRun,
InsightRunTrigger,
EvalRun,
EvalTaskResult,
ResearchRunStatus,
TaskPriority,
TaskSourceIssue,
@@ -78,6 +72,8 @@ import type {
DockerVolumeMount,
DockerExtraCli,
DockerNodeStatus,
ProjectNodePathMapping,
ApprovalRequestStatus,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -247,6 +243,40 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise<T
throw new Error("Request failed");
}
export interface UpdateTaskReviewRequest {
reviewState: TaskDetail["reviewState"] | null;
}
export interface TaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
emptyMessage?: string | null;
prInfo?: TaskDetail["prInfo"];
}
export interface RefreshTaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
prInfo?: TaskDetail["prInfo"];
}
export interface SelectedReviewItem {
id: string;
source: "pr-review" | "reviewer-agent";
threadId?: string;
filePath?: string;
lineNumber?: number;
author?: string;
summary: string;
body: string;
url?: string;
}
export interface ReviseTaskReviewResponse {
task: Task;
reviewState: NonNullable<TaskDetail["reviewState"]>;
}
export interface CreateTaskRequestOptions {
transportNodeId?: string;
localNodeId?: string;
@@ -2341,9 +2371,18 @@ export function fetchRemote(remote?: string, projectId?: string): Promise<GitFet
}
/** Pull current branch */
export function pullBranch(projectId?: string): Promise<GitPullResult> {
return api<GitPullResult>(withProjectId("/git/pull", projectId), {
export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
export function pullBranch(projectId?: string): Promise<GitPullResult>;
export function pullBranch(
optionsOrProjectId?: { rebase?: boolean } | string,
projectId?: string,
): Promise<GitPullResult> {
const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
return api<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
method: "POST",
body: JSON.stringify({ rebase: options?.rebase ?? false }),
});
}
@@ -2657,6 +2696,7 @@ export interface SubtaskItem {
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
priority?: TaskPriority;
dependsOn: string[];
}
@@ -2684,7 +2724,13 @@ export interface AgentOnboardingSummary {
patternAgentId?: string;
rationale?: string;
model?: string;
/** Draft-only AI suggestion for eventual runtimeConfig.model selection. */
modelHint?: string;
/** Draft-only AI suggestion for eventual runtimeConfig.runtimeHint plugin runtime selection. */
runtimeHint?: string;
heartbeatProcedurePath?: string;
heartbeatIntervalMs?: number;
heartbeatEnabled?: boolean;
}
export type OnboardingMode = "create" | "edit";
@@ -2922,6 +2968,7 @@ export function createTasksFromPlanning(
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
priority?: TaskPriority;
dependsOn: string[];
}>,
projectId?: string,
@@ -5073,6 +5120,66 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
});
}
function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
return {
reviewState: {
source: data.mode,
summary: data.summary ?? undefined,
items: data.items.map((item) => ({
id: item.itemId,
body: item.body,
author: { login: item.author },
createdAt: item.createdAt ?? new Date(0).toISOString(),
updatedAt: item.updatedAt ?? undefined,
path: item.filePath,
threadId: item.threadId,
htmlUrl: item.url,
state: item.reviewState ?? undefined,
isResolved: item.isResolved,
})),
addressing: [],
lastRefreshedAt: data.fetchedAt ?? undefined,
refreshStatus: "ready",
refreshSource: "initial-load",
},
automationStatus: null,
};
}
/** Fetch normalized task review data (PR mode or direct mode) */
export async function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
return mapTaskReviewDataToLegacy(data);
}
/** Fetch canonical review payload for future review-tab rendering. */
export function fetchTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
}
/** Refresh normalized task review data (PR mode or direct mode) */
export async function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
method: "POST",
});
return mapTaskReviewDataToLegacy(data);
}
/** Refresh canonical review payload for future review-tab rendering. */
export function refreshTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
method: "POST",
});
}
/** Request an in-place revision pass for selected review items */
export function reviseTaskReviewItems(taskId: string, selectedItems: SelectedReviewItem[], projectId?: string): Promise<ReviseTaskReviewResponse> {
return api<ReviseTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/address`, projectId), {
method: "POST",
body: JSON.stringify({ selectedItems, tab: "review" }),
});
}
/** Return task to agent - clear assignee and status, move to todo */
export function returnTaskToAgent(taskId: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), {
@@ -5531,6 +5638,34 @@ export interface NodeCreateInput {
dockerConfig?: DockerNodeConfigInfo;
}
/** Input for assigning a project path for a specific node during onboarding. */
export interface NodeProjectMappingInput {
projectId: string;
path: string;
}
export interface RemoteNodeDiscoveredProject {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
}
export interface RemoteNodeProjectDiscoveryResult {
projects: RemoteNodeDiscoveredProject[];
}
/**
* Node onboarding payload used by dashboard UI.
*
* `projectMappings` is intentionally separate from `ProjectInfo.path` and `projects.nodeId`.
* It captures node-specific filesystem paths for selected existing projects.
*/
export interface NodeOnboardingInput extends NodeCreateInput {
projectMappings: NodeProjectMappingInput[];
}
/** Input for updating an existing node */
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & {
status?: NodeStatus;
@@ -5618,10 +5753,29 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
return api<ProjectInfo[]>("/projects");
}
/** Project info with source node metadata (added by server for remote projects) */
/** Dashboard-facing mapping contract for project availability on nodes. */
export interface ProjectNodeAvailability {
nodeId: string;
nodeName?: string;
path: string;
available: boolean;
}
/** Project info with source node metadata (added by server for remote projects). */
export interface ProjectInfoWithSource extends ProjectInfo {
/** Name of the source node (added by server for remote projects) */
/** Name of the source node (added by server for remote projects). */
_sourceNodeName?: string;
/** Normalized per-node project mappings for dashboard UI. */
nodeMappings?: ProjectNodeAvailability[];
/** Compatibility fields accepted from in-flight server rollouts. */
projectNodeMappings?: ProjectNodeAvailability[];
pathMappings?: ProjectNodeAvailability[];
}
export function hasNodeMappingsSupport(project: ProjectInfoWithSource): boolean {
return Array.isArray(project.nodeMappings)
|| Array.isArray(project.projectNodeMappings)
|| Array.isArray(project.pathMappings);
}
/** Fetch all registered projects from all nodes (local + remote) */
@@ -5719,11 +5873,24 @@ export function registerNode(input: NodeCreateInput): Promise<NodeInfo> {
});
}
/** Discover projects from a remote node before registering it. */
export function discoverRemoteNodeProjects(input: { url: string; apiKey?: string }): Promise<RemoteNodeProjectDiscoveryResult> {
return api<RemoteNodeProjectDiscoveryResult>("/nodes/discover-projects", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch a single node by ID */
export function fetchNode(id: string): Promise<NodeInfo> {
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`);
}
/** Fetch all project path mappings for a node */
export function fetchNodePathMappings(nodeId: string): Promise<ProjectNodePathMapping[]> {
return api<ProjectNodePathMapping[]>(`/nodes/${encodeURIComponent(nodeId)}/path-mappings`);
}
/** Update an existing node */
export function updateNode(id: string, updates: NodeUpdateInput): Promise<NodeInfo> {
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`, {
@@ -5838,6 +6005,43 @@ export function unregisterProject(id: string): Promise<void> {
});
}
/** Fetch all per-node path mappings for a project */
export function fetchProjectPathMappings(projectId: string): Promise<ProjectNodePathMapping[]> {
return api<ProjectNodePathMapping[]>(`/projects/${encodeURIComponent(projectId)}/path-mappings`);
}
/** Fetch a single project-node path mapping */
export function fetchProjectPathMapping(projectId: string, nodeId: string): Promise<ProjectNodePathMapping> {
return api<ProjectNodePathMapping>(
`/projects/${encodeURIComponent(projectId)}/path-mappings/${encodeURIComponent(nodeId)}`,
);
}
/** Create or update a project-node path mapping */
export function upsertProjectPathMapping(
projectId: string,
nodeId: string,
path: string,
): Promise<ProjectNodePathMapping> {
return api<ProjectNodePathMapping>(
`/projects/${encodeURIComponent(projectId)}/path-mappings/${encodeURIComponent(nodeId)}`,
{
method: "PUT",
body: JSON.stringify({ path }),
},
);
}
/** Remove a project-node path mapping */
export function removeProjectPathMapping(projectId: string, nodeId: string): Promise<void> {
return api<void>(
`/projects/${encodeURIComponent(projectId)}/path-mappings/${encodeURIComponent(nodeId)}`,
{
method: "DELETE",
},
);
}
/** Fetch health metrics for a specific project */
export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
@@ -7132,223 +7336,6 @@ export async function previewEnrichedDescription(
}
}
// ── Roadmap API ─────────────────────────────────────────────────────────────────
/** Fetch all roadmaps */
export function fetchRoadmaps(projectId?: string): Promise<Roadmap[]> {
return api<Roadmap[]>(withProjectId("/roadmaps", projectId));
}
/** Create a new roadmap */
export function createRoadmap(input: RoadmapCreateInput, projectId?: string): Promise<Roadmap> {
return api<Roadmap>(withProjectId("/roadmaps", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch a single roadmap with full hierarchy (milestones and features) */
export function fetchRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapWithHierarchy> {
return api<RoadmapWithHierarchy>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}`, projectId));
}
/** Update roadmap metadata */
export function updateRoadmap(roadmapId: string, updates: RoadmapUpdateInput, projectId?: string): Promise<Roadmap> {
return api<Roadmap>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a roadmap */
export function deleteRoadmap(roadmapId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
method: "DELETE",
});
}
/** Fetch milestones for a roadmap */
export function fetchRoadmapMilestones(roadmapId: string, projectId?: string): Promise<RoadmapMilestone[]> {
return api<RoadmapMilestone[]>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId));
}
/** Create a milestone in a roadmap */
export function createRoadmapMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput, projectId?: string): Promise<RoadmapMilestone> {
return api<RoadmapMilestone>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update milestone metadata */
export function updateRoadmapMilestone(milestoneId: string, updates: RoadmapMilestoneUpdateInput, projectId?: string): Promise<RoadmapMilestone> {
return api<RoadmapMilestone>(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a milestone */
export function deleteRoadmapMilestone(milestoneId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "DELETE",
});
}
/** Fetch features for a milestone */
export function fetchRoadmapFeatures(milestoneId: string, projectId?: string): Promise<RoadmapFeature[]> {
return api<RoadmapFeature[]>(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId));
}
/** Create a feature in a milestone */
export function createRoadmapFeature(milestoneId: string, input: RoadmapFeatureCreateInput, projectId?: string): Promise<RoadmapFeature> {
return api<RoadmapFeature>(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update feature metadata */
export function updateRoadmapFeature(featureId: string, updates: RoadmapFeatureUpdateInput, projectId?: string): Promise<RoadmapFeature> {
return api<RoadmapFeature>(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a feature */
export function deleteRoadmapFeature(featureId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
method: "DELETE",
});
}
/** Reorder milestones within a roadmap */
export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedMilestoneIds }),
});
}
/** Reorder features within a milestone */
export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedFeatureIds }),
});
}
/** Move a feature to a different milestone or position */
export function moveRoadmapFeature(
featureId: string,
targetMilestoneId: string,
targetIndex: number,
projectId?: string
): Promise<void> {
return api<void>(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}/move`, projectId), {
method: "POST",
body: JSON.stringify({ targetMilestoneId, targetIndex }),
});
}
/** Export a roadmap as a flat bundle for persistence/import/export */
export function exportRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapExportBundle> {
return api<RoadmapExportBundle>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/export`, projectId));
}
/** Get mission planning handoff payload for a roadmap */
export function getRoadmapMissionHandoff(roadmapId: string, projectId?: string): Promise<RoadmapMissionPlanningHandoff> {
return api<RoadmapMissionPlanningHandoff>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff/mission`, projectId));
}
/** Get task planning handoff payload for a single roadmap feature */
export function getRoadmapFeatureHandoff(
roadmapId: string,
milestoneId: string,
featureId: string,
projectId?: string
): Promise<RoadmapFeatureTaskPlanningHandoff> {
return api<RoadmapFeatureTaskPlanningHandoff>(
withProjectId(
`/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`,
projectId
)
);
}
/** Combined handoff response type for roadmap handoff endpoint */
export interface RoadmapHandoffResponse {
mission: RoadmapMissionPlanningHandoff;
features: RoadmapFeatureTaskPlanningHandoff[];
}
/** Get both mission and feature handoff payloads for a roadmap */
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
return api<RoadmapHandoffResponse>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Generate milestone suggestions from a goal prompt */
export function generateMilestoneSuggestions(
roadmapId: string,
goalPrompt: string,
count?: number,
projectId?: string
): Promise<MilestoneSuggestionsResponse> {
return api<MilestoneSuggestionsResponse>(
withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId),
{
method: "POST",
body: JSON.stringify({
goalPrompt: goalPrompt.trim(),
...(count !== undefined ? { count } : {}),
}),
}
);
}
/** Response type for feature suggestions */
export interface FeatureSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Input for generating feature suggestions */
export interface GenerateFeatureSuggestionsInput {
/** Optional prompt to guide feature generation */
prompt?: string;
/** Number of features to generate (default 5, max 10) */
count?: number;
}
/** Generate feature suggestions for a milestone */
export function generateFeatureSuggestions(
milestoneId: string,
input?: GenerateFeatureSuggestionsInput,
projectId?: string
): Promise<FeatureSuggestionsResponse> {
return api<FeatureSuggestionsResponse>(
withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId),
{
method: "POST",
body: JSON.stringify({
...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}),
...(input?.count !== undefined ? { count: input.count } : {}),
}),
}
);
}
// ── Todo API ─────────────────────────────────────────────────────────────────
/** Fetch all todo lists with their items */
@@ -7588,6 +7575,7 @@ export interface OutboxResponse {
/** Response shape for GET /messages/unread-count */
export interface UnreadCountResponse {
unreadCount: number;
pendingApprovalCount?: number;
}
/** Response shape for POST /messages/read-all */
@@ -7613,6 +7601,56 @@ export interface SendMessageInput {
content: string;
type: MessageType;
metadata?: MessageMetadata;
wakeImmediately?: boolean;
}
export interface ApprovalRequestSummary {
id: string;
status: ApprovalRequestStatus;
actionCategory: string;
actionSummary: string;
agentId: string;
taskId?: string;
createdAt: string;
updatedAt: string;
decidedAt?: string;
decidedBy?: string;
}
export interface ApprovalRequestDetail extends ApprovalRequestSummary {
requester: {
actorId: string;
actorType: "agent" | "user" | "system";
actorName: string;
};
runId?: string;
requestedAt: string;
completedAt?: string;
targetAction: {
category: string;
action: string;
summary: string;
resourceType: string;
resourceId: string;
context?: Record<string, unknown>;
};
history: Array<{
id: string;
eventType: string;
actor: {
actorId: string;
actorType: "agent" | "user" | "system";
actorName: string;
};
note?: string;
createdAt: string;
}>;
}
export interface ApprovalListResponse {
requests: ApprovalRequestSummary[];
total: number;
pendingCount: number;
}
/** Fetch inbox messages for the current user. */
@@ -7698,6 +7736,34 @@ export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId));
}
export function fetchApprovals(
options?: { status?: ApprovalRequestStatus; limit?: number; offset?: number },
projectId?: string,
): Promise<ApprovalListResponse> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.offset !== undefined) params.set("offset", String(options.offset));
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<ApprovalListResponse>(`/approvals${query}`);
}
export function fetchApprovalDetail(id: string, projectId?: string): Promise<ApprovalRequestDetail> {
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}`, projectId));
}
export function decideApproval(
id: string,
input: { decision: "approve" | "deny"; comment?: string },
projectId?: string,
): Promise<ApprovalRequestDetail> {
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}/decision`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch reflection history for an agent. */
export function fetchAgentReflections(agentId: string, limit?: number, projectId?: string): Promise<AgentReflection[]> {
const params = new URLSearchParams();
@@ -7843,8 +7909,13 @@ export async function updatePluginSettings(
export type PluginSetupStatusResponse =
| { hasSetup: false }
| { hasSetup: false; status: Extract<PluginSetupCheckResult, { status: "error" }> }
| ({ hasSetup: true } & PluginSetupCheckResult);
| ({ hasSetup: true } & PluginSetupCheckResult)
| {
hasSetup: true;
setupCheckDeferred: true;
deferredReason: "plugin-not-started";
pluginState: PluginInstallation["state"];
};
/** Fetch plugin setup status */
export async function fetchPluginSetupStatus(id: string, projectId?: string): Promise<PluginSetupStatusResponse> {
@@ -7985,6 +8056,27 @@ export interface ChatMessageListResponse {
messages: ChatMessage[];
}
export interface ChatRoomListResponse {
rooms: ChatRoom[];
}
export interface ChatRoomResponse {
room: ChatRoom;
members?: ChatRoomMember[];
}
export interface ChatRoomMembersResponse {
members: ChatRoomMember[];
}
export interface ChatRoomMessageListResponse {
messages: ChatRoomMessage[];
}
export interface ChatRoomMessageResponse {
message: ChatRoomMessage;
}
/** Fetch all chat sessions for a project */
export function fetchChatSessions(projectId?: string, status?: string): Promise<ChatSessionListResponse> {
const search = new URLSearchParams();
@@ -8098,6 +8190,114 @@ export function deleteChatMessage(
);
}
export function fetchChatRooms(
options: { status?: string; agentId?: string } = {},
projectId?: string,
): Promise<ChatRoomListResponse> {
const search = new URLSearchParams();
if (projectId) search.set("projectId", projectId);
if (options.status) search.set("status", options.status);
if (options.agentId) search.set("agentId", options.agentId);
const qs = search.toString();
return api<ChatRoomListResponse>(`/chat/rooms${qs ? `?${qs}` : ""}`);
}
export function fetchChatRoom(id: string, projectId?: string): Promise<ChatRoomResponse> {
return api<ChatRoomResponse>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId));
}
export function createChatRoom(
input: { name: string; description?: string | null; createdBy?: string | null; memberAgentIds?: string[] },
projectId?: string,
): Promise<ChatRoomResponse> {
const body = { ...input, ...(projectId ? { projectId } : {}) };
return api<ChatRoomResponse>(withProjectId("/chat/rooms", projectId), {
method: "POST",
body: JSON.stringify(body),
});
}
export function updateChatRoom(
id: string,
updates: { name?: string; description?: string | null; status?: "active" | "archived" },
projectId?: string,
): Promise<{ room: ChatRoom }> {
return api<{ room: ChatRoom }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
export function deleteChatRoom(id: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId), {
method: "DELETE",
});
}
export function fetchChatRoomMembers(id: string, projectId?: string): Promise<ChatRoomMembersResponse> {
return api<ChatRoomMembersResponse>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}/members`, projectId));
}
export function addChatRoomMember(
id: string,
input: { agentId: string; role?: "owner" | "member" },
projectId?: string,
): Promise<{ member: ChatRoomMember }> {
return api<{ member: ChatRoomMember }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}/members`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
export function removeChatRoomMember(id: string, agentId: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(
withProjectId(`/chat/rooms/${encodeURIComponent(id)}/members/${encodeURIComponent(agentId)}`, projectId),
{ method: "DELETE" },
);
}
export function fetchChatRoomMessages(
id: string,
opts?: { limit?: number; offset?: number; before?: string },
projectId?: string,
): Promise<ChatRoomMessageListResponse> {
const search = new URLSearchParams();
if (opts?.limit !== undefined) search.set("limit", String(opts.limit));
if (opts?.offset !== undefined) search.set("offset", String(opts.offset));
if (opts?.before) search.set("before", opts.before);
const qs = search.toString();
return api<ChatRoomMessageListResponse>(
withProjectId(`/chat/rooms/${encodeURIComponent(id)}/messages${qs ? `?${qs}` : ""}`, projectId),
);
}
export function postChatRoomMessage(
id: string,
input: { content: string; senderAgentId?: null; mentions?: string[]; attachments?: File[] | ChatAttachment[] },
projectId?: string,
): Promise<ChatRoomMessageResponse> {
return api<ChatRoomMessageResponse>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}/messages`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
export function deleteChatRoomMessage(
id: string,
messageId: string,
projectId?: string,
): Promise<{ success: boolean }> {
return api<{ success: boolean }>(
withProjectId(`/chat/rooms/${encodeURIComponent(id)}/messages/${encodeURIComponent(messageId)}`, projectId),
{ method: "DELETE" },
);
}
/**
* Room POST /messages in FN-3808 is persist-only (201 JSON response).
* Do not add streamChatRoomResponse until FN-3810 introduces AI invocation/streaming.
*/
/** Cancel an in-flight chat generation. */
export function cancelChatResponse(
sessionId: string,
@@ -8136,21 +8336,39 @@ export function streamChatResponse(
},
attachments?: File[],
projectId?: string,
options?: { maxReconnectAttempts?: number },
options?: { maxReconnectAttempts?: number; firstEventTimeoutMs?: number },
): { close: () => void; isConnected: () => boolean } {
const url = buildApiUrl(withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, projectId));
void options;
const abortController = new AbortController();
let closedByUser = false;
let terminated = false;
let receivedStreamEvent = false;
const firstEventTimeoutMs = Math.max(1_000, options?.firstEventTimeoutMs ?? 60_000);
let firstEventTimer: ReturnType<typeof setTimeout> | null = null;
const clearFirstEventTimer = (): void => {
if (firstEventTimer) {
clearTimeout(firstEventTimer);
firstEventTimer = null;
}
};
const markFirstEventReceived = (): void => {
if (receivedStreamEvent) {
return;
}
receivedStreamEvent = true;
clearFirstEventTimer();
};
const dispatchEvent = (eventName: string, rawData: string): void => {
if (!eventName) {
return;
}
markFirstEventReceived();
switch (eventName) {
case "thinking":
try {
@@ -8248,6 +8466,14 @@ export function streamChatResponse(
}
handlers.onConnectionStateChange?.("connected");
firstEventTimer = setTimeout(() => {
if (terminated || closedByUser || receivedStreamEvent) {
return;
}
terminated = true;
handlers.onError?.("Timed out waiting for first response event");
abortController.abort();
}, firstEventTimeoutMs);
const reader = res.body.getReader();
const decoder = new TextDecoder();
@@ -8319,14 +8545,20 @@ export function streamChatResponse(
if (!terminated && !closedByUser && !hasUndispatchedTrailingFragment) {
handlers.onError?.("Connection closed unexpectedly");
}
clearFirstEventTimer();
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
if (!closedByUser) {
if (!closedByUser && !terminated) {
handlers.onError?.("Connection aborted");
}
clearFirstEventTimer();
return;
}
if (closedByUser) return;
if (closedByUser) {
clearFirstEventTimer();
return;
}
clearFirstEventTimer();
handlers.onError?.(err instanceof Error ? err.message : "Connection error");
}
})();
@@ -8334,6 +8566,7 @@ export function streamChatResponse(
return {
close: () => {
closedByUser = true;
clearFirstEventTimer();
abortController.abort();
},
isConnected: () => !closedByUser,
@@ -8489,6 +8722,35 @@ export function getInsightCreateTaskData(
// ── Research API ────────────────────────────────────────────────────────────
export interface EvalsListOptions {
q?: string;
runId?: string;
scoreMin?: number;
scoreMax?: number;
limit?: number;
offset?: number;
}
export function listEvals(options: EvalsListOptions = {}, projectId?: string): Promise<{ results: EvalTaskResult[]; count: number }> {
const params = new URLSearchParams();
if (options.q) params.set("q", options.q);
if (options.runId) params.set("runId", options.runId);
if (options.scoreMin !== undefined) params.set("scoreMin", String(options.scoreMin));
if (options.scoreMax !== undefined) params.set("scoreMax", String(options.scoreMax));
if (options.limit !== undefined) params.set("limit", String(options.limit));
if (options.offset !== undefined) params.set("offset", String(options.offset));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return api<{ results: EvalTaskResult[]; count: number }>(withProjectId(`/evals${suffix}`, projectId));
}
export function getEval(id: string, projectId?: string): Promise<{ result: EvalTaskResult }> {
return api<{ result: EvalTaskResult }>(withProjectId(`/evals/${encodeURIComponent(id)}`, projectId));
}
export function listEvalRuns(projectId?: string): Promise<{ runs: EvalRun[] }> {
return api<{ runs: EvalRun[] }>(withProjectId("/evals/runs", projectId));
}
export interface CreateResearchRunInput {
query: string;
providers: ResearchProviderOption[];