增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-17 18:16:05 +08:00
1 parent 47faa9bcdf
commit 771957429a
36 files changed
+2038 -300

No files matched your search

+6
View File
@@ -93,6 +93,12 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
deploy
.history
+7 -3
View File
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
@@ -50,6 +50,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -90,7 +94,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -6,6 +6,7 @@ import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = {
id: string;
@@ -74,10 +75,7 @@ const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
return createClientId("drw-ui");
}
export function DocumentRewritePresentationProvider({
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const assistantId = createClientId("sentiment-ai");
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
id: createClientId("sentiment-human"),
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
runId: createClientId("run"),
messages: history,
signal: controller.signal,
handlers: {
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
@@ -77,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.single-round-rag.";
@@ -277,7 +278,7 @@ function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[]
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -0,0 +1,289 @@
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
export interface EightBfTask {
id: string;
externalTaskId: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
taskPriority: string;
targetDirection: string;
taskStatus: string;
source: string;
raw: Record<string, unknown> | null;
}
export interface EightBfDictionaryOption {
label: string;
value: string;
alias?: string;
statusTone?: string;
}
export interface EightBfTaskDictionaries {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
}
export interface EightBfTaskContextPayload {
kind: "eightbf_roundtable_task";
usage: string;
task_id: string;
task: {
id: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
task_priority: string;
target_direction: string;
task_status: string;
};
task_tags: {
priority: string;
direction: string;
status: string;
};
}
const TASK_DETAIL_PATH = "/task/{taskId}";
const DICTIONARY_TYPES = {
priority: "task_priority",
direction: "task_direction",
status: "task_status",
} as const;
function externalApiBase(): string {
// The position-roundtable Vue app calls this task system through
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
return (
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
getBackendBaseURL().replace(/\/+$/, "")
);
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
*
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
* DeerFlow 当前用户。
*/
async function fetchExternalTaskSystem(
path: string,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Authorization")) {
headers.set("Authorization", "Bearer admin");
}
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
return fetch(externalUrl(path), {
...init,
credentials: init.credentials ?? "include",
headers,
});
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function unwrapData(payload: unknown): Record<string, unknown> {
let current: unknown = payload;
// Task services in different deployments wrap the object as either
// { data: task } or { data: { data: task } }.
for (let index = 0; index < 3; index += 1) {
if (!current || typeof current !== "object" || Array.isArray(current)) break;
const next = (current as { data?: unknown }).data;
if (!next || typeof next !== "object" || Array.isArray(next)) break;
current = next;
}
return current && typeof current === "object" && !Array.isArray(current)
? (current as Record<string, unknown>)
: {};
}
export function normalizeEightBfTask(
raw: Record<string, unknown> | null | undefined,
fallbackTaskId = "",
): EightBfTask {
const source = raw ?? {};
const id = firstText(
source.id,
source.taskId,
source.externalTaskId,
source.external_task_id,
fallbackTaskId,
);
return {
id,
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
title: firstText(source.title, source.taskName, source.task_name, source.name),
description: firstText(
source.description,
source.taskDescription,
source.task_description,
source.taskContent,
source.task_content,
source.content,
),
direction: firstText(
source.direction,
source.taskDirection,
source.task_direction,
),
overview: firstText(source.overview, source.summary),
priority: firstText(source.priority, source.urgency),
taskPriority: firstText(
source.taskPriority,
source.task_priority,
source.priority,
source.urgency,
),
targetDirection: firstText(
source.targetDirection,
source.target_direction,
source.direction,
source.taskDirection,
source.task_direction,
),
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
source: "测试分析岗任务详情接口",
raw: source,
};
}
function dictionaryRows(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const directData = (payload as { data?: unknown }).data;
if (Array.isArray(directData)) return directData;
if (directData && typeof directData === "object") {
const nested = directData as { rows?: unknown; list?: unknown };
if (Array.isArray(nested.rows)) return nested.rows;
if (Array.isArray(nested.list)) return nested.list;
}
}
const data = unwrapData(payload);
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.list)) return data.list;
return [];
}
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
return dictionaryRows(payload)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const label = firstText(row.dictLabel, row.label);
const value = firstText(row.dictValue, row.dict_value, row.value);
if (!label || !value) return null;
return {
label,
value,
alias: firstText(row.alias) || undefined,
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
} as EightBfDictionaryOption;
})
.filter((item): item is EightBfDictionaryOption => item !== null);
}
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
const encodedId = encodeURIComponent(taskId);
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
const response = await fetchExternalTaskSystem(path, { method: "GET" });
if (!response.ok) {
throw new Error(`任务详情接口请求失败(${response.status})`);
}
const payload = (await response.json()) as unknown;
const task = normalizeEightBfTask(unwrapData(payload), taskId);
// Keep the external route id as the stable scope even if a deployment's
// response includes a legacy/internal numeric id.
return { ...task, id: taskId, externalTaskId: taskId };
}
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
const entries = await Promise.all(
(Object.entries(DICTIONARY_TYPES) as Array<[
keyof EightBfTaskDictionaries,
string,
]>).map(async ([key, path]) => {
try {
const response = await fetchExternalTaskSystem(
`/system/dict/data/type/${path}`,
{ method: "GET" },
);
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
return [key, normalizeDictionary(await response.json())] as const;
} catch (error) {
// A dictionary failure must not hide the task card; the card falls back
// to the raw task codes and the detail request remains usable.
console.warn(`[8bf] ${key} 字典加载失败`, error);
return [key, []] as const;
}
}),
);
return Object.fromEntries(entries) as EightBfTaskDictionaries;
}
export function dictionaryText(
options: EightBfDictionaryOption[] | undefined,
value: unknown,
preferredField: "label" | "alias" = "label",
): string {
const raw = firstText(value);
if (!raw) return "";
const match = options?.find((option) => option.value === raw);
return firstText(match?.[preferredField], match?.label, raw);
}
export function buildEightBfTaskContext(
task: EightBfTask | null,
dictionaries: EightBfTaskDictionaries,
): EightBfTaskContextPayload | null {
if (!task?.id) return null;
return {
kind: "eightbf_roundtable_task",
usage:
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
task_id: task.id,
task: {
id: task.id,
title: task.title,
description: task.description,
direction: task.direction,
overview: task.overview,
priority: task.priority,
task_priority: task.taskPriority,
target_direction: task.targetDirection,
task_status: task.taskStatus,
},
task_tags: {
priority: dictionaryText(dictionaries.priority, task.taskPriority),
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
status: dictionaryText(dictionaries.status, task.taskStatus),
},
};
}
@@ -0,0 +1,58 @@
import { Clipboard, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
dictionaryText,
type EightBfDictionaryOption,
type EightBfTask,
} from "@/eightbf-roundtable/api/task-context";
interface EightBfTaskCardProps {
task: EightBfTask | null;
dictionaries: {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
};
loading?: boolean;
error?: string | null;
}
export function EightBfTaskCard({
task,
dictionaries,
loading = false,
error,
}: EightBfTaskCardProps) {
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
const status = dictionaryText(dictionaries.status, task?.taskStatus);
const tags = [priority, direction, status].filter(Boolean);
return (
<section className="eightbf-roundtable__task-card" aria-labelledby="eightbf-task-card-title">
<div className="eightbf-roundtable__task-card-heading">
<span className="eightbf-roundtable__task-card-icon" aria-hidden="true">
<Clipboard className="h-3.5 w-3.5" strokeWidth={2.5} />
</span>
<h2 id="eightbf-task-card-title">当前任务</h2>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-label="正在加载任务" />}
</div>
<div className="eightbf-roundtable__task-card-body">
<p className="eightbf-roundtable__task-card-title">
{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}
</p>
{tags.length > 0 && (
<div className="eightbf-roundtable__task-card-tags" aria-label="任务标签">
{tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
)}
<p className={cn("eightbf-roundtable__task-card-description", !task?.description && "is-empty")}>
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
</p>
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
</div>
</section>
);
}
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
buildEightBfTaskContext,
fetchEightBfTask,
fetchEightBfTaskDictionaries,
normalizeEightBfTask,
type EightBfTask,
type EightBfTaskDictionaries,
} from "@/eightbf-roundtable/api/task-context";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
@@ -26,10 +36,28 @@ const BUSINESS_CODE = "8BF";
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
() => searchParams.get("taskId")?.trim() || "",
[searchParams],
);
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const storageTaskId = useMemo(
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
[auth?.user_id, taskId],
);
const [task, setTask] = useState<EightBfTask | null>(() => (
taskId ? normalizeEightBfTask({}, taskId) : null
));
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
priority: [],
direction: [],
status: [],
});
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
const [taskError, setTaskError] = useState<string | null>(null);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -41,6 +69,53 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const taskContext = useMemo(
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
useEffect(() => {
if (!taskId) {
setTask(null);
setTaskError(null);
setTaskLoading(false);
setTaskDictionaries({ priority: [], direction: [], status: [] });
return;
}
let cancelled = false;
setTaskLoading(true);
setTaskError(null);
setTask(normalizeEightBfTask({}, taskId));
let pendingRequests = 2;
const markRequestFinished = () => {
pendingRequests -= 1;
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
};
// Keep detail and dictionary requests independent: a missing dictionary
// must not prevent the task card or task context from being available.
void fetchEightBfTask(taskId)
.then((nextTask) => {
if (!cancelled) setTask(nextTask);
})
.catch((error) => {
if (cancelled) return;
setTaskError(error instanceof Error ? error.message : "任务详情加载失败");
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
.then((nextDictionaries) => {
if (!cancelled) setTaskDictionaries(nextDictionaries);
})
.catch(() => {
// fetchEightBfTaskDictionaries already degrades each dictionary to an
// empty list; this guard protects future adapter changes.
})
.finally(markRequestFinished);
return () => {
cancelled = true;
};
}, [taskId]);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -162,6 +237,16 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
taskContext={taskContext as Record<string, unknown> | null}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
toolbarSlot={toolbarSlot}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
@@ -27,4 +27,100 @@
min-height: 460px;
}
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
border: 1px solid #d9e5ef;
border-radius: 16px;
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
padding: 14px;
box-shadow: 0 4px 20px rgb(15 23 42 / 3%);
}
.eightbf-roundtable .eightbf-roundtable__task-card-heading {
display: flex;
align-items: center;
gap: 8px;
color: #1e293b;
font-size: 13px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child {
margin-left: auto;
color: #0f766e;
}
.eightbf-roundtable .eightbf-roundtable__task-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 7px;
background: #0f766e;
color: #fff;
}
.eightbf-roundtable .eightbf-roundtable__task-card-body {
min-width: 0;
margin-top: 11px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 700;
line-height: 1.55;
overflow-wrap: anywhere;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border: 1px solid #c7e6df;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
padding: 2px 7px;
font-size: 10px;
line-height: 1.4;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
display: -webkit-box;
margin: 9px 0 0;
color: #475569;
font-size: 11px;
line-height: 1.65;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card {
border-color: rgb(92 156 219 / 45%);
background: linear-gradient(145deg, #0b315d 0%, #08264b 100%);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading,
.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; }
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import {
type DomainAgentMapping,
normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping {
return {
id:
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
id: createClientId(),
domain: "",
agentId: "",
enabled: true,
@@ -6,6 +6,7 @@
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
/**
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
return createClientId().replace(/-/g, "");
}
/**
@@ -24,6 +24,7 @@ import {
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import { createClientId } from "@/lib/client-id";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const clientTurnId = createClientId();
const res = await streamIntent({
threadId: intentThreadId,
@@ -298,6 +298,12 @@ export default function RoundtablePlanningPage({
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
entryRailTaskCardSlot,
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
taskContext,
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
}: {
@@ -307,6 +313,9 @@ export default function RoundtablePlanningPage({
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
entryRailTaskCardSlot?: ReactNode;
taskContext?: Record<string, unknown> | null;
entryRailTaskId?: string | null;
rootClassName?: string;
}) {
const { theme } = useTheme();
@@ -315,6 +324,10 @@ export default function RoundtablePlanningPage({
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
const inWujieEmbed = isWujieEnv();
@@ -542,6 +555,38 @@ export default function RoundtablePlanningPage({
}>({ intentReady: null, interactiveMessages: [] });
const buildInitialIntent = useCallback((): string => {
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
const taskTags = taskContextRef.current?.task_tags as
| Record<string, unknown>
| undefined;
const taskValue = (value: unknown) =>
value === undefined || value === null ? "" : String(value).trim();
const taskLines = task
? [
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
["任务名称", taskValue(task.title)],
["任务描述", taskValue(task.description)],
["任务概览", taskValue(task.overview)],
["任务方向", taskValue(task.direction ?? task.target_direction)],
["优先级", taskValue(task.task_priority ?? task.priority)],
["任务状态", taskValue(task.task_status)],
["优先级标签", taskValue(taskTags?.priority)],
["方向标签", taskValue(taskTags?.direction)],
["状态标签", taskValue(taskTags?.status)],
]
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
: [];
const taskCardContext = taskLines.length
? [
"【8BF 任务卡片上下文】",
...taskLines,
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
"",
].join("\n")
: "";
const withTaskCardContext = (value: string) =>
taskCardContext ? `${taskCardContext}${value}` : value;
const { intentReady, interactiveMessages } = step1StateRef.current;
if (intentReady) {
const parts: string[] = [intentReady.objective];
@@ -559,17 +604,17 @@ export default function RoundtablePlanningPage({
parts.push(
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
);
return parts.join("\n");
return withTaskCardContext(parts.join("\n"));
}
const userTurns = interactiveMessages
.filter((m) => m.sender === "user")
.map((m) => m.content?.trim())
.filter((s): s is string => !!s);
if (userTurns.length === 0) {
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
}
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
}, []);
const step2 = useStep2Orchestration({
@@ -593,6 +638,7 @@ export default function RoundtablePlanningPage({
currentStep,
selectedModel,
triggerToast,
taskContext,
});
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
@@ -2176,12 +2222,13 @@ export default function RoundtablePlanningPage({
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
historyTaskId={draftStorageTaskId}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
/>
{/* MIDDLE VIEWPORT */}
@@ -2743,6 +2790,8 @@ interface LeftSidebarProps {
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2794,6 +2843,7 @@ function LeftSidebar({
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
entryRailTaskCardSlot,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2937,6 +2987,7 @@ function LeftSidebar({
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
taskCardSlot={entryRailTaskCardSlot}
/>
)}
</section>
@@ -2992,6 +3043,7 @@ function RoundtableEntryRail({
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
taskCardSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -3007,6 +3059,7 @@ function RoundtableEntryRail({
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
taskCardSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3093,7 +3146,12 @@ function RoundtableEntryRail({
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
{taskCardSlot ? (
<>
{taskCardSlot}
{lockBusinessChain && businessChainSlot}
</>
) : deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
@@ -17,6 +17,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import type {
DocumentRewriteSummary,
DocumentRewriteStreamEvent,
@@ -355,7 +356,7 @@ export async function startJob(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: opts.requestId ?? crypto.randomUUID(),
request_id: opts.requestId ?? createClientId(),
entry: opts.entry ?? "legacy",
config: opts.config ?? null,
}),
@@ -1147,6 +1147,10 @@ Automatic conversation summarization when approaching token limits:
the config flag. `config.summarization.enabled` now defaults to **false**.
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
(tokens, messages, or fraction of max input) and the keep policy.
- A compressed request must retain the latest non-empty `HumanMessage`, even
when it has already been folded into the hidden summary. This keeps strict
OpenAI-compatible gateways from rejecting a tool-continuation call with
`No user query found in message`.
- Keeps recent messages while summarizing older ones.
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
@@ -1284,6 +1288,31 @@ The per-user Deep Research concurrency guard counts only session statuses
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
read/write splitting. Deep Research session/job/event/source/message writes
must serialise their changed row while the write transaction is still open
(`flush` for inserts, select-after-update **before** commit), never through a
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
lose its snapshot, or can fail report/follow-up persistence after the model has
already completed. Always nudge the dispatcher for both a newly created job and
an idempotently reused queued job; otherwise a retry after such an interrupted
response returns the old job but leaves it waiting for the periodic scan. The
SSE replay generator must catch temporary event/job-store reads, emit a
recoverable warning, and keep the connection open so the next poll can repair
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
`test_job_create_never_reads_back_after_commit`,
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
At ordinary report completion, persist and confirm the session's
`report_markdown` projection **before** marking the Job `completed`. Retry a
short transient projection failure; if it cannot be confirmed, let the job
become failed rather than reporting a report that vanishes on refresh.
`test_report_is_persisted_before_job_is_marked_completed` and
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
The chat-collection frontend must keep report-writing progress in the existing
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
virtual `deep_research_progress` tool calls for the durable phases, keep the
+1 -1
View File
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
@@ -61,6 +61,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
_LEASE_TTL_SECONDS = 60
_HEARTBEAT_INTERVAL = 20.0
_CANCEL_POLL_INTERVAL = 5.0
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
# Phase → progress% mapping for the job timeline.
_PHASE_PROGRESS: dict[str, int] = {
@@ -1372,7 +1374,17 @@ class DeepResearchJobExecutor:
async def _mark_completed(
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
) -> None:
# Job row → completed.
# Persist the report projection before exposing a terminal completed
# job. The session row is the durable report itself; marking the job
# complete first and suppressing a session-write failure can otherwise
# make a successfully generated report disappear after refresh.
await self._persist_completed_report_projection(
job_id=job_id,
session_id=session_id,
result=result,
)
# Job row → completed only after the report is durable.
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1385,26 +1397,62 @@ class DeepResearchJobExecutor:
"plan": result.plan,
},
)
# Session row → completed + report.
source_count = len(result.source_ids)
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research trace
# after a refresh; it does not affect billing or job state.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=source_count,
active_job_id=None,
)
# Emit a terminal event for SSE consumers.
with suppress(Exception): # noqa: BLE001
await self._emit_terminal(job_id, session_id, "job_completed")
async def _persist_completed_report_projection(
self,
*,
job_id: str,
session_id: str,
result: DeepResearchResult,
) -> None:
"""Durably save the report before publishing job completion.
A write can succeed while a short-lived MySQL proxy/readback hiccup
makes its caller fail. Retry the full owner-scoped update a few times;
if it still cannot be confirmed, propagate the error so ``_run`` marks
the job failed instead of advertising a report that cannot be reopened.
"""
last_error: Exception | None = None
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
try:
updated = await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research
# trace after a refresh; it does not affect billing.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=len(result.source_ids),
active_job_id=None,
)
if updated is not None:
return
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
logger.warning(
"deep-research report projection failed for %s (attempt %s/%s); retrying",
job_id,
attempt,
_REPORT_PROJECTION_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
raise RuntimeError(
f"研究报告未能保存到会话 {session_id},任务不会标记为完成"
) from last_error
async def _mark_awaiting(
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
) -> None:
@@ -639,17 +639,30 @@ async def start_job(session_id: str, body: StartJobRequest, request: Request) ->
input_snapshot=input_snapshot,
)
# Update session status.
await session_store.update(
session_id,
user_id=user_id,
status="running",
active_job_id=job["id"],
)
# The durable job is the source of truth once it has been committed. A
# transient failure updating the display projection must not turn that
# successful write into a misleading 500 response or leave it undispatched.
# The executor re-projects the terminal status when it completes.
try:
await session_store.update(
session_id,
user_id=user_id,
status="running",
active_job_id=job["id"],
)
except Exception: # noqa: BLE001
logger.exception(
"deep-research job %s was queued but its session running projection could not be updated",
job["id"],
)
# Nudge the dispatcher to claim immediately.
# Nudge the dispatcher even for an idempotent retry. If an earlier request
# committed the job but failed before this point (for example a transient
# MySQL read-after-write failure), the retry receives the existing queued
# job. Restricting the nudge to ``created`` would leave that durable job
# queued until the next periodic scan and make the UI appear stuck.
dispatcher = _get_dispatcher(request)
if dispatcher is not None and created:
if dispatcher is not None:
dispatcher.nudge()
return {
@@ -810,36 +823,68 @@ async def stream_job(
if loop.time() >= next_durable_poll:
# Durable replay repairs a missed live delta after
# reconnect or when another worker owned the job.
events = await event_store.list_after(job_id, after=cursor, limit=200)
for ev in events:
payload = {
"seq": ev["seq"],
"sessionId": session_id,
"jobId": job_id,
"type": ev["event_type"],
"phase": ev.get("phase", "initializing"),
"timestamp": _iso(ev.get("created_at")),
"payload": ev.get("payload") or {},
}
yield format_sse("progress", payload, event_id=str(ev["seq"]))
cursor = max(cursor, ev["seq"])
current = await job_store.get_unscoped(job_id)
if current and current.get("status") in TERMINAL_STATUSES:
try:
events = await event_store.list_after(job_id, after=cursor, limit=200)
for ev in events:
payload = {
"seq": ev["seq"],
"sessionId": session_id,
"jobId": job_id,
"type": ev["event_type"],
"phase": ev.get("phase", "initializing"),
"timestamp": _iso(ev.get("created_at")),
"payload": ev.get("payload") or {},
}
yield format_sse("progress", payload, event_id=str(ev["seq"]))
cursor = max(cursor, ev["seq"])
current = await job_store.get_unscoped(job_id)
if current and current.get("status") in TERMINAL_STATUSES:
yield format_sse(
"progress",
{
"seq": cursor + 1,
"sessionId": session_id,
"jobId": job_id,
"type": "job_status",
"phase": current.get("phase", "done"),
"timestamp": _iso(datetime.now(UTC)),
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
},
event_id=str(cursor + 1),
)
return
next_durable_poll = loop.time() + _POLL_INTERVAL_S
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
# Do not let one read-replica / transient DB failure
# abort the ASGI generator (which surfaces as an
# ExceptionGroup and leaves the page stuck). Live
# frames can still reach an attached browser, and the
# next durable poll repairs the cursor on recovery.
logger.warning(
"deep-research SSE durable replay temporarily failed for job %s; keeping stream open",
job_id,
exc_info=True,
)
yield format_sse(
"progress",
{
"seq": cursor + 1,
"seq": 0,
"liveId": f"durable-replay-retry:{job_id}:{int(loop.time() * 1000)}",
"sessionId": session_id,
"jobId": job_id,
"type": "job_status",
"phase": current.get("phase", "done"),
"type": "warning",
"phase": "initializing",
"timestamp": _iso(datetime.now(UTC)),
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
"payload": {
"code": "durable_replay_temporarily_unavailable",
"message": "进度同步暂时不可用,任务仍在继续;正在自动重试。",
"recoverable": True,
},
},
event_id=str(cursor + 1),
)
return
next_durable_poll = loop.time() + _POLL_INTERVAL_S
next_durable_poll = loop.time() + max(_POLL_INTERVAL_S, 3.0)
if live_queue is None:
await asyncio.sleep(max(0.01, next_durable_poll - loop.time()))
continue
@@ -195,17 +195,22 @@ def _intent_thread_metadata(
if not task_id:
return metadata
metadata.update(
{
"taskId": task_id,
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
metadata["taskId"] = task_id
# 8BF 使用通用意图接口但有明确的上下文 kind;它不能被岗位会商的
# 会话索引误认为岗位任务,否则两个页面的历史记录会串在一起。
# 其它历史调用保持原有 metadata 形状,避免影响既有会商恢复逻辑。
is_eightbf_context = task_context.get("kind") == "eightbf_roundtable_task"
if intent_agent_id == POSITION_INTENT_AGENT_ID or not is_eightbf_context:
metadata.update(
{
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
return metadata
@@ -48,6 +48,11 @@ class PersistingEventSink:
self._session_id = session_id
self._job_id = job_id
self._on_event = on_event
# A durable event row normally owns the sequence. When the event
# database is temporarily unavailable, use distinct negative sequence
# numbers for the live-only path so clients can render every milestone
# without advancing their durable ``after`` cursor.
self._fallback_seq = 0
async def emit(
self,
@@ -56,26 +61,66 @@ class PersistingEventSink:
phase: ResearchPhase = "initializing",
payload: dict | None = None,
) -> DeepResearchEvent:
row = await self._repo.append(
session_id=self._session_id,
job_id=self._job_id,
event_type=type,
phase=phase,
payload=payload or {},
)
# Reconstruct the envelope from the persisted row (seq is now assigned).
event = DeepResearchEvent(
seq=int(row.get("seq", 0)),
session_id=self._session_id,
job_id=self._job_id,
type=row.get("event_type", type),
phase=row.get("phase", phase),
payload=row.get("payload") or payload or {},
)
if self._on_event is not None:
await self._on_event(event.to_sse_dict())
event_payload = payload or {}
try:
row = await self._repo.append(
session_id=self._session_id,
job_id=self._job_id,
event_type=type,
phase=phase,
payload=event_payload,
)
# Reconstruct the envelope from the persisted row (seq is now assigned).
event = DeepResearchEvent(
seq=int(row.get("seq", 0)),
session_id=self._session_id,
job_id=self._job_id,
type=row.get("event_type", type),
phase=row.get("phase", phase),
payload=row.get("payload") or event_payload,
)
except Exception: # noqa: BLE001
# Progress persistence must not turn a recoverable database blip
# into a failed report. The job/status stores still record terminal
# state; this live-only frame lets an attached UI stay responsive
# until durable replay becomes available again after reconnect.
self._fallback_seq -= 1
logger.exception(
"deep-research event persistence failed; continuing job with a live-only event (job=%s, type=%s, phase=%s)",
self._job_id,
type,
phase,
)
event = DeepResearchEvent(
seq=self._fallback_seq,
session_id=self._session_id,
job_id=self._job_id,
type=type,
phase=phase,
payload={**event_payload, "eventPersistenceDegraded": True},
)
await self._publish_event(event)
return event
async def _publish_event(self, event: DeepResearchEvent) -> None:
"""Push a persisted or live-only event without risking the worker."""
if self._on_event is None:
return
wire = event.to_sse_dict()
if event.seq < 0:
wire["liveId"] = f"event-persistence-fallback:{self._job_id}:{abs(event.seq)}"
try:
await self._on_event(wire)
except Exception: # noqa: BLE001
# A browser disconnect or a live-hub issue must not abort report
# generation; persisted rows will still be replayable on reconnect.
logger.warning(
"deep-research live event publish failed; job continues (job=%s, seq=%s)",
self._job_id,
event.seq,
exc_info=True,
)
async def publish_live(
self,
type: str, # noqa: A002
@@ -85,16 +130,24 @@ class PersistingEventSink:
) -> None:
if self._on_event is None:
return
await self._on_event(
{
"seq": 0,
"sessionId": self._session_id,
"jobId": self._job_id,
"type": type,
"phase": phase,
"payload": payload or {},
}
)
try:
await self._on_event(
{
"seq": 0,
"sessionId": self._session_id,
"jobId": self._job_id,
"type": type,
"phase": phase,
"payload": payload or {},
}
)
except Exception: # noqa: BLE001
logger.warning(
"deep-research live frame publish failed; job continues (job=%s, type=%s)",
self._job_id,
type,
exc_info=True,
)
class NullEventSink:
@@ -27,14 +27,15 @@ from typing import Any, Protocol, override, runtime_checkable
from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import (
AIMessage,
AnyMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langgraph.config import get_config
from langgraph.runtime import Runtime
@@ -145,8 +146,8 @@ class _SummaryCacheEntry:
summarized_ids: frozenset[str]
summary_text: str
preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
# Process-wide summary cache shared across every middleware instance.
@@ -160,51 +161,99 @@ class _SummaryCacheEntry:
# so only the first threshold crossing (or a genuine re-compaction) pays for an
# LLM call. A lock guards the non-atomic LRU eviction against concurrent
# threads (subagents / parallel conversations share this process).
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only."""
with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only."""
with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
def _has_nonempty_human_message(message: AnyMessage) -> bool:
"""Whether a message is a real, non-empty user turn.
Some OpenAI-compatible gateways reject a completion request that contains
only ``system`` / ``assistant`` / ``tool`` roles. That can legitimately
happen after compaction: the active model call is continuing a tool turn,
while the user request that started the turn has already moved into the
internal summary.
"""
if not isinstance(message, HumanMessage):
return False
content = message.content
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(bool(block.strip()) if isinstance(block, str) else bool((block.get("text") or block.get("content") or "").strip()) if isinstance(block, dict) else False for block in content)
return bool(str(content).strip())
def _ensure_latest_human_message(
source_messages: list[AnyMessage],
compressed_messages: list[AnyMessage],
) -> list[AnyMessage]:
"""Keep the latest real user request visible to strict chat gateways.
The message is inserted after leading system messages, so it neither
exposes the internal summary to the UI nor splits an AI tool-call from its
following ToolMessages. When there was no user turn in the source at all,
do not fabricate one: that is an upstream caller bug and should remain
diagnosable rather than silently changing the task.
"""
if any(_has_nonempty_human_message(message) for message in compressed_messages):
return compressed_messages
latest_human = next(
(message for message in reversed(source_messages) if _has_nonempty_human_message(message)),
None,
)
if latest_human is None:
logger.warning("summarization: compressed model request has no user message and source history contains none; passing it through unchanged")
return compressed_messages
insert_at = 0
while insert_at < len(compressed_messages) and isinstance(compressed_messages[insert_at], SystemMessage):
insert_at += 1
return [*compressed_messages[:insert_at], latest_human, *compressed_messages[insert_at:]]
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
@@ -231,8 +280,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
# Points at the *process-wide* cache (not a fresh per-instance dict) so a
# summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``.
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
@override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
@@ -248,21 +297,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
"""Async counterpart of :meth:`before_model` — also a no-op."""
return None
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override
def wrap_model_call(
@@ -376,20 +425,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
thread_id = self._resolve_request_thread_id(request)
if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None
if cached is None:
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cached is not None and thread_id:
self._store_cache_entry(thread_id, cached)
present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None
if cached is None:
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cached is not None and thread_id:
self._store_cache_entry(thread_id, cached)
present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
effective = _ensure_latest_human_message(messages, effective)
# Decide re-summarization on the *counted* size of the compressed
# context only — NOT ``_should_summarize`` — because that folds in the
# last AIMessage's reported ``usage_metadata`` (system prompt + tool
@@ -400,8 +450,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Refresh LRU recency and reuse without an LLM call.
self._touch_cache_entry(thread_id)
logger.info(
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary "
"thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
thread_id,
self.token_counter(messages),
self.token_counter(effective),
@@ -415,7 +464,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# an "it compacts every turn" report is diagnosable straight from the
# backend log: cache=miss → thread_id/key problem; unusable → message-id
# instability; stale → preserved genuinely grew back over the trigger.
cache_state = "miss" if cached is None else "stale"
cache_state = "miss" if cached is None else "stale"
logger.info(
"summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d",
thread_id,
@@ -452,22 +501,23 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> list[AnyMessage]:
"""Assemble the compressed message list and update the per-thread cache."""
compressed = [self._summary_message(summary), *preserved]
compressed = _ensure_latest_human_message([*messages_to_summarize, *preserved], compressed)
if thread_id:
summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None)
preserved_ids = {m.id for m in preserved if m.id is not None}
self._store_cache_entry(
thread_id,
entry := _SummaryCacheEntry(
summarized_ids=summarized_ids,
summary_text=summary,
preserved_ids=frozenset(preserved_ids),
preserved_messages=tuple(preserved),
),
)
self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
self._store_cache_entry(
thread_id,
entry := _SummaryCacheEntry(
summarized_ids=summarized_ids,
summary_text=summary,
preserved_ids=frozenset(preserved_ids),
preserved_messages=tuple(preserved),
),
)
self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None:
"""Return the cached entry for ``thread_id`` (thread-safe read)."""
@@ -571,7 +621,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
return _resolve_thread_id(runtime)
def _summary_message(self, summary: str) -> SystemMessage:
def _summary_message(self, summary: str) -> SystemMessage:
"""Build the summary message handed to the model.
It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because
@@ -579,21 +629,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
state), the frontend never receives it — the user keeps seeing the
original conversation.
"""
return SystemMessage(
content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
return SystemMessage(
content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
name="summary",
additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"},
)
@override
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
"""Kept for backwards compatibility; delegates to :meth:`_summary_message`."""
return [self._summary_message(summary)]
@@ -68,13 +68,7 @@ class DeepResearchEventRepository:
"""
for attempt in range(max_retries):
async with self._sf() as session:
current_max = (
await session.execute(
select(func.max(DeepResearchEventRow.seq)).where(
DeepResearchEventRow.job_id == job_id
)
)
).scalar() or 0
current_max = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() or 0
next_seq = int(current_max) + 1
row = DeepResearchEventRow(
job_id=job_id,
@@ -86,49 +80,38 @@ class DeepResearchEventRepository:
)
session.add(row)
try:
# Do not issue ``session.refresh(row)`` after commit. On a
# MySQL read/write-splitting deployment that read can land
# on a lagging replica, which cannot see the just-committed
# event and raises ``Could not refresh instance``. Flush
# assigns generated primary-key/default values on the writer
# connection, so the plain dict is complete before commit.
await session.flush()
result = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return result
except IntegrityError:
await session.rollback()
continue # retry with recomputed max
raise RuntimeError(
f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})"
)
raise RuntimeError(f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})")
async def list_after(
self, job_id: str, *, after: int = 0, limit: int = 200
) -> list[dict[str, Any]]:
async def list_after(self, job_id: str, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]:
"""Events with ``seq > after`` in ascending order (SSE replay, §15.3)."""
async with self._sf() as session:
stmt = (
select(DeepResearchEventRow)
.where(DeepResearchEventRow.job_id == job_id)
.where(DeepResearchEventRow.seq > after)
.order_by(DeepResearchEventRow.seq.asc())
.limit(limit)
)
stmt = select(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id).where(DeepResearchEventRow.seq > after).order_by(DeepResearchEventRow.seq.asc()).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [self._row_to_dict(r) for r in rows]
async def last_seq(self, job_id: str) -> int:
async with self._sf() as session:
mx = (
await session.execute(
select(func.max(DeepResearchEventRow.seq)).where(
DeepResearchEventRow.job_id == job_id
)
)
).scalar()
mx = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar()
return int(mx or 0)
async def delete_by_job(self, job_id: str) -> int:
from sqlalchemy import delete
async with self._sf() as session:
result = await session.execute(
delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id)
)
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id))
await session.commit()
return result.rowcount
@@ -136,8 +119,6 @@ class DeepResearchEventRepository:
from sqlalchemy import delete
async with self._sf() as session:
result = await session.execute(
delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id)
)
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id))
await session.commit()
return result.rowcount
@@ -113,9 +113,18 @@ class DeepResearchJobRepository:
phase=phase,
)
session.add(row)
# Do not issue ``session.refresh(row)`` after commit. A
# MySQL read/write-splitting proxy can route that read to
# a lagging replica, even though the INSERT has already
# committed on the writer. The caller would receive 500
# while an invisible queued job remains in the database.
# ``flush`` materializes ORM defaults on the writer, so we
# can return a complete, durable snapshot without a
# replica-dependent readback.
await session.flush()
result = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
return result, True
except IntegrityError:
# Unique-index conflict → an active job already exists.
pass
@@ -230,13 +239,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── heartbeat ───────────────────────────────────────────────────────────
@@ -351,13 +361,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── two-phase cancel ────────────────────────────────────────────────────
@@ -385,13 +396,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def finalize_cancel(
self, job_id: str, *, lease_owner: str | None = None
@@ -419,13 +431,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── resume after human-review interrupt ─────────────────────────────────
@@ -460,13 +473,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── cleanup ─────────────────────────────────────────────────────────────
@@ -74,9 +74,13 @@ class DeepResearchMessageRepository:
seq=int(current_max) + 1,
)
session.add(row)
# Keep the successful append independent of a later replica read.
# The explicit id/seq and Python-side defaults are available after
# flush, before the transaction releases its writer connection.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
async def list_by_session(
self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100
@@ -139,10 +143,11 @@ class DeepResearchMessageRepository:
if not result.rowcount:
await session.rollback()
return None
await session.commit()
stmt = select(DeepResearchMessageRow).where(*where)
row = (await session.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row) if row is not None else None
snapshot = self._row_to_dict(row) if row is not None else None
await session.commit()
return snapshot
async def delete_by_session(self, session_id: str) -> int:
from sqlalchemy import delete
@@ -85,9 +85,13 @@ class DeepResearchSessionRepository:
runtime_thread_id=runtime_thread_id,
)
session.add(row)
# New sessions are a control-plane write. Avoid a post-commit
# refresh because MySQL read/write-splitting can make a just
# created session look absent on a lagging replica.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
# ── read ────────────────────────────────────────────────────────────────
@@ -187,13 +191,14 @@ class DeepResearchSessionRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def replace_report_if_unchanged(
self,
@@ -231,13 +236,14 @@ class DeepResearchSessionRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── delete ──────────────────────────────────────────────────────────────
@@ -23,6 +23,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class DeepResearchSourceRow(Base):
__tablename__ = "deep_research_sources"
__table_args__ = (
# MySQL requires a prefix length when indexing TEXT columns. The
# column remains unbounded so long URLs are preserved; the prefix is
# only an index implementation detail and equality filters still
# compare the complete URL value.
sa.Index("ix_deep_research_sources_url", "url", mysql_length=512),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
@@ -32,7 +39,7 @@ class DeepResearchSourceRow(Base):
# The query that first surfaced this material.
first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(256), nullable=False, default="")
@@ -37,9 +37,14 @@ class DeepResearchSourceRepository:
row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields)
session.add(row)
try:
# The source was produced on the worker's write path. Build
# its response on that same connection so a replica-lag read
# cannot turn a successfully collected source into a failed
# research run.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
except Exception:
await session.rollback()
# Fallback: find existing by rec_uuid or url.
@@ -147,13 +152,14 @@ class DeepResearchSourceRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def delete_by_session(self, session_id: str) -> int:
from sqlalchemy import delete
@@ -0,0 +1,83 @@
"""Fault-tolerance coverage for Deep Research progress events."""
import pytest
from deerflow.agents.deep_research.adapters.event_sink import PersistingEventSink
class _UnavailableEventRepository:
async def append(self, **kwargs): # noqa: ANN003
raise RuntimeError("database temporarily unavailable")
@pytest.mark.asyncio
async def test_event_persistence_failure_keeps_job_live_and_emits_unique_fallback_event():
published: list[dict] = []
async def publish(event: dict) -> None:
published.append(event)
sink = PersistingEventSink(
_UnavailableEventRepository(),
session_id="drs_1",
job_id="drj_1",
on_event=publish,
)
first = await sink.emit("phase_changed", phase="planning", payload={"to": "planning"})
second = await sink.emit("queries_planned", phase="collecting", payload={"queries": ["AI"]})
assert (first.seq, second.seq) == (-1, -2)
assert first.payload["eventPersistenceDegraded"] is True
assert published[0]["liveId"] == "event-persistence-fallback:drj_1:1"
assert published[1]["liveId"] == "event-persistence-fallback:drj_1:2"
@pytest.mark.asyncio
async def test_live_publish_failure_does_not_fail_event_delivery():
class _EventRepository:
async def append(self, **kwargs): # noqa: ANN003
return {
"seq": 1,
"event_type": kwargs["event_type"],
"phase": kwargs["phase"],
"payload": kwargs["payload"],
}
async def broken_publisher(event: dict) -> None: # noqa: ARG001
raise RuntimeError("browser disconnected")
sink = PersistingEventSink(
_EventRepository(),
session_id="drs_1",
job_id="drj_1",
on_event=broken_publisher,
)
event = await sink.emit("phase_changed", phase="planning")
assert event.seq == 1
@pytest.mark.asyncio
async def test_document_rewrite_milestone_is_a_supported_durable_event():
"""Guard the artifact-rewrite job path against EventType regressions."""
class _EventRepository:
async def append(self, **kwargs): # noqa: ANN003
return {
"seq": 1,
"event_type": kwargs["event_type"],
"phase": kwargs["phase"],
"payload": kwargs["payload"],
}
sink = PersistingEventSink(_EventRepository(), session_id="drs_1", job_id="drj_1")
event = await sink.emit(
"document_rewrite",
phase="planning",
payload={"type": "requirements_started"},
)
assert event.type == "document_rewrite"
@@ -0,0 +1,66 @@
"""Failure-ordering regression coverage for final Deep Research report writes."""
import pytest
from app.gateway import deep_research_job_executor as executor_module
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
from deerflow.agents.deep_research.types import DeepResearchResult
class _JobStore:
def __init__(self, session_store) -> None:
self._session_store = session_store
self.progress_calls: list[dict] = []
async def update_progress(self, job_id: str, **fields): # noqa: ARG002
assert self._session_store.report_updates, "report must be persisted before job completion"
self.progress_calls.append(fields)
return {"id": job_id, **fields}
class _SessionStore:
def __init__(self, *, available: bool = True) -> None:
self.available = available
self.report_updates: list[dict] = []
async def update(self, session_id: str, **fields): # noqa: ARG002
if not self.available:
raise RuntimeError("temporary session database failure")
self.report_updates.append(fields)
return {"id": session_id, **fields}
@pytest.mark.asyncio
async def test_report_is_persisted_before_job_is_marked_completed():
sessions = _SessionStore()
jobs = _JobStore(sessions)
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
await runner._mark_completed(
"drj_1",
"drs_1",
"worker_1",
DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]),
)
assert sessions.report_updates[0]["report_markdown"] == "# 报告"
assert jobs.progress_calls[0]["status"] == "completed"
@pytest.mark.asyncio
async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2)
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0)
sessions = _SessionStore(available=False)
jobs = _JobStore(sessions)
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
with pytest.raises(RuntimeError, match="研究报告未能保存"):
await runner._mark_completed(
"drj_1",
"drs_1",
"worker_1",
DeepResearchResult(report_markdown="# 报告"),
)
assert jobs.progress_calls == []
@@ -0,0 +1,538 @@
"""Persistence tests for deep-research sessions, jobs, events, sources, messages.
Uses in-memory SQLite + aiosqlite, mirroring ``test_ai_writing_session_repo``.
Focuses on the durable-job mechanics that are load-bearing for correctness:
- Session CRUD + user isolation.
- Job ``try_create_or_get_active`` idempotency (unique dedupe key).
- Job lease claim / renew / release (atomic CAS).
- Two-phase cancel (``request_cancel`` → ``finalize_cancel``).
- Event seq monotonicity + ``list_after`` replay (SSE reconnect correctness).
Pattern: a plain ``async def _make_repos()`` helper (not a fixture) — matches
the project convention for async DB setup in pytest-asyncio strict mode.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def _make_repos():
"""Create an in-memory SQLite repo set with all deep-research tables."""
from deerflow.persistence.base import Base
from deerflow.persistence.deep_research_events.model import DeepResearchEventRow # noqa: F401
from deerflow.persistence.deep_research_events.sql import DeepResearchEventRepository
from deerflow.persistence.deep_research_jobs.model import DeepResearchJobRow # noqa: F401
from deerflow.persistence.deep_research_jobs.sql import DeepResearchJobRepository
from deerflow.persistence.deep_research_messages.model import DeepResearchMessageRow # noqa: F401
from deerflow.persistence.deep_research_messages.sql import DeepResearchMessageRepository
from deerflow.persistence.deep_research_sessions.model import DeepResearchSessionRow # noqa: F401
from deerflow.persistence.deep_research_sessions.sql import DeepResearchSessionRepository
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow # noqa: F401
from deerflow.persistence.deep_research_sources.sql import DeepResearchSourceRepository
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
return {
"sessions": DeepResearchSessionRepository(sf),
"jobs": DeepResearchJobRepository(sf),
"events": DeepResearchEventRepository(sf),
"sources": DeepResearchSourceRepository(sf),
"messages": DeepResearchMessageRepository(sf),
"_engine": engine,
}
# ── sessions ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_session_create_and_get():
repos = await _make_repos()
try:
created = await repos["sessions"].create(id="drs_1", user_id="u1", title="Test", query="AI research", mode="deep")
assert created["id"] == "drs_1"
assert created["status"] == "draft"
got = await repos["sessions"].get("drs_1", user_id="u1")
assert got is not None
assert got["query"] == "AI research"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_deep_research_writes_never_depend_on_post_commit_refresh(monkeypatch):
"""Control-plane writes must survive a MySQL replica-lag readback."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("Deep Research writes must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
session = await repos["sessions"].create(
id="drs_1", user_id="u1", title="Test", query="AI research"
)
assert session["id"] == "drs_1"
assert await repos["sessions"].update("drs_1", user_id="u1", status="running")
job, created = await repos["jobs"].try_create_or_get_active(
id="drj_1", session_id="drs_1", user_id="u1"
)
assert created is True
assert job["id"] == "drj_1"
source = await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="A",
raw_content="source content",
source="web",
content_hash="hash_1",
)
assert source["id"] == "src_1"
message = await repos["messages"].append(
id="msg_1",
session_id="drs_1",
user_id="u1",
role="assistant",
content="已开始撰写",
)
assert message["id"] == "msg_1"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_user_isolation():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q1")
assert await repos["sessions"].get("drs_1", user_id="u2") is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_list_strips_heavy_columns():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", config_snapshot={"mode": "deep"})
items = await repos["sessions"].list_by_user(user_id="u1")
assert len(items) == 1
assert "report_markdown" not in items[0]
assert "config_snapshot" not in items[0]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_update_and_delete():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q")
updated = await repos["sessions"].update("drs_1", user_id="u1", title="New Title")
assert updated["title"] == "New Title"
assert await repos["sessions"].delete("drs_1", user_id="u1") is True
assert await repos["sessions"].get("drs_1", user_id="u1") is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_count_active_by_user():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", status="running")
await repos["sessions"].create(id="drs_2", user_id="u1", query="q", status="completed")
count = await repos["sessions"].count_active_by_user(user_id="u1")
assert count == 1
finally:
await repos["_engine"].dispose()
# ── jobs: idempotent create ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_try_create_returns_created_true():
repos = await _make_repos()
try:
job, created = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
assert created is True
assert job["status"] == "queued"
assert job["active_dedupe_key"] is not None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_create_never_reads_back_after_commit(monkeypatch):
"""A committed job must not fail when a MySQL replica briefly lags."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("DeepResearchJobRepository must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
job, created = await repos["jobs"].try_create_or_get_active(
id="drj_1",
session_id="drs_1",
user_id="u1",
request_id="req_1",
)
assert created is True
assert job["id"] == "drj_1"
assert job["status"] == "queued"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_try_create_idempotent_returns_existing():
repos = await _make_repos()
try:
job1, created1 = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1", request_id="req_2")
assert created1 is True
assert created2 is False
assert job2["id"] == job1["id"]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_dedupe_released_on_terminal():
repos = await _make_repos()
try:
job, _ = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
assert job["active_dedupe_key"] is not None
await repos["jobs"].update_progress("drj_1", lease_owner=None, user_id="u1", status="completed")
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1")
assert created2 is True
assert job2["id"] == "drj_2"
finally:
await repos["_engine"].dispose()
# ── jobs: lease mechanics ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_claim_succeeds_for_queued():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
claimed = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert claimed is not None
assert claimed["status"] == "running"
assert claimed["lease_owner"] == "w1"
assert claimed["attempt"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_dispatcher_claims_queued_job_and_starts_report_worker():
"""The claimed snapshot must reach the executor before the job can run."""
from app.gateway.deep_research_job_dispatcher import DeepResearchJobDispatcher
class _Executor:
def __init__(self) -> None:
self.started: list[tuple[dict, str, str | None]] = []
def start_job(self, snapshot: dict, *, job_id: str, lease_owner: str | None) -> bool:
self.started.append((snapshot, job_id, lease_owner))
return True
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(
id="drj_1",
session_id="drs_1",
user_id="u1",
input_snapshot={"query": "测试报告"},
)
runner = _Executor()
dispatcher = DeepResearchJobDispatcher(repos["jobs"], runner, worker_id="worker_1")
started = await dispatcher.dispatch_once()
assert started == 1
assert runner.started == [({"query": "测试报告"}, "drj_1", "worker_1")]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_claim_fails_if_already_claimed():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
first = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert first is not None
second = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=lease_until)
assert second is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_renew_lease_only_by_owner():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w1", lease_until=lease_until) is True
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w2", lease_until=lease_until) is False
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_expired_lease_reclaimable():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
expired = datetime.now(UTC) - timedelta(seconds=10)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=expired)
now = datetime.now(UTC)
claimable = await repos["jobs"].list_claimable(now=now)
assert "drj_1" in claimable
reclaimed = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=now + timedelta(seconds=60))
assert reclaimed is not None
assert reclaimed["lease_owner"] == "w2"
finally:
await repos["_engine"].dispose()
# ── jobs: two-phase cancel ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_cancel_two_phase():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
requested = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert requested is not None
assert requested["cancel_requested"] is True
finalized = await repos["jobs"].finalize_cancel("drj_1")
assert finalized is not None
assert finalized["status"] == "cancelled"
assert finalized["active_dedupe_key"] is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_cancel_idempotent():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
first = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert first is not None
await repos["jobs"].finalize_cancel("drj_1")
second = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert second is None
finally:
await repos["_engine"].dispose()
# ── jobs: progress update with lease gate ───────────────────────────────────
@pytest.mark.asyncio
async def test_job_update_progress_lease_gated():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
updated = await repos["jobs"].update_progress("drj_1", lease_owner="w1", phase="collecting", progress=50)
assert updated is not None
assert updated["phase"] == "collecting"
assert updated["progress"] == 50
rejected = await repos["jobs"].update_progress("drj_1", lease_owner="w2", phase="writing")
assert rejected is None
finally:
await repos["_engine"].dispose()
# ── events: monotonic seq + replay ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_event_seq_monotonic():
repos = await _make_repos()
try:
repo = repos["events"]
e1 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="phase_changed", payload={"to": "planning"})
e2 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s1"})
e3 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s2"})
assert e1["seq"] == 1
assert e2["seq"] == 2
assert e3["seq"] == 3
assert await repo.last_seq("drj_1") == 3
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_append_never_reads_back_after_commit(monkeypatch):
"""Event persistence must work when a post-commit read hits a lagging replica."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("DeepResearchEventRepository must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
event = await repos["events"].append(
session_id="drs_1",
job_id="drj_1",
event_type="phase_changed",
payload={"to": "planning"},
)
assert event["id"] is not None
assert event["seq"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_list_after_replay():
repos = await _make_repos()
try:
repo = repos["events"]
for i in range(5):
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat", payload={"i": i})
replayed = await repo.list_after("drj_1", after=2)
assert len(replayed) == 3
assert [e["seq"] for e in replayed] == [3, 4, 5]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_payload_round_trip():
repos = await _make_repos()
try:
repo = repos["events"]
await repo.append(
session_id="drs_1",
job_id="drj_1",
event_type="plan_created",
payload={"subtopics": ["AI", "ML"], "nested": {"a": 1}},
)
events = await repo.list_after("drj_1", after=0)
assert events[0]["payload"]["subtopics"] == ["AI", "ML"]
assert events[0]["payload"]["nested"]["a"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_seq_independent_per_job():
repos = await _make_repos()
try:
repo = repos["events"]
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
await repo.append(session_id="drs_1", job_id="drj_2", event_type="heartbeat")
assert await repo.last_seq("drj_1") == 2
assert await repo.last_seq("drj_2") == 1
finally:
await repos["_engine"].dispose()
# ── sources ─────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_source_upsert_and_list():
repos = await _make_repos()
try:
await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="Source A",
url="https://example.com/a",
raw_content="content A",
source="web",
source_type="web",
content_hash="hash_a",
)
items = await repos["sources"].list_by_session("drs_1", user_id="u1")
assert len(items) == 1
assert items[0]["title"] == "Source A"
assert "raw_content" not in items[0]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_source_set_selected():
repos = await _make_repos()
try:
await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="A",
raw_content="c",
source="web",
content_hash="h",
)
updated = await repos["sources"].set_selected("src_1", selected=True, reason="relevant", citation_key="1", user_id="u1")
assert updated["selected"] is True
assert updated["citation_key"] == "1"
finally:
await repos["_engine"].dispose()
# ── messages ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_message_append_and_list():
repos = await _make_repos()
try:
await repos["messages"].append(id="msg_1", session_id="drs_1", user_id="u1", role="user", content="question?")
await repos["messages"].append(
id="msg_2",
session_id="drs_1",
user_id="u1",
role="assistant",
content="answer",
citation_source_ids=["src_1"],
)
msgs = await repos["messages"].list_by_session("drs_1", user_id="u1")
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[1]["citation_source_ids"] == ["src_1"]
finally:
await repos["_engine"].dispose()
@@ -0,0 +1,156 @@
"""Regression coverage for idempotent Deep Research job submission."""
from types import SimpleNamespace
import pytest
from app.gateway.routers import deep_research
class _SessionStore:
def __init__(self) -> None:
self.updates: list[dict] = []
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"config_snapshot": {"mode": "basic"},
}
async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002
return 0
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
self.updates.append(fields)
return {"id": session_id, **fields}
class _JobStore:
async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002
return None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
# Simulate a retry after an earlier request committed the job but did
# not finish responding to the browser.
return {"id": "drj_existing", "status": "queued"}, False
class _Dispatcher:
def __init__(self) -> None:
self.nudges = 0
def nudge(self) -> None:
self.nudges += 1
def _request(*, sessions, jobs, dispatcher):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_session_store=sessions,
deep_research_job_store=jobs,
deep_research_dispatcher=dispatcher,
)
)
)
@pytest.mark.asyncio
async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch):
"""An idempotent retry must recover a queued-but-not-yet-dispatched job."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _SessionStore()
dispatcher = _Dispatcher()
request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher)
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
request,
)
assert response == {
"jobId": "drj_existing",
"sessionId": "drs_1",
"status": "queued",
"reused": True,
}
assert dispatcher.nudges == 1
assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}]
@pytest.mark.asyncio
async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch):
"""A committed job is more important than its non-authoritative UI projection."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ProjectionFailureSessionStore(_SessionStore):
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
raise RuntimeError("temporary database readback failure")
class _NewJobStore(_JobStore):
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
return {"id": "drj_new", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
dispatcher = _Dispatcher()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
_request(
sessions=_ProjectionFailureSessionStore(),
jobs=_NewJobStore(),
dispatcher=dispatcher,
),
)
assert response["jobId"] == "drj_new"
assert response["reused"] is False
assert dispatcher.nudges == 1
@pytest.mark.asyncio
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
"""A stream DB blip must yield a recoverable warning, not an ASGI error group."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _StreamJobStore:
async def get(self, job_id: str, *, user_id: str): # noqa: ARG002
return {"id": job_id, "session_id": "drs_1"}
async def get_unscoped(self, job_id: str): # noqa: ARG002
return None
class _UnavailableEventStore:
async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("temporary replica failure")
async def is_connected() -> bool:
return False
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_job_store=_StreamJobStore(),
deep_research_event_store=_UnavailableEventStore(),
)
),
is_disconnected=is_connected,
)
response = await deep_research.stream_job("drj_1", request)
chunk = await anext(response.body_iterator)
await response.body_iterator.aclose()
assert "durable_replay_temporarily_unavailable" in chunk
@@ -0,0 +1,30 @@
"""Regression coverage for provider-safe transient context compression."""
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
user = HumanMessage(content="请分析这份材料", id="user-1")
source = [
user,
AIMessage(content="", id="ai-1"),
ToolMessage(content="检索结果", tool_call_id="call-1", id="tool-1"),
]
compressed = [SystemMessage(content="internal summary"), *source[1:]]
result = _ensure_latest_human_message(source, compressed)
assert result[0].type == "system"
assert result[1] is user
assert [message.type for message in result[2:]] == ["ai", "tool"]
def test_compression_does_not_duplicate_human_turn_already_in_tail():
user = HumanMessage(content="继续", id="user-1")
compressed = [SystemMessage(content="internal summary"), user]
result = _ensure_latest_human_message([user], compressed)
assert result == compressed