feat(FN-2998): scope ResearchView bottom safe-area padding to mobile
The merge brings in a fix (FN-2998) that scopes the bottom safe-area padding in ResearchView to mobile devices only, correcting an over-application of the padding on desktop or larger screens. Fusion-Task-Id: FN-2998
This commit is contained in:
@@ -173,6 +173,11 @@ describe("runTaskShow", () => {
|
||||
{ sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" } },
|
||||
"Source: GitHub Import (https://github.com/owner/repo/issues/42)",
|
||||
],
|
||||
[
|
||||
{ sourceType: "research", sourceMetadata: { runId: "RR-001", findingLabel: "Latency hotspot" } },
|
||||
"Source: Research (Latency hotspot)",
|
||||
],
|
||||
[{ sourceType: "research", sourceMetadata: { runId: "RR-002" } }, "Source: Research (RR-002)"],
|
||||
] as const)("prints provenance line for %o", async (overrides, expectedLine) => {
|
||||
mockTaskStoreGetTask(makeTask(overrides));
|
||||
|
||||
|
||||
@@ -24,6 +24,18 @@ function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
|
||||
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
|
||||
}
|
||||
|
||||
function getResearchSourceContext(sourceMetadata: unknown): string | undefined {
|
||||
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
|
||||
|
||||
const findingLabel = (sourceMetadata as { findingLabel?: unknown }).findingLabel;
|
||||
if (typeof findingLabel === "string" && findingLabel.length > 0) {
|
||||
return findingLabel;
|
||||
}
|
||||
|
||||
const runId = (sourceMetadata as { runId?: unknown }).runId;
|
||||
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
|
||||
}
|
||||
|
||||
function formatTaskSource(task: {
|
||||
sourceType?: string;
|
||||
sourceAgentId?: string;
|
||||
@@ -49,6 +61,10 @@ function formatTaskSource(task: {
|
||||
const issueUrl = getGitHubIssueUrl(task.sourceMetadata);
|
||||
return issueUrl ? `GitHub Import (${issueUrl})` : "GitHub Import";
|
||||
}
|
||||
case "research": {
|
||||
const context = getResearchSourceContext(task.sourceMetadata);
|
||||
return context ? `Research (${context})` : "Research";
|
||||
}
|
||||
case "task_refine":
|
||||
return task.sourceParentTaskId
|
||||
? `Refinement of ${task.sourceParentTaskId}`
|
||||
|
||||
@@ -30,7 +30,7 @@ import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as projectMemory from "../project-memory.js";
|
||||
import type { Task } from "../types.js";
|
||||
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
||||
@@ -7402,6 +7402,41 @@ Task with acceptance criteria
|
||||
expect(fetched.sourceAgentId).toBe("agent-auto");
|
||||
expect(fetched.sourceMetadata).toEqual({ trigger: "nightly" });
|
||||
});
|
||||
|
||||
it("persists research provenance metadata", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Research finding follow-up",
|
||||
source: {
|
||||
sourceType: "research",
|
||||
sourceMetadata: {
|
||||
runId: "RR-42",
|
||||
findingId: "finding-1",
|
||||
findingLabel: "Key risk",
|
||||
documentKey: "research-RR-42",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.sourceType).toBe("research");
|
||||
expect(fetched.sourceMetadata).toEqual({
|
||||
runId: "RR-42",
|
||||
findingId: "finding-1",
|
||||
findingLabel: "Key risk",
|
||||
documentKey: "research-RR-42",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("research document key helper", () => {
|
||||
it("builds canonical research document keys", () => {
|
||||
expect(buildResearchDocumentKey("RR-1")).toBe("research-RR-1");
|
||||
expect(buildResearchDocumentKey("RR/1")).toBe("research-RR1");
|
||||
});
|
||||
|
||||
it("rejects run IDs that sanitize to an empty string", () => {
|
||||
expect(() => buildResearchDocumentKey("!!!")).toThrow("Invalid research run id");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Title Handling Tests ────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
|
||||
@@ -4406,6 +4406,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
"text/yaml",
|
||||
"text/x-toml",
|
||||
|
||||
@@ -741,6 +741,17 @@ export function validateDocumentKey(key: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build canonical research enrichment document key from a run id. */
|
||||
export function buildResearchDocumentKey(runId: string): string {
|
||||
const sanitizedRunId = runId.replace(/[^A-Za-z0-9_-]/g, "");
|
||||
if (!sanitizedRunId) {
|
||||
throw new Error("Invalid research run id: sanitized run id is empty");
|
||||
}
|
||||
const key = `research-${sanitizedRunId}`;
|
||||
validateDocumentKey(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export interface MergeDetails {
|
||||
commitSha?: string;
|
||||
filesChanged?: number;
|
||||
@@ -813,6 +824,7 @@ export type SourceType =
|
||||
| "cli"
|
||||
| "api"
|
||||
| "recovery"
|
||||
| "research"
|
||||
| "unknown";
|
||||
|
||||
/** Provenance metadata for how a task was created. */
|
||||
|
||||
@@ -8128,28 +8128,40 @@ export function exportResearchRun(
|
||||
|
||||
export function createTaskFromResearchRun(
|
||||
id: string,
|
||||
input: { title?: string; includeSummary?: boolean; includeCitations?: boolean },
|
||||
input: { findingId?: string; title?: string; description?: string; priority?: "low" | "normal" | "high" | "urgent"; attachExport?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<{ task: { id: string; title: string } }> {
|
||||
return api<{ task: { id: string; title: string } }>(
|
||||
withProjectId(`/research/runs/${encodeURIComponent(id)}/create-task`, projectId),
|
||||
): Promise<{ task: Task; documentKey: string; attachmentFilename?: string }> {
|
||||
const findingId = input.findingId ?? "finding-1";
|
||||
return api<{ task: Task; documentKey: string; attachmentFilename?: string }>(
|
||||
withProjectId(`/research/runs/${encodeURIComponent(id)}/findings/${encodeURIComponent(findingId)}/task`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
priority: input.priority,
|
||||
attachExport: input.attachExport,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function attachResearchRunToTask(
|
||||
id: string,
|
||||
input: { taskId: string; mode: "document" | "attachment"; includeSummary?: boolean; includeCitations?: boolean },
|
||||
input: { findingId?: string; taskId: string; attachExport?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<{ task: { id: string }; documentKey?: string; attachmentName?: string }> {
|
||||
return api<{ task: { id: string }; documentKey?: string; attachmentName?: string }>(
|
||||
withProjectId(`/research/runs/${encodeURIComponent(id)}/attach-task`, projectId),
|
||||
): Promise<{ taskId: string; documentKey: string; revision: number; attachmentFilename?: string }> {
|
||||
const findingId = input.findingId ?? "finding-1";
|
||||
return api<{ taskId: string; documentKey: string; revision: number; attachmentFilename?: string }>(
|
||||
withProjectId(
|
||||
`/research/runs/${encodeURIComponent(id)}/findings/${encodeURIComponent(findingId)}/tasks/${encodeURIComponent(input.taskId)}/enrich`,
|
||||
projectId,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({
|
||||
attachExport: input.attachExport,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
.research-task-action-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.research-task-action-modal__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.research-task-action-modal__preview p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.research-task-action-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.research-task-action-modal__textarea {
|
||||
min-height: calc(var(--space-2xl) * 2);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.research-task-action-modal {
|
||||
width: min(100%, calc(100vw - (var(--space-md) * 2)));
|
||||
}
|
||||
}
|
||||
130
packages/dashboard/app/components/ResearchTaskActionModal.tsx
Normal file
130
packages/dashboard/app/components/ResearchTaskActionModal.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Task, TaskPriority } from "@fusion/core";
|
||||
import { fetchTasks } from "../api";
|
||||
import type { ResearchRunDetail } from "../research-types";
|
||||
import "./ResearchTaskActionModal.css";
|
||||
|
||||
type Mode = "create" | "enrich";
|
||||
|
||||
interface ResearchTaskActionModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
run: ResearchRunDetail;
|
||||
finding: { id: string; heading?: string; content?: string };
|
||||
projectId?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (payload: { taskId?: string; title?: string; description?: string; priority?: TaskPriority; attachExport: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) {
|
||||
const [attachExport, setAttachExport] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<TaskPriority>("normal");
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const firstSentence = (finding.content ?? "").split(/(?<=[.!?])\s+/)[0] ?? "";
|
||||
return `${finding.heading || "Research finding"} — ${firstSentence}`.trim();
|
||||
}, [finding.content, finding.heading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAttachExport(false);
|
||||
setTitle(`Research: ${finding.heading || run.title}`);
|
||||
setDescription(preview);
|
||||
setPriority("normal");
|
||||
setTaskId("");
|
||||
|
||||
if (mode === "enrich") {
|
||||
setLoadingTasks(true);
|
||||
void fetchTasks(50, 0, projectId)
|
||||
.then((rows) => setTasks(rows.filter((task) => task.column !== "archived")))
|
||||
.finally(() => setLoadingTasks(false));
|
||||
}
|
||||
}, [open, mode, projectId, finding.heading, preview, run.title]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" role="presentation" onClick={onClose}>
|
||||
<div className="modal modal-lg research-task-action-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{mode === "create" ? "Create task from finding" : "Enrich existing task"}</h3>
|
||||
<button className="modal-close" type="button" aria-label="Close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="research-task-action-modal__body">
|
||||
<div className="card research-task-action-modal__preview">
|
||||
<p><strong>Run:</strong> {run.id}</p>
|
||||
<p><strong>Finding:</strong> {finding.id}{finding.heading ? ` — ${finding.heading}` : ""}</p>
|
||||
<p>{preview || "No preview available."}</p>
|
||||
</div>
|
||||
|
||||
{mode === "create" ? (
|
||||
<>
|
||||
<label className="research-task-action-modal__field">Title
|
||||
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
</label>
|
||||
<label className="research-task-action-modal__field">Description
|
||||
<textarea className="input research-task-action-modal__textarea" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label className="research-task-action-modal__field">Priority
|
||||
<select className="select" value={priority} onChange={(event) => setPriority(event.target.value as TaskPriority)}>
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="research-task-action-modal__field">Target task
|
||||
<input
|
||||
className="input"
|
||||
list="research-task-action-task-list"
|
||||
value={taskId}
|
||||
placeholder={loadingTasks ? "Loading tasks…" : "Enter task ID"}
|
||||
onChange={(event) => setTaskId(event.target.value)}
|
||||
/>
|
||||
<datalist id="research-task-action-task-list">
|
||||
{tasks.map((task) => (
|
||||
<option key={task.id} value={task.id}>{task.title}</option>
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={attachExport} onChange={(event) => setAttachExport(event.target.checked)} />
|
||||
<span>Attach markdown export artifact</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={saving || (mode === "enrich" && !taskId)}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
void onConfirm({
|
||||
taskId: mode === "enrich" ? taskId : undefined,
|
||||
title: mode === "create" ? title.trim() : undefined,
|
||||
description: mode === "create" ? description.trim() : undefined,
|
||||
priority: mode === "create" ? priority : undefined,
|
||||
attachExport,
|
||||
}).finally(() => setSaving(false));
|
||||
}}
|
||||
>
|
||||
{mode === "create" ? "Create Task" : "Enrich Task"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-lg);
|
||||
padding-bottom: calc(var(--space-lg) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
|
||||
padding-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.research-view__header {
|
||||
@@ -77,10 +77,17 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.research-view__history-search-row .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.research-view__history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -175,6 +182,10 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.research-view__finding-actions {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.research-view__events {
|
||||
margin: var(--space-sm) 0 0;
|
||||
padding-left: var(--space-lg);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Loader2, Search } from "lucide-react";
|
||||
import { fetchAuthStatus, fetchSettings } from "../api";
|
||||
import { useResearch } from "../hooks/useResearch";
|
||||
import type { ResearchProviderOption } from "../research-types";
|
||||
import { ResearchTaskActionModal } from "./ResearchTaskActionModal";
|
||||
import type { SectionId } from "./SettingsModal";
|
||||
import "./ResearchView.css";
|
||||
|
||||
@@ -57,8 +58,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
|
||||
const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
|
||||
const [taskIdToAttach, setTaskIdToAttach] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [modalState, setModalState] = useState<null | { mode: "create" | "enrich"; findingId: string }>(null);
|
||||
|
||||
const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS;
|
||||
const isProviderEnabled = (provider: ResearchProviderOption) => effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]];
|
||||
@@ -339,33 +340,35 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
|
||||
{supportedExportFormats.includes("json") && <button className="btn" type="button" disabled={actionLoading === "export-json"} onClick={() => void handleExport("json")}>Export JSON</button>}
|
||||
{supportedExportFormats.includes("html") && <button className="btn" type="button" disabled={actionLoading === "export-html"} onClick={() => void handleExport("html")}>Export HTML</button>}
|
||||
</div>
|
||||
<div className="research-view__actions">
|
||||
<button className="btn btn-primary" type="button" disabled={actionLoading === "create-task"} onClick={() => void runAction("create-task", () => createTaskFromRun(selectedRun.id, `Research: ${selectedRun.title}`), "Task created from research") }>
|
||||
Create Task
|
||||
</button>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-task-id">Task ID</label>
|
||||
<input
|
||||
id="research-task-id"
|
||||
className="input"
|
||||
placeholder="Task ID"
|
||||
value={taskIdToAttach}
|
||||
onChange={(event) => setTaskIdToAttach(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" type="button" disabled={!taskIdToAttach.trim() || actionLoading === "attach-task"} onClick={() => void runAction("attach-task", () => attachRunToTask(selectedRun.id, taskIdToAttach.trim(), "document"), "Attached to task")}>
|
||||
Attach to Task
|
||||
</button>
|
||||
</div>
|
||||
{selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
|
||||
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
|
||||
<div className="research-view__findings">
|
||||
{selectedRun.results.findings.map((finding) => (
|
||||
<article key={finding.heading} className="research-view__finding card">
|
||||
<h4>{finding.heading}</h4>
|
||||
<p>{finding.content}</p>
|
||||
</article>
|
||||
))}
|
||||
{selectedRun.results.findings.map((finding, index) => {
|
||||
const findingRecord = finding as { id?: string };
|
||||
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`;
|
||||
return (
|
||||
<article key={findingId} className="research-view__finding card">
|
||||
<h4>{finding.heading}</h4>
|
||||
<p>{finding.content}</p>
|
||||
<div className="research-view__actions research-view__finding-actions">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
onClick={() => setModalState({ mode: "create", findingId })}
|
||||
>
|
||||
Create Task
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
type="button"
|
||||
onClick={() => setModalState({ mode: "enrich", findingId })}
|
||||
>
|
||||
Enrich Task
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(selectedRun.results?.citations) && selectedRun.results!.citations!.length > 0 && (
|
||||
@@ -397,6 +400,42 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedRun && modalState && (() => {
|
||||
const findingIndex = selectedRun.results?.findings?.findIndex((entry, idx) => {
|
||||
const findingRecord = entry as { id?: string };
|
||||
const id = findingRecord.id?.trim() || `finding-${idx + 1}`;
|
||||
return id === modalState.findingId;
|
||||
}) ?? -1;
|
||||
const finding = findingIndex >= 0 ? selectedRun.results!.findings[findingIndex] : null;
|
||||
if (!finding) return null;
|
||||
|
||||
return (
|
||||
<ResearchTaskActionModal
|
||||
open
|
||||
mode={modalState.mode}
|
||||
run={selectedRun}
|
||||
finding={{ id: modalState.findingId, heading: finding.heading, content: finding.content }}
|
||||
projectId={projectId}
|
||||
onClose={() => setModalState(null)}
|
||||
onConfirm={async ({ taskId, title, description, priority, attachExport }) => {
|
||||
if (modalState.mode === "create") {
|
||||
await runAction(
|
||||
"create-task",
|
||||
() => createTaskFromRun(selectedRun.id, title, modalState.findingId, description, priority, attachExport),
|
||||
"Task created from research",
|
||||
);
|
||||
} else if (taskId) {
|
||||
await runAction(
|
||||
"attach-task",
|
||||
() => attachRunToTask(selectedRun.id, taskId, modalState.findingId, attachExport),
|
||||
"Task enriched from research",
|
||||
);
|
||||
}
|
||||
setModalState(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,6 +239,16 @@ function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | und
|
||||
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
|
||||
}
|
||||
|
||||
function getResearchContextInfo(metadata: Task["sourceMetadata"]): string | undefined {
|
||||
const findingLabel = metadata?.findingLabel;
|
||||
if (typeof findingLabel === "string" && findingLabel.length > 0) {
|
||||
return findingLabel;
|
||||
}
|
||||
|
||||
const runId = metadata?.runId;
|
||||
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
|
||||
}
|
||||
|
||||
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
|
||||
|
||||
function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOptions = {}): ProvenanceDisplay | null {
|
||||
@@ -269,6 +279,13 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt
|
||||
contextInfo: issueUrl,
|
||||
};
|
||||
}
|
||||
case "research": {
|
||||
const contextInfo = getResearchContextInfo(task.sourceMetadata);
|
||||
return {
|
||||
label: "Research",
|
||||
contextInfo,
|
||||
};
|
||||
}
|
||||
case "task_refine":
|
||||
return {
|
||||
label: "Refinement",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { Header } from "../Header";
|
||||
import { ResearchView } from "../ResearchView";
|
||||
|
||||
@@ -21,11 +21,13 @@ const configuredResearchSettings = {
|
||||
|
||||
const mockFetchSettings = vi.fn().mockResolvedValue(configuredResearchSettings);
|
||||
const mockFetchAuthStatus = vi.fn().mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: false }] });
|
||||
const mockFetchTasks = vi.fn().mockResolvedValue([{ id: "FN-1", title: "Existing task", column: "todo" }]);
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchScripts: vi.fn().mockResolvedValue({}),
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
fetchTasks: (...args: unknown[]) => mockFetchTasks(...args),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
@@ -142,7 +144,14 @@ describe("ResearchView", () => {
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
|
||||
selectedRun: { id: "RR-1", title: "t", query: "q", status: "pending", events: [{ id: "E-1", message: "queued" }], results: { summary: "Summary", findings: [], citations: [] } },
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "pending",
|
||||
events: [{ id: "E-1", message: "queued" }],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
cancelRun,
|
||||
retryRun,
|
||||
@@ -157,16 +166,16 @@ describe("ResearchView", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
fireEvent.click(screen.getByText("Retry"));
|
||||
fireEvent.click(screen.getByText("Create Task"));
|
||||
fireEvent.change(screen.getByPlaceholderText("Task ID"), { target: { value: "FN-1" } });
|
||||
fireEvent.click(screen.getByText("Attach to Task"));
|
||||
fireEvent.click(screen.getByText("Export MD"));
|
||||
|
||||
fireEvent.click(screen.getAllByText("Create Task")[0]);
|
||||
const createDialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(createDialog).getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cancelRun).toHaveBeenCalled();
|
||||
expect(retryRun).toHaveBeenCalled();
|
||||
expect(createTaskFromRun).toHaveBeenCalled();
|
||||
expect(attachRunToTask).toHaveBeenCalled();
|
||||
expect(exportRun).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,47 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.getByText("Created via GitHub Import (https://github.com/owner/repo/issues/42)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders finding label for research provenance", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
sourceType: "research",
|
||||
sourceMetadata: {
|
||||
runId: "RR-123",
|
||||
findingLabel: "Pricing pressure in EU segment",
|
||||
},
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Created via Research (Pricing pressure in EU segment)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to run id for research provenance context", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
sourceType: "research",
|
||||
sourceMetadata: { runId: "RR-456" },
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Created via Research (RR-456)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["unknown", undefined] as const)("omits provenance for %s source", (sourceType) => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -148,9 +148,16 @@ export function useResearch(options?: { projectId?: string }) {
|
||||
return response;
|
||||
},
|
||||
exportRun: (runId: string, format: "markdown" | "json" | "html") => exportResearchRun(runId, format, projectId),
|
||||
createTaskFromRun: (runId: string, title?: string) => createTaskFromResearchRun(runId, { title }, projectId),
|
||||
attachRunToTask: (runId: string, taskId: string, mode: "document" | "attachment") =>
|
||||
attachResearchRunToTask(runId, { taskId, mode }, projectId),
|
||||
createTaskFromRun: (
|
||||
runId: string,
|
||||
title?: string,
|
||||
findingId?: string,
|
||||
description?: string,
|
||||
priority?: "low" | "normal" | "high" | "urgent",
|
||||
attachExport?: boolean,
|
||||
) => createTaskFromResearchRun(runId, { title, findingId, description, priority, attachExport }, projectId),
|
||||
attachRunToTask: (runId: string, taskId: string, findingId?: string, attachExport?: boolean) =>
|
||||
attachResearchRunToTask(runId, { taskId, findingId, attachExport }, projectId),
|
||||
statusCounts: runs.reduce<Record<ResearchRunStatus, number>>(
|
||||
(acc, run) => {
|
||||
acc[run.status] += 1;
|
||||
|
||||
@@ -4,23 +4,46 @@ import express from "express";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import { createResearchRouter } from "../research-routes.js";
|
||||
|
||||
function createMockStore() {
|
||||
function createMockStore(options?: {
|
||||
taskColumn?: string;
|
||||
runId?: string;
|
||||
missingRun?: boolean;
|
||||
missingFinding?: boolean;
|
||||
missingTask?: boolean;
|
||||
existingAttachmentOriginalName?: string;
|
||||
addAttachmentError?: string;
|
||||
}) {
|
||||
const run = {
|
||||
id: "RR-1",
|
||||
id: options?.runId ?? "RR-1",
|
||||
query: "test",
|
||||
topic: "test",
|
||||
status: "pending",
|
||||
sources: [],
|
||||
events: [],
|
||||
tags: [],
|
||||
results: {
|
||||
summary: "Run summary",
|
||||
findings: options?.missingFinding
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "finding-1",
|
||||
heading: "Finding One",
|
||||
content: "Important actionable result.",
|
||||
sources: ["https://example.com/citation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let revision = 0;
|
||||
|
||||
const researchStore = {
|
||||
listRuns: vi.fn(() => [run]),
|
||||
createRun: vi.fn(() => run),
|
||||
getRun: vi.fn(() => run),
|
||||
getRun: vi.fn(() => (options?.missingRun ? null : run)),
|
||||
updateStatus: vi.fn(),
|
||||
updateRun: vi.fn(),
|
||||
appendEvent: vi.fn(),
|
||||
@@ -29,9 +52,25 @@ function createMockStore() {
|
||||
|
||||
return {
|
||||
getResearchStore: () => researchStore,
|
||||
createTask: vi.fn(async () => ({ id: "FN-1", title: "Task" })),
|
||||
upsertTaskDocument: vi.fn(async () => ({ key: "research-rr-1" })),
|
||||
addAttachment: vi.fn(async () => ({ filename: "RR-1.md" })),
|
||||
createTask: vi.fn(async (input) => ({ id: "FN-1", title: input.title, description: input.description, attachments: [] })),
|
||||
getTask: vi.fn(async (taskId: string) => {
|
||||
if (options?.missingTask) return null;
|
||||
return {
|
||||
id: taskId,
|
||||
column: options?.taskColumn ?? "todo",
|
||||
attachments: options?.existingAttachmentOriginalName
|
||||
? [{ filename: `123-${options.existingAttachmentOriginalName}`, originalName: options.existingAttachmentOriginalName }]
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
upsertTaskDocument: vi.fn(async () => ({ key: "research-RR-1", revision: ++revision })),
|
||||
addAttachment: vi.fn(async () => {
|
||||
if (options?.addAttachmentError) {
|
||||
throw new Error(options.addAttachmentError);
|
||||
}
|
||||
return { filename: "RR-1-finding-1.md" };
|
||||
}),
|
||||
log: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +86,186 @@ describe("research-routes", () => {
|
||||
expect(Array.isArray(response.body.runs)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates task from run", async () => {
|
||||
it("creates task from finding with research provenance", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ attachExport: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.documentKey).toBe("research-RR-1");
|
||||
expect(response.body.task.id).toBe("FN-1");
|
||||
expect(store.addAttachment).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
"RR-1-finding-1.md",
|
||||
expect.any(Buffer),
|
||||
"text/markdown",
|
||||
);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
sourceType: "research",
|
||||
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("enriches existing task from finding and returns revision", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: false }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.taskId).toBe("FN-42");
|
||||
expect(response.body.documentKey).toBe("research-RR-1");
|
||||
expect(response.body.revision).toBe(1);
|
||||
});
|
||||
|
||||
it("skips duplicate attachment when original name already exists", async () => {
|
||||
const store = createMockStore({ existingAttachmentOriginalName: "RR-1-finding-1.md" });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(store.addAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("increments document revision on repeated enrichment", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
const first = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: false }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
const second = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: false }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(first.body.revision).toBe(1);
|
||||
expect(second.body.revision).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 404 when run is missing", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ missingRun: true }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toContain("Run not found");
|
||||
});
|
||||
|
||||
it("returns 404 when finding is missing", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ missingFinding: true }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toContain("Finding not found");
|
||||
});
|
||||
|
||||
it("returns 404 when enrich target task is missing", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ missingTask: true }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: false }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toContain("Task not found");
|
||||
});
|
||||
|
||||
it("returns 409 when enriching an archived task", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ taskColumn: "archived" }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: false }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.error).toContain("Cannot enrich archived task");
|
||||
});
|
||||
|
||||
it("returns 400 when run id sanitizes to an empty document key", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ runId: "!!!" }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/!!!/findings/finding-1/task",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("Invalid run id for research document key");
|
||||
});
|
||||
|
||||
it("returns 400 when create payload priority is invalid", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore() as any));
|
||||
@@ -55,11 +273,81 @@ describe("research-routes", () => {
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/create-task",
|
||||
JSON.stringify({ includeSummary: true, includeCitations: true }),
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ priority: "p0" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.task.id).toBe("FN-1");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("priority must be one of");
|
||||
});
|
||||
|
||||
it("returns 400 when create payload attachExport is not boolean", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore() as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ attachExport: "yes" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("attachExport must be a boolean");
|
||||
});
|
||||
|
||||
it("returns 400 when enrich payload attachExport is not boolean", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore() as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
|
||||
JSON.stringify({ attachExport: "yes" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("attachExport must be a boolean");
|
||||
});
|
||||
|
||||
it("returns 400 when attachment exceeds size limit", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ addAttachmentError: "File too large: max 5242880 bytes" }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ attachExport: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("File too large");
|
||||
});
|
||||
|
||||
it("returns 400 when attachment mime type is invalid", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore({ addAttachmentError: "Invalid mime type: text/plain" }) as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ attachExport: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("Invalid mime type");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RESEARCH_SOURCE_TYPES,
|
||||
RESEARCH_SOURCE_STATUSES,
|
||||
RESEARCH_EVENT_TYPES,
|
||||
buildResearchDocumentKey,
|
||||
type ResearchRunListOptions,
|
||||
type ResearchRunStatus,
|
||||
} from "@fusion/core";
|
||||
@@ -18,17 +19,6 @@ const DEFAULT_AVAILABILITY = {
|
||||
supportedExportFormats: ["markdown", "json", "html"],
|
||||
} as const;
|
||||
|
||||
function unavailableResponse(reason: string, code: "unavailable" | "not-configured" | "feature-disabled" = "unavailable") {
|
||||
return {
|
||||
availability: {
|
||||
available: false,
|
||||
code,
|
||||
reason,
|
||||
setupInstructions: "Enable the research subsystem and provider integrations to use this endpoint.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rethrowAsApiError(error: unknown, fallback = "Internal server error"): never {
|
||||
if (error instanceof ApiError) throw error;
|
||||
if (error instanceof Error) throw new ApiError(500, error.message);
|
||||
@@ -60,6 +50,79 @@ function toRunDetail(run: ResearchRun) {
|
||||
};
|
||||
}
|
||||
|
||||
function getFindingId(finding: NonNullable<ResearchRun["results"]>["findings"][number], index: number): string {
|
||||
const maybeFinding = finding as { id?: unknown };
|
||||
const explicitId = typeof maybeFinding.id === "string" ? maybeFinding.id.trim() : "";
|
||||
return explicitId || `finding-${index + 1}`;
|
||||
}
|
||||
|
||||
function getFindingById(run: ResearchRun, findingId: string) {
|
||||
const findings = run.results?.findings ?? [];
|
||||
for (const [index, finding] of findings.entries()) {
|
||||
if (getFindingId(finding, index) === findingId) {
|
||||
return { finding, findingId };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildFindingTaskSummary(run: ResearchRun, finding: NonNullable<ResearchRun["results"]>["findings"][number]): string {
|
||||
const heading = finding.heading?.trim() || "Research finding";
|
||||
const content = finding.content?.trim() || "";
|
||||
const firstSentence = content.split(/(?<=[.!?])\s+/)[0]?.trim() || content;
|
||||
const scope = run.topic || run.query;
|
||||
return `${heading} — ${firstSentence || "Review cited research details."}\n\nContext: ${scope}`;
|
||||
}
|
||||
|
||||
function buildFindingMarkdown(run: ResearchRun, findingId: string, finding: NonNullable<ResearchRun["results"]>["findings"][number]): string {
|
||||
const citations = (finding.sources ?? []).map((source) => `- ${source}`).join("\n");
|
||||
const runSummary = run.results?.summary?.trim();
|
||||
return [
|
||||
`# Research Finding`,
|
||||
``,
|
||||
`- Run ID: ${run.id}`,
|
||||
`- Finding ID: ${findingId}`,
|
||||
`- Query: ${run.query}`,
|
||||
``,
|
||||
`## ${finding.heading || "Finding"}`,
|
||||
finding.content || "",
|
||||
runSummary ? `\n## Run Summary\n${runSummary}` : "",
|
||||
citations ? `\n## Citations\n${citations}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function validateAttachExport(value: unknown): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (typeof value !== "boolean") {
|
||||
throw badRequest("attachExport must be a boolean");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAttachmentValidationError(error: unknown): error is Error {
|
||||
return error instanceof Error
|
||||
&& (error.message.startsWith("Invalid mime type") || error.message.startsWith("File too large"));
|
||||
}
|
||||
|
||||
async function addFindingAttachment(
|
||||
scopedStore: TaskStore,
|
||||
taskId: string,
|
||||
filename: string,
|
||||
markdown: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const attachment = await scopedStore.addAttachment(taskId, filename, Buffer.from(markdown, "utf8"), "text/markdown");
|
||||
return attachment.filename;
|
||||
} catch (error) {
|
||||
if (isAttachmentValidationError(error)) {
|
||||
throw badRequest(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createResearchRouter(store: TaskStore): Router {
|
||||
const router = Router();
|
||||
const requestContext = new AsyncLocalStorage<TaskStore>();
|
||||
@@ -184,58 +247,153 @@ export function createResearchRouter(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/runs/:id/create-task", async (req, res) => {
|
||||
router.post("/runs/:runId/findings/:findingId/task", async (req, res) => {
|
||||
try {
|
||||
const run = getStore().getRun(req.params.id);
|
||||
if (!run) throw notFound(`Run not found: ${req.params.id}`);
|
||||
const includeSummary = req.body?.includeSummary !== false;
|
||||
const includeCitations = req.body?.includeCitations !== false;
|
||||
const summary = includeSummary ? run.results?.summary ?? "" : "";
|
||||
const citations = includeCitations ? (run.results?.citations ?? []).map((c) => `- ${c}`).join("\n") : "";
|
||||
const description = [summary, citations].filter(Boolean).join("\n\n");
|
||||
const scopedStore = requestContext.getStore();
|
||||
if (!scopedStore) throw new ApiError(500, "Task store context unavailable");
|
||||
|
||||
const run = getStore().getRun(req.params.runId);
|
||||
if (!run) throw notFound(`Run not found: ${req.params.runId}`);
|
||||
const found = getFindingById(run, req.params.findingId);
|
||||
if (!found) throw notFound(`Finding not found: ${req.params.findingId}`);
|
||||
|
||||
let documentKey: string;
|
||||
try {
|
||||
documentKey = buildResearchDocumentKey(req.params.runId);
|
||||
} catch {
|
||||
throw badRequest("Invalid run id for research document key");
|
||||
}
|
||||
|
||||
const title = typeof req.body?.title === "string" && req.body.title.trim()
|
||||
? req.body.title.trim()
|
||||
: `Research: ${found.finding.heading || run.topic || run.query}`;
|
||||
const description = typeof req.body?.description === "string" && req.body.description.trim()
|
||||
? req.body.description.trim()
|
||||
: buildFindingTaskSummary(run, found.finding);
|
||||
const priority = req.body?.priority;
|
||||
if (priority !== undefined && !["low", "normal", "high", "urgent"].includes(priority)) {
|
||||
throw badRequest("priority must be one of: low, normal, high, urgent");
|
||||
}
|
||||
const attachExport = validateAttachExport(req.body?.attachExport);
|
||||
|
||||
const taskInput: TaskCreateInput = {
|
||||
title: req.body?.title || `Research: ${run.topic || run.query}`,
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
source: {
|
||||
sourceType: "research",
|
||||
sourceRunId: run.id,
|
||||
sourceMetadata: {
|
||||
runId: run.id,
|
||||
findingId: found.findingId,
|
||||
findingLabel: found.finding.heading,
|
||||
documentKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
const task = await requestContext.getStore()!.createTask(taskInput);
|
||||
res.json({ task: { id: task.id, title: task.title } });
|
||||
|
||||
const task = await scopedStore.createTask(taskInput);
|
||||
const markdown = buildFindingMarkdown(run, found.findingId, found.finding);
|
||||
await scopedStore.upsertTaskDocument(task.id, {
|
||||
key: documentKey,
|
||||
content: markdown,
|
||||
author: "research",
|
||||
metadata: {
|
||||
runId: run.id,
|
||||
findingId: found.findingId,
|
||||
findingLabel: found.finding.heading,
|
||||
},
|
||||
});
|
||||
if (typeof scopedStore.appendAgentLog === "function") {
|
||||
await scopedStore.appendAgentLog(
|
||||
task.id,
|
||||
`Task created from research finding ${found.findingId} in run ${run.id}`,
|
||||
"text",
|
||||
"research-task-integration",
|
||||
"executor",
|
||||
);
|
||||
}
|
||||
|
||||
let attachmentFilename: string | undefined;
|
||||
if (attachExport) {
|
||||
const filename = `${run.id}-${found.findingId}.md`;
|
||||
const existing = await scopedStore.getTask(task.id);
|
||||
if (!existing.attachments?.some((attachment) => attachment.originalName === filename)) {
|
||||
attachmentFilename = await addFindingAttachment(scopedStore, task.id, filename, markdown);
|
||||
}
|
||||
}
|
||||
|
||||
const responseTask = await scopedStore.getTask(task.id);
|
||||
res.status(201).json({ task: responseTask, documentKey, attachmentFilename });
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to create task from research run");
|
||||
if (error instanceof ApiError) {
|
||||
res.status(error.statusCode).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to create task from research finding";
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/runs/:id/attach-task", async (req, res) => {
|
||||
router.post("/runs/:runId/findings/:findingId/tasks/:taskId/enrich", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = requestContext.getStore();
|
||||
if (!scopedStore) {
|
||||
res.status(501).json(unavailableResponse("Task store context unavailable"));
|
||||
return;
|
||||
if (!scopedStore) throw new ApiError(500, "Task store context unavailable");
|
||||
|
||||
const run = getStore().getRun(req.params.runId);
|
||||
if (!run) throw notFound(`Run not found: ${req.params.runId}`);
|
||||
const found = getFindingById(run, req.params.findingId);
|
||||
if (!found) throw notFound(`Finding not found: ${req.params.findingId}`);
|
||||
|
||||
const task = await scopedStore.getTask(req.params.taskId);
|
||||
if (!task) throw notFound(`Task not found: ${req.params.taskId}`);
|
||||
if (task.column === "archived") throw new ApiError(409, "Cannot enrich archived task");
|
||||
|
||||
let documentKey: string;
|
||||
try {
|
||||
documentKey = buildResearchDocumentKey(req.params.runId);
|
||||
} catch {
|
||||
throw badRequest("Invalid run id for research document key");
|
||||
}
|
||||
|
||||
const run = getStore().getRun(req.params.id);
|
||||
if (!run) throw notFound(`Run not found: ${req.params.id}`);
|
||||
const taskId = String(req.body?.taskId ?? "").trim();
|
||||
const mode = req.body?.mode;
|
||||
if (!taskId) throw badRequest("taskId is required");
|
||||
if (mode !== "document" && mode !== "attachment") throw badRequest("mode must be 'document' or 'attachment'");
|
||||
const markdown = buildFindingMarkdown(run, found.findingId, found.finding);
|
||||
const document = await scopedStore.upsertTaskDocument(task.id, {
|
||||
key: documentKey,
|
||||
content: markdown,
|
||||
author: "research",
|
||||
metadata: {
|
||||
runId: run.id,
|
||||
findingId: found.findingId,
|
||||
findingLabel: found.finding.heading,
|
||||
},
|
||||
});
|
||||
|
||||
const markdown = `# Research Findings\n\n## Query\n${run.query}\n\n## Summary\n${run.results?.summary ?? ""}\n\n## Citations\n${(run.results?.citations ?? []).map((c) => `- ${c}`).join("\n")}`;
|
||||
|
||||
if (mode === "document") {
|
||||
const document = await scopedStore.upsertTaskDocument(taskId, { key: `research-${run.id.toLowerCase()}`, content: markdown });
|
||||
res.json({ task: { id: taskId }, documentKey: document.key });
|
||||
return;
|
||||
const attachExport = validateAttachExport(req.body?.attachExport);
|
||||
let attachmentFilename: string | undefined;
|
||||
if (attachExport) {
|
||||
const filename = `${run.id}-${found.findingId}.md`;
|
||||
if (!task.attachments?.some((attachment) => attachment.originalName === filename)) {
|
||||
attachmentFilename = await addFindingAttachment(scopedStore, task.id, filename, markdown);
|
||||
}
|
||||
}
|
||||
|
||||
const attachment = await scopedStore.addAttachment(
|
||||
taskId,
|
||||
`${run.id}.txt`,
|
||||
Buffer.from(markdown, "utf8"),
|
||||
"text/plain",
|
||||
);
|
||||
res.json({ task: { id: taskId }, attachmentName: attachment.filename });
|
||||
if (typeof scopedStore.appendAgentLog === "function") {
|
||||
await scopedStore.appendAgentLog(
|
||||
task.id,
|
||||
`Task enriched from research finding ${found.findingId} in run ${run.id}`,
|
||||
"text",
|
||||
"research-task-integration",
|
||||
"executor",
|
||||
);
|
||||
}
|
||||
res.json({ taskId: task.id, documentKey, revision: document.revision, attachmentFilename });
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to attach research findings to task");
|
||||
if (error instanceof ApiError) {
|
||||
res.status(error.statusCode).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to enrich task from research finding";
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user