fix: writing mode uses chat search then pipeline write only

Keep deep_research_report as a write-only deliverable from conversation materials, block post-success rewrite loops with a summary nudge, fill thin/empty pools from model knowledge, and open the sandbox only on real report.md content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-30 12:41:04 +08:00
1 parent 9afb9750e5
commit 49c01cb72b
20 files changed
+1449 -381

No files matched your search

@@ -19,6 +19,7 @@ import {
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
@@ -390,9 +391,12 @@ const ChatBox: React.FC<{
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages)) {
selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
}
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
@@ -424,6 +428,7 @@ const ChatBox: React.FC<{
setArtifacts,
availableArtifacts,
disableArtifacts,
thread.messages,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
@@ -69,6 +69,7 @@ import {
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -866,6 +867,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
if (tool_call.name === "task") {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
if (
(
tool_call.name === "write_file" ||
tool_call.name === "str_replace" ||
tool_call.name === "read_file" ||
tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files"
) &&
isInterceptedWritingReportWrite(writePath, message.id, messages)
) {
continue;
}
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
@@ -61,6 +61,73 @@ test("latestInFlightWriteFilePreview keeps markdown when a later json write is i
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
content: "# 草稿\n",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
const preview = latestInFlightWriteFilePreview([
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-write",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 我自己写\n",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "research-report-j1",
type: "ai",
tool_calls: [
{
id: "research-report-j1-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告\n",
},
},
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([
{
@@ -1,3 +1,4 @@
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
import { isImageFile } from "../utils/files";
import {
@@ -123,6 +124,11 @@ export function latestInFlightWriteFilePreview(
if (!sourcePath) {
continue;
}
// Writing mode rewrites custom outputs/*.md to deep_research_report.
// Opening that filename would 404-poll until report.md lands.
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages)) {
continue;
}
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
// switching the sandbox onto it freezes the main thread, then the rest
// of the turn appears to dump at once. Keep showing markdown instead.
@@ -56,6 +56,61 @@ test("write_file tool after 用户协助 opens a processing group instead of err
]);
});
test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一篇论文" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "deep_research_report",
content: "ok",
tool_call_id: "c1",
} as Message,
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } },
],
} as Message,
{
type: "ai",
id: "write",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }],
} as Message,
{
type: "ai",
id: "summary",
content: "已生成多智能体论文,详见 report.md。",
} as Message,
]);
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("a lone deep-research progress bar still opens its own group", () => {
const types = groupTypes([
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } },
],
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
+21 -8
View File
@@ -166,14 +166,19 @@ export function groupMessages<T>(
messages: [message],
});
} else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
const lastGroup = groups[groups.length - 1];
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
lastGroup.messages.push(message);
} else {
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
@@ -426,6 +431,14 @@ export function hasOnlyDeepResearchProgress(message: Message) {
);
}
function groupHasDeepResearchReport(group: MessageGroup): boolean {
return group.messages.some(
(message) =>
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
@@ -6,6 +6,7 @@ import {
buildResearchFeedMessages,
buildResearchProgressToolCalls,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
clearResearchReportStream,
finalizeResearchReportStream,
ingestResearchReportEvent,
@@ -148,6 +149,22 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
assert.equal(buildResearchProgressToolCalls(writing).length, 3);
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
const completedByPhase = streamOf({ jobId: "j7b", phase: "done", reportText: "# x" });
const completedCalls = buildResearchProgressToolCalls(completedByPhase);
assert.equal(completedCalls.length, 3);
assert.ok(completedCalls.every((call) => call.args.status === "complete"));
assert.match(completedCalls[2].args.detail, /report\.md/);
const completedByStatus = streamOf({
jobId: "j7c",
phase: "writing",
status: "success",
reportText: "# x",
});
assert.ok(
buildResearchProgressToolCalls(completedByStatus).every((call) => call.args.status === "complete"),
);
const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
const failedCalls = buildResearchProgressToolCalls(failed);
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
@@ -157,15 +174,73 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
test("feed messages attach the write_file stub only once report text exists", () => {
const noText = streamOf({ jobId: "j9", phase: "collecting" });
assert.equal(buildResearchFeedMessages(noText).length, 1);
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress");
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls?.[0]?.name, "deep_research_progress");
const writingEmpty = streamOf({ jobId: "j9b", phase: "writing" });
assert.equal(buildResearchFeedMessages(writingEmpty).length, 1);
const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
const feed = buildResearchFeedMessages(withText);
assert.equal(feed.length, 2);
const stub = feed[1].tool_calls[0];
assert.equal(stub.name, "write_file");
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub.args.content, "# 报告正文");
const stub = feed[1].tool_calls?.[0];
assert.equal(stub?.name, "write_file");
assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub?.args.content, "# 报告正文");
const done = streamOf({ jobId: "j10b", phase: "done", reportText: "# 报告正文" });
const doneFeed = buildResearchFeedMessages(done);
assert.equal(doneFeed.length, 3);
assert.equal(doneFeed[2].type, "tool");
assert.equal(doneFeed[2].tool_call_id, "research-report-j10b-write");
});
test("report_completed flips phase to done and write step completes before tool_finished", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "jdone", payload: { delta: "正文", offset: 0 } },
{ type: "report_completed", jobId: "jdone", payload: { sourceCount: 2 } },
]);
const state = peekResearchReportStream(tid);
assert.equal(state?.phase, "done");
assert.equal(state?.status, "active");
const calls = buildResearchProgressToolCalls(state!);
assert.ok(calls.every((call) => call.args.status === "complete"));
});
test("composeResearchDisplayMessages inserts the bar before the summary and drops leaked prose", () => {
const feed = buildResearchFeedMessages(streamOf({
jobId: "jsplice",
phase: "done",
reportText: "# 报告",
}));
const composed = composeResearchDisplayMessages(
[
{ id: "h", type: "human", content: "写论文" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }],
},
{ id: "leak", type: "ai", content: "# 报告\n\n泄漏到消息列表的正文" },
{ id: "tool", type: "tool", tool_call_id: "c1", content: "ok" },
{ id: "summary", type: "ai", content: "已生成论文,详见 report.md。" },
],
feed,
);
assert.deepEqual(
composed.map((message) => message.id),
[
"h",
"ai-tool",
"tool",
"research-progress-jsplice",
"research-report-jsplice",
"research-report-jsplice-write-result",
"summary",
],
);
});
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
* finalize/clear 立即 flush,结束态不滞后。
* 10 次通知,流式观感不受影响。终态帧(tool_finished /
* report_completed)与 finalize/clear 立即 flush,结束态不滞后。
*/
const EMIT_COALESCE_MS = 100;
@@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
case "report_completed":
next = {
...base,
phase: "writing",
// 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
phase: "done",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount
: base.sourceCount,
@@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
if (next !== previous) {
store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished") {
if (frame.type === "tool_finished" || frame.type === "report_completed") {
emitNow(store);
} else {
emitCoalesced(store);
@@ -280,7 +281,11 @@ export type ResearchProgressToolCall = {
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase);
const finished = state.status !== "active";
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
// success 时必须用 length(越过 write 的 boundary),length-1 永远完不成最后一步。
// report_completed 会先把 phase 置 done,此时工具可能还没返回,也视为写完。
const succeeded =
state.status === "success" || (state.status === "active" && state.phase === "done");
const effectiveRank = succeeded ? PHASE_ORDER.length : rank;
const stages = [
{
id: "plan",
@@ -304,9 +309,11 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
id: "write",
label: "撰写研究报告",
boundary: PHASE_ORDER.length,
detail: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
detail: succeeded
? "已按「通用分析结构」写完并保存为 report.md"
: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
},
];
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
@@ -330,19 +337,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
export type ResearchFeedMessage = {
id: string;
type: "ai";
type: "ai" | "tool";
content: string;
tool_calls: {
name?: string;
tool_call_id?: string;
tool_calls?: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
return state.status === "success" || state.phase === "done";
}
/**
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
* 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
* (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
* 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
*/
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [
@@ -353,23 +366,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
tool_calls: buildResearchProgressToolCalls(state),
},
];
if (state.reportText.trim()) {
// 必须等正文首 token:思考阶段挂 stub 会提前显示「写入文件 report.md」。
if (!state.reportText.trim()) {
return messages;
}
const writeCallId = `research-report-${state.jobId}-write`;
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: writeCallId,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
if (isResearchWriteFinished(state)) {
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: `research-report-${state.jobId}-write`,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
id: `research-report-${state.jobId}-write-result`,
type: "tool",
name: "write_file",
tool_call_id: writeCallId,
content: "ok",
});
}
return messages;
}
function isDeepResearchReportAi(message: { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }): boolean {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report"))
);
}
function deepResearchReportCallIds(message: { tool_calls?: Array<{ name?: string; id?: string }> }): Set<string> {
return new Set(
(message.tool_calls ?? [])
.filter((toolCall) => toolCall.name === "deep_research_report" && toolCall.id)
.map((toolCall) => toolCall.id as string),
);
}
function messageHasVisibleText(message: { type?: string; content?: unknown; tool_calls?: unknown[] }): boolean {
if (message.type !== "ai") return false;
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
return typeof message.content === "string" && message.content.trim().length > 0;
}
/**
* 丢掉管线内部 LLM 泄漏到父图 messages 流的正文:它们夹在
* `deep_research_report` 调用与其 tool result 之间,会让左侧气泡和沙箱一起写。
*/
export function omitLeakedReportTokens<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
): T[] {
const hidden = new Set<number>();
let pendingIds: Set<string> | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (isDeepResearchReportAi(message)) {
pendingIds = deepResearchReportCallIds(message);
continue;
}
if (!pendingIds) continue;
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
pendingIds.has(message.tool_call_id)
) {
pendingIds = null;
continue;
}
if (messageHasVisibleText(message)) {
hidden.add(i);
}
}
return hidden.size === 0 ? messages : messages.filter((_, index) => !hidden.has(index));
}
/**
* 把进度条 / write_file stub 插到 `deep_research_report` 及其结果之后、
* 模型总结之前,这样总结气泡永远在步骤条下面。
*/
export function insertResearchFeedMessages<T>(messages: T[], feed: T[]): T[] {
if (feed.length === 0) return messages;
let insertAt = -1;
let callIds = new Set<string>();
for (let i = 0; i < messages.length; i++) {
const message = messages[i] as { type?: string; tool_calls?: Array<{ name?: string; id?: string }> };
if (isDeepResearchReportAi(message)) {
insertAt = i + 1;
callIds = deepResearchReportCallIds(message);
}
}
if (insertAt === -1) {
return [...messages, ...feed];
}
while (insertAt < messages.length) {
const message = messages[insertAt] as { type?: string; tool_call_id?: string };
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
callIds.has(message.tool_call_id)
) {
insertAt += 1;
continue;
}
break;
}
return [...messages.slice(0, insertAt), ...feed, ...messages.slice(insertAt)];
}
export function composeResearchDisplayMessages<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
feed: T[],
): T[] {
return insertResearchFeedMessages(omitLeakedReportTokens(messages), feed);
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL(
@@ -3,8 +3,8 @@
/**
* 聊天页「写作模式」深度研究进度注入。
*
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
* 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
* 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
*
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
@@ -26,13 +26,16 @@ import {
artifactPreviewKey,
useArtifactsOptional,
} from "@/components/workspace/artifacts/context";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
import {
buildResearchFeedMessages,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
RESEARCH_REPORT_OUTPUT_PATH,
useResearchReportStream,
} from "./use-research-report-stream";
import { isInterceptedWritingReportWrite } from "./writing-mode";
type FeedThreadLike = {
messages: Message[];
@@ -55,10 +58,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
[thread.values?.artifacts],
);
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
// 预览,报告在沙箱里边写边流式渲染。
// 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
// 避免「写入文件 report.md」出现在模型还在想的时候。
useEffect(() => {
if (!research || research.status !== "active" || !research.reportText.trim()) {
if (
!research ||
research.status !== "active" ||
research.phase !== "writing" ||
!research.reportText.trim()
) {
return;
}
if (autoOpenedJobRef.current === research.jobId) return;
@@ -77,7 +85,14 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
if (!artifacts) return;
handedOverJobRef.current = research.jobId;
const current = artifacts.selectedArtifact;
if (current !== null && current !== buildResearchReportVirtualUrl(research)) {
const virtualUrl = buildResearchReportVirtualUrl(research);
const currentPath = current ? resolveWriteFilePath(current) : "";
const keepUserSelection =
current !== null &&
current !== virtualUrl &&
current !== RESEARCH_REPORT_OUTPUT_PATH &&
!isInterceptedWritingReportWrite(currentPath);
if (keepUserSelection) {
return;
}
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
@@ -91,11 +106,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
}, [hasRealArtifact, research]);
const displayThread = useMemo(() => {
if (feedMessages.length === 0) return thread;
const messages = [
...thread.messages,
...(feedMessages as unknown as Message[]),
];
const messages = composeResearchDisplayMessages(
thread.messages,
feedMessages as unknown as Message[],
);
if (messages === thread.messages) return thread;
return {
...thread,
values: { ...thread.values, messages },
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isInterceptedWritingReportWrite,
isWritingFirstReportTurn,
shouldAutoOpenWritingArtifact,
} from "./writing-mode";
test("custom outputs markdown is always intercepted except the research stub", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
true,
);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"research-report-j1",
),
false,
);
});
test("report.md is intercepted for the whole first-report turn", () => {
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
assert.equal(isWritingFirstReportTurn(beforePipeline), true);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"m1",
beforePipeline,
),
true,
);
const inFlight = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(isWritingFirstReportTurn(inFlight), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight),
true,
);
const succeeded = [
...inFlight,
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
];
assert.equal(isWritingFirstReportTurn(succeeded), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded),
true,
);
});
test("sandbox auto-open waits for report.md on the first-report turn", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
false,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
true,
);
});
test("a later user turn releases report.md live edits", () => {
const followUp = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
{ type: "human", content: "把第二节改短一点" },
];
assert.equal(isWritingFirstReportTurn(followUp), false);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp),
false,
);
});
@@ -0,0 +1,112 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string {
if (!path) return "";
return path.replace(/\\/g, "/").split("?")[0]?.split("#")[0]?.replace(/\/+$/, "") ?? "";
}
export function isWritingModeReportPath(
path: string | null | undefined,
): boolean {
const normalized = normalizeWritingPath(path);
return /(?:^|\/)report\.md$/i.test(normalized);
}
/** Markdown under the sandbox outputs dir — writing mode intercepts these as the report. */
export function isOutputsMarkdownPath(path: string | null | undefined): boolean {
const normalized = normalizeWritingPath(path);
if (!normalized || !/\.(?:md|markdown)$/i.test(normalized)) return false;
return (
normalized === OUTPUTS_PREFIX ||
normalized.startsWith(`${OUTPUTS_PREFIX}/`) ||
normalized === "outputs" ||
normalized.startsWith("outputs/")
);
}
type WritingPipelineMessage = {
type?: string;
name?: string;
content?: unknown;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{ name?: string }>;
};
/**
* First-report turn: no successful pipeline yet, or the latest visible user
* message is still the one that triggered that pipeline. Follow-up edits
* (a later visible human after success) keep live `report.md` writes visible.
*/
export function isWritingFirstReportTurn(
messages: readonly WritingPipelineMessage[] | null | undefined,
): boolean {
if (!messages?.length) return false;
let lastVisibleHuman = -1;
let lastSuccess = -1;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (
message?.type === "human" &&
message.additional_kwargs?.hide_from_ui !== true
) {
lastVisibleHuman = index;
}
if (message?.type === "tool" && message.name === "deep_research_report") {
const content = typeof message.content === "string" ? message.content : "";
if (content && !content.toLowerCase().startsWith("error")) {
lastSuccess = index;
}
}
}
return lastVisibleHuman >= 0 && (lastSuccess < 0 || lastVisibleHuman <= lastSuccess);
}
/**
* Hand-written outputs/*.md writes the backend rewrites or drops:
* custom filenames always, and `report.md` while the first-turn pipeline is
* in-flight or just succeeded. The injected research stub stays visible.
*/
export function isInterceptedWritingReportWrite(
path: string | null | undefined,
messageId?: string | null,
messages?: readonly WritingPipelineMessage[] | null,
): boolean {
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
return false;
}
if (!isOutputsMarkdownPath(path)) {
return false;
}
if (!isWritingModeReportPath(path)) {
return true;
}
return isWritingFirstReportTurn(messages);
}
/** Auto-open the sandbox only for the real report during the first-report turn. */
export function shouldAutoOpenWritingArtifact(
path: string | null | undefined,
messages?: readonly WritingPipelineMessage[] | null,
): boolean {
if (!path) {
return false;
}
if (isWritingFirstReportTurn(messages)) {
return isWritingModeReportPath(path);
}
return !isInterceptedWritingReportWrite(path, undefined, messages);
}
export function resolveWritingModeReportPath(
artifacts: readonly string[] | null | undefined,
selectedArtifact?: string | null,
): string | undefined {
if (isWritingModeReportPath(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(artifacts ?? [])]
.reverse()
.find((path) => isWritingModeReportPath(path));
}
+8 -10
View File
@@ -45,6 +45,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
Select,
@@ -612,16 +613,13 @@ export default function AgentChatPage({
} as typeof displayThread;
}, [displayThread, showReportImportCard, taskId, threadId]);
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
// ChatPage 的回传逻辑对齐)。
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [thread.values.artifacts]);
// 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
// .md 不得跳过管线(与 ChatPage 对齐)。
const writingArtifactPath = useMemo(
() => resolveWritingModeReportPath(thread.values.artifacts),
[thread.values.artifacts],
);
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
+6 -10
View File
@@ -63,6 +63,7 @@ import {
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -354,16 +355,11 @@ export default function ChatPage() {
researchDisplayThread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
+6 -10
View File
@@ -42,6 +42,7 @@ import {
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
@@ -285,16 +286,11 @@ export default function IframeChatPage() {
threadMetadataAgentId,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const handleSubmit = useCallback(
(