fix: writing mode first-report allows only the pipeline to write the report

- Strip write_file/str_replace/bash from the model binding in the
  first-report run: after_model rewrites only fire after a full model
  response, so any bound write tool let weak models draft the whole
  essay into tool args (write_file content or bash cat>/python
  heredocs) for tens of invisible seconds before interception
- Hallucinated hand-writes are still handled: after_model rewrites
  bash cat >/python open('w')/tee and leftover write calls to
  deep_research_report; wrap_tool_call rejects whatever reaches the
  tool layer; post-success writes are dropped, nudged to a 2-3
  sentence summary, and patched with a fallback closing message so
  the turn never ends silently
- The rewritten pipeline call carries topic = original request
  (clarification answers skipped) plus focus = clarification answers,
  so the pipeline writes the clarified subject instead of the raw
  prompt text; prompts updated to match (tools unavailable, call the
  tool directly, do not draft first)
- Frontend write-step interception and streaming preview scoping now
  applies only in writing mode; normal mode shows/previews writes
  exactly as before
This commit is contained in:
董宏凯 committed 2026-08-30 15:53:43 +08:00
1 parent 49c01cb72b
commit 4964967295
12 files changed
+1271 -133

No files matched your search

+3
View File
@@ -127,6 +127,9 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_workflow_engine.py !offline-backend-20260512/backend/tests/test_workflow_engine.py
!offline-backend-20260512/backend/tests/test_workflow_runs.py !offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py !offline-backend-20260512/backend/tests/test_workflow_concurrency.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
deploy deploy
.history .history
@@ -101,6 +101,9 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null); const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]); const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const ragModeEnabled = const ragModeEnabled =
@@ -392,7 +395,7 @@ const ChatBox: React.FC<{
((hasNewArtifact || !hadArtifacts) && !selectedArtifact)) ((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) { ) {
const newest = nextArtifacts[nextArtifacts.length - 1]!; const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages)) { if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
selectArtifact(newest); selectArtifact(newest);
setArtifactsOpen(true); setArtifactsOpen(true);
setSideTab("document"); setSideTab("document");
@@ -429,6 +432,7 @@ const ChatBox: React.FC<{
availableArtifacts, availableArtifacts,
disableArtifacts, disableArtifacts,
thread.messages, thread.messages,
writingModeEnabled,
]); ]);
// Live sandbox: open as soon as any in-flight write_file has a path or // Live sandbox: open as soon as any in-flight write_file has a path or
@@ -439,8 +443,8 @@ const ChatBox: React.FC<{
() => () =>
disableArtifacts || !thread.isLoading disableArtifacts || !thread.isLoading
? null ? null
: latestInFlightWriteFilePreview(thread.messages ?? []), : latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
[disableArtifacts, thread.isLoading, thread.messages], [disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
); );
useEffect(() => { useEffect(() => {
if (!streamingWritePreview) return; if (!streamingWritePreview) return;
@@ -101,8 +101,14 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState( const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
// write_file / read_file 步骤条必须照旧显示。
const writingModeEnabled = !!settings.context.writing_mode;
const steps = useMemo(
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills(); const { skills } = useSkills();
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -849,7 +855,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] { function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = []; const steps: CoTStep[] = [];
for (const message of messages) { for (const message of messages) {
if (message.type === "ai") { if (message.type === "ai") {
@@ -879,7 +885,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
tool_call.name === "read_multiple_files" || tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files" tool_call.name === "present_files"
) && ) &&
isInterceptedWritingReportWrite(writePath, message.id, messages) isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
) { ) {
continue; continue;
} }
@@ -61,7 +61,27 @@ test("latestInFlightWriteFilePreview keeps markdown when a later json write is i
assert.match(preview?.artifactPath ?? "", /report\.md/); assert.match(preview?.artifactPath ?? "", /report\.md/);
}); });
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown", () => { test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown in writing mode", () => {
const messages = [
{
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(latestInFlightWriteFilePreview(messages, true), null);
});
test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview([
{ {
id: "m1", id: "m1",
@@ -78,54 +98,114 @@ test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown",
], ],
}, },
]); ]);
assert.equal(preview, null); assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/);
}); });
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => { test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview(
{ type: "human", content: "写一篇论文" }, [
{ { type: "human", content: "写一篇论文" },
id: "m-pipe", {
type: "ai", id: "m-pipe",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }], type: "ai",
}, tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
{ },
id: "m-write", {
type: "ai", id: "m-write",
tool_calls: [ type: "ai",
{ tool_calls: [
id: "w1", {
name: "write_file", id: "w1",
args: { name: "write_file",
path: "/mnt/user-data/outputs/report.md", args: {
content: "# 我自己写\n", path: "/mnt/user-data/outputs/report.md",
content: "# 我自己写\n",
},
}, },
}, ],
], },
}, ],
]); true,
);
assert.equal(preview, null); assert.equal(preview, null);
}); });
test("latestInFlightWriteFilePreview still opens the injected research stub", () => { 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",
},
},
],
},
],
true,
);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("writing mode still previews follow-up-turn writes after a later user message", () => {
// 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。
const preview = latestInFlightWriteFilePreview(
[
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-result",
type: "tool",
tool_call_id: "p1",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md 并展示给用户。",
},
{ type: "human", content: "把第二章改写得更学术一些" },
{
id: "m-edit",
type: "ai",
tool_calls: [
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 修订后的报告\n",
},
},
],
},
],
true,
);
assert.ok(preview);
assert.equal(preview?.previewKey, "m-edit:w2");
});
test("normal mode keeps previewing pathless streaming drafts", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview([
{ {
id: "research-report-j1", id: "m1",
type: "ai", type: "ai",
tool_calls: [ tool_calls: [
{ { id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
id: "research-report-j1-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告\n",
},
},
], ],
}, },
]); ]);
assert.ok(preview); assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/); assert.match(preview?.artifactPath ?? "", /\.md/);
}); });
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => { test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
@@ -38,7 +38,10 @@ type WriteFileThreadLike = {
messages: Array<{ messages: Array<{
id?: string; id?: string;
type?: string; type?: string;
name?: string;
content?: unknown;
tool_call_id?: string; tool_call_id?: string;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{ tool_calls?: Array<{
id?: string; id?: string;
name?: string; name?: string;
@@ -95,6 +98,7 @@ export function isWriteFileInFlight(
*/ */
export function latestInFlightWriteFilePreview( export function latestInFlightWriteFilePreview(
messages: WriteFileThreadLike["messages"], messages: WriteFileThreadLike["messages"],
writingMode = false,
): { artifactPath: string; previewKey: string } | null { ): { artifactPath: string; previewKey: string } | null {
const thread = { messages }; const thread = { messages };
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
@@ -125,8 +129,9 @@ export function latestInFlightWriteFilePreview(
continue; continue;
} }
// Writing mode rewrites custom outputs/*.md to deep_research_report. // Writing mode rewrites custom outputs/*.md to deep_research_report.
// Opening that filename would 404-poll until report.md lands. // Opening that filename would 404-poll until report.md lands. Normal
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages)) { // mode never skips: the write is a real deliverable, not a rewrite.
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
continue; continue;
} }
// JSON (e.g. agentfx task-reports.json) is too large to live-preview: // JSON (e.g. agentfx task-reports.json) is too large to live-preview:
@@ -91,7 +91,8 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
current !== null && current !== null &&
current !== virtualUrl && current !== virtualUrl &&
current !== RESEARCH_REPORT_OUTPUT_PATH && current !== RESEARCH_REPORT_OUTPUT_PATH &&
!isInterceptedWritingReportWrite(currentPath); // research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
if (keepUserSelection) { if (keepUserSelection) {
return; return;
} }
@@ -9,18 +9,33 @@ import {
test("custom outputs markdown is always intercepted except the research stub", () => { test("custom outputs markdown is always intercepted except the research stub", () => {
assert.equal( assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"), isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true),
true, true,
); );
assert.equal( assert.equal(
isInterceptedWritingReportWrite( isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md", "/mnt/user-data/outputs/report.md",
"research-report-j1", "research-report-j1",
undefined,
true,
), ),
false, false,
); );
}); });
test("normal mode never intercepts outputs markdown writes", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
false,
);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m0", [
{ type: "human", content: "写一篇论文" },
]),
false,
);
});
test("report.md is intercepted for the whole first-report turn", () => { test("report.md is intercepted for the whole first-report turn", () => {
const beforePipeline = [{ type: "human", content: "写一篇论文" }]; const beforePipeline = [{ type: "human", content: "写一篇论文" }];
assert.equal(isWritingFirstReportTurn(beforePipeline), true); assert.equal(isWritingFirstReportTurn(beforePipeline), true);
@@ -29,6 +44,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
"/mnt/user-data/outputs/report.md", "/mnt/user-data/outputs/report.md",
"m1", "m1",
beforePipeline, beforePipeline,
true,
), ),
true, true,
); );
@@ -42,7 +58,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
]; ];
assert.equal(isWritingFirstReportTurn(inFlight), true); assert.equal(isWritingFirstReportTurn(inFlight), true);
assert.equal( assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight), isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true),
true, true,
); );
@@ -56,7 +72,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
]; ];
assert.equal(isWritingFirstReportTurn(succeeded), true); assert.equal(isWritingFirstReportTurn(succeeded), true);
assert.equal( assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded), isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true),
true, true,
); );
}); });
@@ -70,9 +86,27 @@ test("sandbox auto-open waits for report.md on the first-report turn", () => {
}, },
]; ];
assert.equal( assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn), shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true),
false, false,
); );
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn, true),
true,
);
});
test("normal mode auto-opens every new artifact like before writing mode existed", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
true,
);
assert.equal( assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn), shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
true, true,
@@ -95,7 +129,7 @@ test("a later user turn releases report.md live edits", () => {
]; ];
assert.equal(isWritingFirstReportTurn(followUp), false); assert.equal(isWritingFirstReportTurn(followUp), false);
assert.equal( assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp), isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true),
false, false,
); );
}); });
+17 -2
View File
@@ -67,12 +67,19 @@ export function isWritingFirstReportTurn(
* Hand-written outputs/*.md writes the backend rewrites or drops: * Hand-written outputs/*.md writes the backend rewrites or drops:
* custom filenames always, and `report.md` while the first-turn pipeline is * custom filenames always, and `report.md` while the first-turn pipeline is
* in-flight or just succeeded. The injected research stub stays visible. * in-flight or just succeeded. The injected research stub stays visible.
*
* Everything above only exists in writing mode — normal mode writes md files
* by hand as always, so `writingMode=false` (the default) never intercepts.
*/ */
export function isInterceptedWritingReportWrite( export function isInterceptedWritingReportWrite(
path: string | null | undefined, path: string | null | undefined,
messageId?: string | null, messageId?: string | null,
messages?: readonly WritingPipelineMessage[] | null, messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean { ): boolean {
if (!writingMode) {
return false;
}
if (typeof messageId === "string" && messageId.startsWith("research-report-")) { if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
return false; return false;
} }
@@ -85,18 +92,26 @@ export function isInterceptedWritingReportWrite(
return isWritingFirstReportTurn(messages); return isWritingFirstReportTurn(messages);
} }
/** Auto-open the sandbox only for the real report during the first-report turn. */ /**
* Auto-open the sandbox only for the real report during the first-report
* turn. Normal mode keeps the pre-writing-mode behavior: always auto-open
* the newest artifact.
*/
export function shouldAutoOpenWritingArtifact( export function shouldAutoOpenWritingArtifact(
path: string | null | undefined, path: string | null | undefined,
messages?: readonly WritingPipelineMessage[] | null, messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean { ): boolean {
if (!path) { if (!path) {
return false; return false;
} }
if (!writingMode) {
return true;
}
if (isWritingFirstReportTurn(messages)) { if (isWritingFirstReportTurn(messages)) {
return isWritingModeReportPath(path); return isWritingModeReportPath(path);
} }
return !isInterceptedWritingReportWrite(path, undefined, messages); return !isInterceptedWritingReportWrite(path, undefined, messages, true);
} }
export function resolveWritingModeReportPath( export function resolveWritingModeReportPath(
@@ -795,15 +795,14 @@ def apply_agent_only_prompt_template(
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。 # 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
writing_mode_section = """<writing_mode> writing_mode_section = """<writing_mode>
Writing mode is enabled for this run (first report turn — no existing artifact). Writing mode is enabled for this run (first report turn — no existing artifact).
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — never write it yourself with `write_file`, never `present_files` it. - HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — call it directly at delivery time and do NOT draft the full report body first (not in chat, not inside tool arguments). File-writing tools (`write_file` / `str_replace`) and the `bash` tool are NOT available in this run; any hand-write attempt is intercepted and redirected to `deep_research_report`. Never `present_files` the report.
- Work normally (chat, search, clarify) until the report is due; then produce it by calling the `deep_research_report` tool: - Work normally (chat, search, clarify) until the report is due; then produce it by calling the `deep_research_report` tool:
`topic` = the user's subject copied faithfully, `focus` = the user's extra requirements (if any). `topic` = the user's subject (the refined thesis after clarifications, not the raw first message), `focus` = the extra requirements from the user's answers (type, length, tone, must-cover points).
The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references) The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references)
to `/mnt/user-data/outputs/report.md`, presented automatically. to `/mnt/user-data/outputs/report.md`, presented automatically.
- If harvested materials are thin, still call `deep_research_report` once and let it write immediately. - If harvested materials are thin, still call `deep_research_report` once and let it write immediately.
Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials. Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials.
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search. - Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for it.
- After the tool returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the core conclusions. Do not rewrite even if you think materials were thin. - After the tool returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the core conclusions. Do not rewrite even if you think materials were thin.
- Only if the tool fails, tell the user and offer to retry. - Only if the tool fails, tell the user and offer to retry.
</writing_mode> </writing_mode>
@@ -1479,20 +1478,18 @@ def apply_prompt_template(
Writing mode is enabled for this run (first report turn — no existing artifact). Writing mode is enabled for this run (first report turn — no existing artifact).
**Mandatory Output Contract** **Mandatory Output Contract**
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool. You are FORBIDDEN from writing the report yourself — do not call `write_file` - HARD RULE: the report file is produced ONLY by the `deep_research_report` tool. Call the tool directly when the report is due — do NOT draft the full report body first (not in chat, not inside tool arguments). File-writing tools (`write_file` / `str_replace`) and the `bash` tool are NOT available in this run; any hand-write attempt is intercepted and redirected to `deep_research_report`. Do not call `present_files` for the report.
(or any file-writing tool) to create the report, and do not call `present_files` for it.
- Treat the user's request as a research-report writing task. Work normally until delivery time: discuss, search, and clarify exactly as you always would. - Treat the user's request as a research-report writing task. Work normally until delivery time: discuss, search, and clarify exactly as you always would.
- When the report file is due — the user asks to start writing, or you have finished gathering what the conversation needs — call the `deep_research_report` tool: - When the report file is due — the user asks to start writing, or you have finished gathering what the conversation needs — call the `deep_research_report` tool directly:
- `topic`: the user's subject/thesis, copied faithfully. - `topic`: the user's subject/thesis — the refined one settled in the clarifications, not the raw first message.
- `focus`: the user's extra requirements (length, tone, must-cover points), if any. - `focus`: the extra requirements from the user's answers (type, length, tone, must-cover points), if any.
- The tool reuses the materials already collected in this conversation, then writes the full - The tool reuses the materials already collected in this conversation, then writes the full
report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically. report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically.
- If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately. - If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately.
Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason. Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason.
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search. - Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for the report — the tool already handles both.
- While the tool runs, stay silent. After it returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the report's core conclusions and point the user to the Markdown artifact. Do not duplicate the report inline, and do not rewrite even if you think materials were thin. - While the tool runs, stay silent. After it returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the report's core conclusions and point the user to the Markdown artifact. Do not duplicate the report inline, and do not rewrite even if you think materials were thin.
- Only if the tool fails, tell the user what went wrong and offer to retry; do not silently fall back to writing the report by hand unless the user explicitly asks. - Only if the tool fails, tell the user what went wrong and offer to retry; do not silently fall back to writing the report by hand.
</writing_mode> </writing_mode>
""" """
else: else:
@@ -1,19 +1,35 @@
"""Redirect writing-mode Markdown deliverables onto ``deep_research_report``. """Redirect writing-mode Markdown deliverables onto ``deep_research_report``.
Writing mode still allows normal chat, search, and clarification. The pipeline Writing mode still allows normal chat, search, and clarification. The report
starts only when the model begins writing the report file (``write_file`` / only comes from the pipeline, and no hand-write path can even start:
``str_replace`` of ``report.md`` or any Markdown under
``/mnt/user-data/outputs/``): those calls are rewritten to 1. ``wrap_model_call`` removes ``write_file`` / ``str_replace`` / ``bash``
``deep_research_report``. Sibling ``present_files`` of the same report are from the model binding for the whole first-report run. ``after_model``
dropped because the pipeline presents the file itself. rewrites can only fire AFTER a model response completes, so any write tool
left bound lets weak models stream the full essay into its arguments first
(``write_file`` content or a bash ``cat > report.md << EOF`` heredoc) for
tens of seconds, invisible to the user. bash is the only execution tool
that writes files, so excluding it too leaves NO tool the model could use
to start a file write — at delivery time the only path is the
``deep_research_report`` call itself.
2. Hallucinated calls (emitted despite not being bound) are still handled:
bash heredoc / ``python open(..., 'w')`` / ``tee`` writes and leftover
``write_file`` / ``str_replace`` calls are rewritten to the pipeline by
``after_model``, and ``wrap_tool_call`` rejects whatever reaches the tool
layer.
The rewritten pipeline call carries ``topic`` = the user's original request
(clarification answers skipped) plus ``focus`` = the clarification answers
(direction/type/length), so the pipeline writes the refined subject instead of
the raw prompt text.
After the pipeline has succeeded once in this thread, further After the pipeline has succeeded once in this thread, further
``deep_research_report`` / hand-written report writes / ``read_file`` of the ``deep_research_report`` / hand-written report writes / ``read_file`` of the
report are dropped. The first time that happens, a hidden summary nudge is report are dropped. The first time that happens, a hidden summary nudge is
injected and the graph jumps back to the model so it can write the 2–3 injected and the graph jumps back to the model so it can write the 2–3
sentence wrap-up. If it still tries to loop after that nudge, the turn ends. sentence wrap-up. If it still tries to loop after that nudge, the dropped
Ending immediately on the first rewrite attempt left the model's message is patched to a fallback summary and the turn ends — the user always
"I will rewrite this" text as the visible summary. sees a closing message.
This middleware never sets ``tool_choice``. Thinking-mode providers (Aliyun This middleware never sets ``tool_choice``. Thinking-mode providers (Aliyun
compatible-mode) reject ``required`` / object ``tool_choice`` with HTTP 400. compatible-mode) reject ``required`` / object ``tool_choice`` with HTTP 400.
@@ -23,12 +39,14 @@ from __future__ import annotations
import json import json
import logging import logging
import re
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from copy import deepcopy from copy import deepcopy
from typing import Any, override from typing import Any, override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware, hook_config from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
@@ -42,24 +60,20 @@ TOOL_NAME = "deep_research_report"
_WRITE_TOOLS = frozenset({"write_file", "str_replace"}) _WRITE_TOOLS = frozenset({"write_file", "str_replace"})
_READ_TOOLS = frozenset({"read_file", "read_multiple_files"}) _READ_TOOLS = frozenset({"read_file", "read_multiple_files"})
_PRESENT_TOOL = "present_files" _PRESENT_TOOL = "present_files"
_BASH_TOOL = "bash"
_OUTPUTS_PREFIX = "/mnt/user-data/outputs" _OUTPUTS_PREFIX = "/mnt/user-data/outputs"
_MAX_TOPIC_CHARS = 500 _MAX_TOPIC_CHARS = 500
_MAX_FOCUS_CHARS = 600
_NUDGE_KWARG = "writing_mode_summary_nudge" _NUDGE_KWARG = "writing_mode_summary_nudge"
_REJECT_HANDWRITTEN = ( _FALLBACK_SUMMARY = "写作管线已完成,报告已写入 /mnt/user-data/outputs/report.md 并展示在右侧沙箱,可直接查看。如需调整内容,请在下一条消息中告诉我具体要求。"
"写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / " _REJECT_HANDWRITTEN = "写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / str_replace / bash 命令(cat > 重定向、python open(..., 'w')、tee 等)手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。"
"str_replace 手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。"
)
_REJECT_AFTER_SUCCESS = ( _REJECT_AFTER_SUCCESS = (
"写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。" "写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。"
"禁止再次调用 deep_research_report,也禁止用 write_file / str_replace / " "禁止再次调用 deep_research_report,也禁止用 write_file / str_replace / bash / "
"read_file 读写该报告。请只用 2-3 句话总结核心结论后结束本轮,不要再调用任何文件或写作工具。" "read_file 读写该报告。请只用 2-3 句话总结核心结论后结束本轮,不要再调用任何文件或写作工具。"
) )
_SUMMARY_NUDGE = ( _SUMMARY_NUDGE = (
"<system_reminder>\n" "<system_reminder>\n写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n不要评价材料是否不足,不要计划重写。\n</system_reminder>"
"写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n"
"请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n"
"不要评价材料是否不足,不要计划重写。\n"
"</system_reminder>"
) )
@@ -119,11 +133,42 @@ def _is_outputs_markdown_path(path: str) -> bool:
return relative == "outputs" or relative.startswith("outputs/") return relative == "outputs" or relative.startswith("outputs/")
# bash 手写报告的识别:写入动作(重定向 / tee / python 只写打开 / write_text)
# 的目标本身是交付物 Markdown。纯读取(mkdir、wc、cat 输出)不拦。
_BASH_MD_PATH = re.compile(r"""['"]([^'"]{0,200}\.md)['"]|(^|[\s;|&<>()=])([^\s;'"&|<>(){},]{0,200}\.md)\b""")
_BASH_MD_REDIRECT = re.compile(r">{1,2}\s*([^\s;|&>]{0,200}\.md)\b")
_BASH_MD_TEE = re.compile(r"\btee\s+(?:-[A-Za-z]+\s+)*([^\s;|&<>]{0,200}\.md)\b")
_BASH_MD_PY_WRITE = re.compile(r"""open\s*\(\s*['"][^'"]{0,200}\.md['"]\s*,\s*['"]w""")
def _bash_command_writes_report(command: str) -> bool:
"""True 当 bash 命令在向写作模式的 Markdown 交付物写入内容。
实测弱模型被禁用手写工具后会绕道 bash:``cat > report.md << 'EOF'``、
``python3 << PYEOF`` + ``open(..., 'w')``、``tee`` 等。按「写入动作的
目标(或命令提到的路径)是 outputs Markdown / report.md」判定;
mkdir、wc、cat 读取这类无写入动作的命令不受影响。
"""
if not command or ".md" not in command:
return False
for pattern in (_BASH_MD_REDIRECT, _BASH_MD_TEE):
if any(_is_outputs_markdown_path(match.group(1)) for match in pattern.finditer(command)):
return True
paths = [quoted or bare or "" for quoted, _, bare in _BASH_MD_PATH.findall(command)]
if not any(_is_outputs_markdown_path(path) for path in paths):
return False
return bool(_BASH_MD_PY_WRITE.search(command)) or "write_text" in command or "write_bytes" in command
def is_handwritten_report_call(tool_call: object) -> bool: def is_handwritten_report_call(tool_call: object) -> bool:
"""True when the model is about to hand-write the writing-mode Markdown file.""" """True when the model is about to hand-write the writing-mode Markdown file."""
if _tool_name(tool_call) not in _WRITE_TOOLS: name = _tool_name(tool_call)
return False if name in _WRITE_TOOLS:
return _is_outputs_markdown_path(_tool_path(tool_call)) return _is_outputs_markdown_path(_tool_path(tool_call))
if name == _BASH_TOOL:
args = _normalize_args(_tool_field(tool_call, "args", {}))
return _bash_command_writes_report(str(args.get("command") or ""))
return False
def is_report_present_call(tool_call: object) -> bool: def is_report_present_call(tool_call: object) -> bool:
@@ -184,28 +229,6 @@ def _summary_nudge_message() -> HumanMessage:
) )
def is_post_success_loop_call(tool_call: object) -> bool:
"""After a successful pipeline, re-reading the report is also a loop."""
return is_deliverable_loop_call(tool_call) or is_report_reread_call(tool_call)
def _already_nudged_summary(messages: list[Any]) -> bool:
for message in messages:
if not isinstance(message, HumanMessage):
continue
kwargs = getattr(message, "additional_kwargs", None) or {}
if kwargs.get(_NUDGE_KWARG):
return True
return False
def _summary_nudge_message() -> HumanMessage:
return HumanMessage(
content=_SUMMARY_NUDGE,
additional_kwargs={"hide_from_ui": True, _NUDGE_KWARG: True},
)
def _report_tool_succeeded(message: ToolMessage) -> bool: def _report_tool_succeeded(message: ToolMessage) -> bool:
if str(getattr(message, "name", "") or "") != TOOL_NAME: if str(getattr(message, "name", "") or "") != TOOL_NAME:
return False return False
@@ -238,21 +261,58 @@ def _message_text(message: Any) -> str:
return str(content or "").strip() return str(content or "").strip()
def _is_clarification_result(message: Any) -> bool:
return isinstance(message, ToolMessage) and str(getattr(message, "name", "") or "") == "ask_clarification"
def extract_report_topic(messages: list[Any]) -> str: def extract_report_topic(messages: list[Any]) -> str:
"""Use the latest visible user text as ``deep_research_report.topic``.""" """Use the latest visible user text that is not a clarification answer.
for message in reversed(messages):
if _is_visible_human(message): 澄清轮里用户对 ask_clarification 的回答(紧跟着澄清 ToolMessage 的人类
text = " ".join(_message_text(message).split()) 消息,如「方向:AI Agent;用途:学术期刊论文」)不是课题本身;拿它当
if text: topic 会让管线给「回答本身」写报告。向上跳过所有回答,取真正的课题原文
return text[:_MAX_TOPIC_CHARS] (最新一条非回答的用户消息)。
"""
for index in range(len(messages) - 1, -1, -1):
message = messages[index]
if not _is_visible_human(message):
continue
if index > 0 and _is_clarification_result(messages[index - 1]):
continue
text = " ".join(_message_text(message).split())
if text:
return text[:_MAX_TOPIC_CHARS]
return "当前课题" return "当前课题"
def _as_report_call(tool_call: object, topic: str) -> dict[str, Any]: def extract_report_focus(messages: list[Any], topic: str = "") -> str:
"""Join the user's clarification answers as the pipeline's ``focus``.
澄清回答(方向/类型/篇幅/语气等)不是课题本身,但正是写作要求;
topic 取课题原文的同时把它们汇总进 focus,管线才能按澄清后的
精确主题(如「LLM 的能力边界…」+ 观点评论型 + 3000-5000 字)成稿,
而不是只拿原始请求一句泛写。
"""
parts: list[str] = []
for index, message in enumerate(messages):
if not _is_visible_human(message):
continue
if index == 0 or not _is_clarification_result(messages[index - 1]):
continue
text = " ".join(_message_text(message).split())
if text and text != topic:
parts.append(text)
return ";".join(parts).strip()[:_MAX_FOCUS_CHARS]
def _as_report_call(tool_call: object, topic: str, focus: str = "") -> dict[str, Any]:
call_id = str(_tool_field(tool_call, "id") or "") call_id = str(_tool_field(tool_call, "id") or "")
args: dict[str, Any] = {"topic": topic}
if focus:
args["focus"] = focus
return { return {
"name": TOOL_NAME, "name": TOOL_NAME,
"args": {"topic": topic}, "args": args,
"id": call_id, "id": call_id,
"type": "tool_call", "type": "tool_call",
} }
@@ -267,11 +327,7 @@ def _sync_raw_tool_calls(last: AIMessage, kept: list[Any]) -> dict[str, Any]:
additional_kwargs = dict(getattr(last, "additional_kwargs", {}) or {}) additional_kwargs = dict(getattr(last, "additional_kwargs", {}) or {})
raw_calls = additional_kwargs.get("tool_calls") raw_calls = additional_kwargs.get("tool_calls")
kept_ids = _kept_call_ids(kept) kept_ids = _kept_call_ids(kept)
rewritten_by_id = { rewritten_by_id = {str(_tool_field(call, "id") or ""): call for call in kept if _tool_field(call, "id")}
str(_tool_field(call, "id") or ""): call
for call in kept
if _tool_field(call, "id")
}
if isinstance(raw_calls, list) and raw_calls: if isinstance(raw_calls, list) and raw_calls:
synced: list[Any] = [] synced: list[Any] = []
for item in raw_calls: for item in raw_calls:
@@ -312,12 +368,14 @@ def rewrite_handwritten_report_calls(
last: AIMessage, last: AIMessage,
*, *,
topic: str, topic: str,
focus: str = "",
pipeline_already_done: bool = False, pipeline_already_done: bool = False,
) -> AIMessage | None: ) -> AIMessage | None:
"""Rewrite or drop report deliverable tool calls. """Rewrite or drop report deliverable tool calls.
Before the pipeline has succeeded: rewrite the first hand-written report Before the pipeline has succeeded: rewrite the first hand-written report
write to ``deep_research_report`` and drop sibling presents. write to ``deep_research_report`` (carrying ``topic`` + ``focus``) and drop
sibling presents.
After it has succeeded: drop every pipeline / hand-written / present / After it has succeeded: drop every pipeline / hand-written / present /
report-reread call so the model cannot start another writing loop. report-reread call so the model cannot start another writing loop.
@@ -347,12 +405,10 @@ def rewrite_handwritten_report_calls(
changed = True changed = True
if redirected: if redirected:
continue continue
rewritten.append(_as_report_call(call, topic)) rewritten.append(_as_report_call(call, topic, focus))
redirected = True redirected = True
continue continue
if is_report_present_call(call) and ( if is_report_present_call(call) and (has_pipeline or redirected or any(is_handwritten_report_call(item) for item in tool_calls)):
has_pipeline or redirected or any(is_handwritten_report_call(item) for item in tool_calls)
):
changed = True changed = True
continue continue
rewritten.append(call) rewritten.append(call)
@@ -371,10 +427,45 @@ def _messages_from_tool_request(request: ToolCallRequest) -> list[Any]:
class WritingModeReportMiddleware(AgentMiddleware[AgentState]): class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
"""Start the writing pipeline only when the model begins writing the Markdown file.""" """The first-report run's report only comes from ``deep_research_report``.
``wrap_model_call`` 把 write_file / str_replace / bash 一并从模型绑定中
剥离(本中间件只在首报轮挂载)——after_model 的改写只能在整个模型响应
结束后触发,任何留在绑定里的写入口都会被弱模型用来先把全文起草进参数
(write_file 内容或 bash heredoc 命令),流式几十秒前端不可见后才被拦
截。bash 是唯一能写文件的执行类工具,剥离后模型没有任何可发起文件写
入的工具,交付只能直接调管线;幻觉出的调用仍由 after_model 改写/丢弃、
wrap_tool_call 拒绝兜底,管线成功后再写会被丢弃并保证一段收尾总结。
"""
state_schema = AgentState state_schema = AgentState
@staticmethod
def _binding_tool_name(tool: Any) -> str:
if isinstance(tool, str):
return tool
return str(getattr(tool, "name", "") or "")
def _strip_handwrite_tools(self, request: ModelRequest) -> ModelRequest:
"""Remove ``write_file`` / ``str_replace`` / ``bash`` from the model binding.
``after_model`` 的改写只能在模型整个响应结束后触发,所以任何留在绑定里
的写入口都会被弱模型用来先把全文起草进参数(write_file 参数、bash
heredoc 命令),流式输出几十秒且前端不可见后才被拦截。bash 是唯一能
写文件的执行类工具,一并剥离后首报轮**不存在可发起文件写入的工具**,
交付只能直接调 ``deep_research_report``。ToolNode 仍持有全部工具;
模型幻觉出的调用由 after_model 改写/丢弃、wrap_tool_call 拒绝兜底。
"""
tools = list(getattr(request, "tools", None) or [])
if not tools:
return request
excluded = _WRITE_TOOLS | {_BASH_TOOL}
removed = sorted({self._binding_tool_name(tool) for tool in tools if self._binding_tool_name(tool) in excluded})
if not removed:
return request
logger.info("Writing-mode first-report run: removed hand-write tools %s from model binding", removed)
return request.override(tools=[tool for tool in tools if self._binding_tool_name(tool) not in excluded])
def _redirect_or_stop(self, state: AgentState) -> dict[str, Any] | None: def _redirect_or_stop(self, state: AgentState) -> dict[str, Any] | None:
messages = list(state.get("messages") or []) messages = list(state.get("messages") or [])
if not messages: if not messages:
@@ -383,26 +474,38 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
if not isinstance(last, AIMessage): if not isinstance(last, AIMessage):
return None return None
already_done = pipeline_already_succeeded(messages) already_done = pipeline_already_succeeded(messages)
topic = extract_report_topic(messages)
patched = rewrite_handwritten_report_calls( patched = rewrite_handwritten_report_calls(
last, last,
topic=extract_report_topic(messages), topic=topic,
focus=extract_report_focus(messages, topic),
pipeline_already_done=already_done, pipeline_already_done=already_done,
) )
if patched is None: if patched is None:
return None return None
result: dict[str, Any] = {"messages": [patched]} result: dict[str, Any] = {"messages": [patched]}
if already_done and not (patched.tool_calls or []): if already_done:
if _already_nudged_summary(messages): if not (patched.tool_calls or []):
if _already_nudged_summary(messages):
logger.info("Writing-mode pipeline already succeeded; summary nudge ignored, ending turn")
# 模型无视总结提示仍要继续读写:丢弃其调用后补一段兜底
# 总结再结束,保证用户总能看到收尾消息而不是静默结束。
if not _message_text(patched):
patched = patched.model_copy(update={"content": _FALLBACK_SUMMARY})
result["messages"] = [patched]
result["jump_to"] = "end"
return result
logger.info("Writing-mode pipeline already succeeded; dropping deliverable tools and nudging summary")
result["messages"] = [patched, _summary_nudge_message()]
result["jump_to"] = "model"
else:
redirected_names = sorted({_tool_name(call) for call in getattr(last, "tool_calls", None) or [] if is_handwritten_report_call(call)})
if redirected_names:
logger.info( logger.info(
"Writing-mode pipeline already succeeded; summary nudge ignored, ending turn" "Writing-mode: redirected hand-written report call(s) %s to %s",
redirected_names,
TOOL_NAME,
) )
result["jump_to"] = "end"
return result
logger.info(
"Writing-mode pipeline already succeeded; dropping deliverable tools and nudging summary"
)
result["messages"] = [patched, _summary_nudge_message()]
result["jump_to"] = "model"
return result return result
def _reject_loop_tool(self, request: ToolCallRequest) -> ToolMessage | None: def _reject_loop_tool(self, request: ToolCallRequest) -> ToolMessage | None:
@@ -427,6 +530,22 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None: def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._redirect_or_stop(state) return self._redirect_or_stop(state)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
return handler(self._strip_handwrite_tools(request))
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
return await handler(self._strip_handwrite_tools(request))
@hook_config(can_jump_to=["end", "model"]) @hook_config(can_jump_to=["end", "model"])
@override @override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None: async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
@@ -458,6 +577,7 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
__all__ = [ __all__ = [
"TOOL_NAME", "TOOL_NAME",
"WritingModeReportMiddleware", "WritingModeReportMiddleware",
"extract_report_focus",
"extract_report_topic", "extract_report_topic",
"is_handwritten_report_call", "is_handwritten_report_call",
"is_post_success_loop_call", "is_post_success_loop_call",
@@ -0,0 +1,707 @@
"""写作模式:仅在开始写 md 时改走 deep_research_report,且不强制 tool_choice。"""
from __future__ import annotations
from types import SimpleNamespace
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from deerflow.agents.middlewares.writing_mode_report_middleware import (
TOOL_NAME,
WritingModeReportMiddleware,
extract_report_focus,
extract_report_topic,
is_handwritten_report_call,
is_report_reread_call,
pipeline_already_succeeded,
rewrite_handwritten_report_calls,
)
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
def test_only_report_md_is_writing_mode_follow_up() -> None:
assert is_writing_mode_report_path("/mnt/user-data/outputs/report.md") is True
assert is_writing_mode_report_path("report.md") is True
assert is_writing_mode_report_path("/mnt/user-data/outputs/notes.md") is False
assert is_writing_mode_report_path("/mnt/user-data/outputs/analysis.markdown") is False
assert is_writing_mode_report_path("") is False
assert is_writing_mode_report_path(None) is False
def test_handwritten_report_detects_outputs_markdown() -> None:
assert is_handwritten_report_call({"name": "write_file", "args": {"path": "/mnt/user-data/outputs/report.md"}, "id": "c1"})
assert is_handwritten_report_call({"name": "write_file", "args": {"path": "/mnt/user-data/outputs/analysis.md"}, "id": "c1"})
assert is_handwritten_report_call({"name": "str_replace", "args": {"path": "report.md"}, "id": "c1"})
assert not is_handwritten_report_call({"name": "write_file", "args": {"path": "/mnt/user-data/workspace/notes.md"}, "id": "c1"})
assert not is_handwritten_report_call({"name": "web_search", "args": {"query": "课题"}, "id": "c1"})
def test_extract_topic_uses_latest_visible_user_text() -> None:
topic = extract_report_topic(
[
HumanMessage(content="旧课题"),
HumanMessage(content="hidden", additional_kwargs={"hide_from_ui": True}),
HumanMessage(content="写一份新能源汽车产业报告,重点看产能"),
]
)
assert topic.startswith("写一份新能源汽车产业报告")
def test_rewrite_write_file_to_pipeline() -> None:
last = AIMessage(
content="",
tool_calls=[
{
"id": "c1",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/report.md", "content": "# 草稿"},
"type": "tool_call",
},
{
"id": "c2",
"name": "present_files",
"args": {"path": "/mnt/user-data/outputs/report.md"},
"type": "tool_call",
},
],
)
patched = rewrite_handwritten_report_calls(last, topic="新能源汽车")
assert patched is not None
assert len(patched.tool_calls) == 1
assert patched.tool_calls[0]["name"] == TOOL_NAME
assert patched.tool_calls[0]["args"] == {"topic": "新能源汽车"}
assert patched.tool_calls[0]["id"] == "c1"
def test_rewrite_keeps_existing_pipeline_and_drops_handwritten() -> None:
last = AIMessage(
content="",
tool_calls=[
{
"id": "c1",
"name": TOOL_NAME,
"args": {"topic": "课题"},
"type": "tool_call",
},
{
"id": "c2",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/report.md", "content": "x"},
"type": "tool_call",
},
],
)
patched = rewrite_handwritten_report_calls(last, topic="课题")
assert patched is not None
assert [call["name"] for call in patched.tool_calls] == [TOOL_NAME]
def test_rewrite_leaves_chat_and_search_alone() -> None:
chat = AIMessage(content="先确认一下研究范围。")
assert rewrite_handwritten_report_calls(chat, topic="课题") is None
search = AIMessage(
content="",
tool_calls=[
{"id": "s1", "name": "web_search", "args": {"query": "课题"}, "type": "tool_call"},
],
)
assert rewrite_handwritten_report_calls(search, topic="课题") is None
pipeline = AIMessage(
content="",
tool_calls=[
{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"},
],
)
assert rewrite_handwritten_report_calls(pipeline, topic="课题") is None
def test_middleware_allows_chat_before_writing() -> None:
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="先聊聊这个课题"),
AIMessage(content="好的,我们先明确范围。"),
]
}
assert middleware.after_model(state, runtime=SimpleNamespace()) is None
def test_middleware_rewrites_write_file_on_after_model() -> None:
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写一份新能源汽车报告"),
AIMessage(
content="",
tool_calls=[
{
"id": "c1",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/report.md", "content": "# 报告"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
patched = update["messages"][0]
assert patched.tool_calls[0]["name"] == TOOL_NAME
assert patched.tool_calls[0]["args"]["topic"].startswith("写一份新能源汽车报告")
def test_wrap_tool_call_rejects_handwritten_report() -> None:
middleware = WritingModeReportMiddleware()
request = SimpleNamespace(
tool_call={
"id": "c1",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/report.md", "content": "x"},
}
)
called = {"handler": False}
def handler(_request):
called["handler"] = True
return ToolMessage(content="wrote", tool_call_id="c1", name="write_file")
result = middleware.wrap_tool_call(request, handler)
assert called["handler"] is False
assert isinstance(result, ToolMessage)
assert result.content.startswith("Error:")
assert TOOL_NAME in result.content
def test_pipeline_success_is_detected() -> None:
messages = [
HumanMessage(content="写一份报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME),
]
assert pipeline_already_succeeded(messages) is True
assert pipeline_already_succeeded(messages[:-1]) is False
failed = [
*messages[:-1],
ToolMessage(content="Error: 深度研究报告生成失败。", tool_call_id="c1", name=TOOL_NAME),
]
assert pipeline_already_succeeded(failed) is False
def test_reread_detects_outputs_markdown() -> None:
assert is_report_reread_call({"name": "read_file", "args": {"path": "/mnt/user-data/outputs/report.md"}, "id": "c1"})
assert is_report_reread_call({"name": "read_file", "args": {"path": "/mnt/user-data/outputs/ai-agents-paper.md"}, "id": "c1"})
assert not is_report_reread_call({"name": "read_file", "args": {"path": "/mnt/user-data/workspace/notes.md"}, "id": "c1"})
assert not is_report_reread_call({"name": "write_file", "args": {"path": "/mnt/user-data/outputs/report.md"}, "id": "c1"})
def test_rewrite_after_success_drops_deliverable_tools() -> None:
last = AIMessage(
content="我再重写一版",
tool_calls=[
{
"id": "c2",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/ai-agents-paper.md", "content": "# 新稿"},
"type": "tool_call",
},
{
"id": "c3",
"name": TOOL_NAME,
"args": {"topic": "课题"},
"type": "tool_call",
},
],
)
patched = rewrite_handwritten_report_calls(last, topic="课题", pipeline_already_done=True)
assert patched is not None
assert patched.tool_calls == []
def test_rewrite_after_success_drops_reread() -> None:
last = AIMessage(
content="先看看报告",
tool_calls=[
{
"id": "c2",
"name": "read_file",
"args": {"path": "/mnt/user-data/outputs/report.md"},
"type": "tool_call",
}
],
)
patched = rewrite_handwritten_report_calls(last, topic="课题", pipeline_already_done=True)
assert patched is not None
assert patched.tool_calls == []
assert patched.content == ""
def test_middleware_nudges_summary_after_successful_pipeline() -> None:
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写一份新能源汽车报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME),
AIMessage(
content="报告太泛化,我要重写",
tool_calls=[
{
"id": "c2",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/ai-agents-paper.md", "content": "# 重写"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
assert update["jump_to"] == "model"
assert update["messages"][0].tool_calls == []
assert update["messages"][0].content == ""
nudge = update["messages"][1]
assert nudge.additional_kwargs["hide_from_ui"] is True
assert nudge.additional_kwargs["writing_mode_summary_nudge"] is True
assert "2-3" in nudge.content
def test_middleware_ends_turn_after_ignored_summary_nudge() -> None:
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写一份新能源汽车报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME),
HumanMessage(
content="请总结",
additional_kwargs={"hide_from_ui": True, "writing_mode_summary_nudge": True},
),
AIMessage(
content="",
tool_calls=[
{
"id": "c2",
"name": "read_file",
"args": {"path": "/mnt/user-data/outputs/report.md"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
assert update["jump_to"] == "end"
assert update["messages"][0].tool_calls == []
def test_wrap_tool_call_rejects_rerun_after_success() -> None:
middleware = WritingModeReportMiddleware()
prior = [
HumanMessage(content="写一份报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME),
]
request = SimpleNamespace(
tool_call={"id": "c2", "name": TOOL_NAME, "args": {"topic": "课题"}},
state={"messages": prior},
)
called = {"handler": False}
def handler(_request):
called["handler"] = True
return ToolMessage(content="ran", tool_call_id="c2", name=TOOL_NAME)
result = middleware.wrap_tool_call(request, handler)
assert called["handler"] is False
assert isinstance(result, ToolMessage)
assert result.content.startswith("Error:")
assert "已经完成" in result.content
assert "请立即调用" not in result.content
def test_wrap_tool_call_rejects_reread_after_success() -> None:
middleware = WritingModeReportMiddleware()
prior = [
HumanMessage(content="写一份报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME),
]
request = SimpleNamespace(
tool_call={
"id": "c2",
"name": "read_file",
"args": {"path": "/mnt/user-data/outputs/report.md"},
},
state={"messages": prior},
)
called = {"handler": False}
def handler(_request):
called["handler"] = True
return ToolMessage(content="read", tool_call_id="c2", name="read_file")
result = middleware.wrap_tool_call(request, handler)
assert called["handler"] is False
assert isinstance(result, ToolMessage)
assert result.content.startswith("Error:")
assert "已经完成" in result.content
def test_wrap_tool_call_allows_unrelated_writes() -> None:
middleware = WritingModeReportMiddleware()
request = SimpleNamespace(
tool_call={
"id": "c1",
"name": "write_file",
"args": {"path": "/mnt/user-data/workspace/notes.md", "content": "x"},
}
)
expected = ToolMessage(content="wrote", tool_call_id="c1", name="write_file")
result = middleware.wrap_tool_call(request, lambda _request: expected)
assert result is expected
def test_extract_topic_skips_clarification_answers() -> None:
"""澄清回答不是课题:向上取真正的写作请求原文。"""
topic = extract_report_topic(
[
HumanMessage(content="写人工智能领域的论文md"),
AIMessage(
content="",
tool_calls=[
{
"id": "q1",
"name": "ask_clarification",
"args": {"question": "方向?"},
"type": "tool_call",
}
],
),
ToolMessage(content="需要澄清:方向?", tool_call_id="q1", name="ask_clarification"),
HumanMessage(content="方向:AI Agent;用途:学术期刊论文;篇幅:短篇2000-4000字"),
AIMessage(
content="",
tool_calls=[
{
"id": "c1",
"name": "write_file",
"args": {"path": "/mnt/user-data/outputs/report.md"},
"type": "tool_call",
}
],
),
]
)
assert topic.startswith("写人工智能领域的论文md")
def test_bash_heredoc_write_is_redirected_to_pipeline() -> None:
"""模型被剥离不了工具时会绕道 bash heredoc 手写报告——同样改写为管线。"""
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写人工智能领域的论文md"),
AIMessage(
content="",
tool_calls=[
{
"id": "b1",
"name": "bash",
"args": {"command": "cat > /mnt/user-data/outputs/report.md << 'ENDOFFILE'\n# 报告全文……\nENDOFFILE"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
patched = update["messages"][0]
assert patched.tool_calls[0]["name"] == TOOL_NAME
assert patched.tool_calls[0]["args"]["topic"].startswith("写人工智能领域的论文md")
def test_bash_python_open_write_is_redirected_to_pipeline() -> None:
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写一份新能源汽车报告"),
AIMessage(
content="",
tool_calls=[
{
"id": "b2",
"name": "bash",
"args": {"command": ("python3 << 'PYEOF'\ncontent = r'''# 报告'''\nwith open('/mnt/user-data/outputs/report.md', 'w', encoding='utf-8') as f:\n f.write(content)\nPYEOF")},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
patched = update["messages"][0]
assert patched.tool_calls[0]["name"] == TOOL_NAME
def test_bash_readonly_and_non_outputs_commands_are_left_alone() -> None:
"""mkdir / wc / cat 读取、非 outputs 的 .md 写入都不是手写报告。"""
middleware = WritingModeReportMiddleware()
for command in (
"mkdir -p /mnt/user-data/outputs",
"wc -m /mnt/user-data/outputs/report.md",
"cat /mnt/user-data/outputs/report.md",
"echo hi > /tmp/scratch.md",
):
state = {
"messages": [
HumanMessage(content="写一份报告"),
AIMessage(
content="",
tool_calls=[{"id": "b3", "name": "bash", "args": {"command": command}, "type": "tool_call"}],
),
]
}
assert middleware.after_model(state, runtime=SimpleNamespace()) is None, command
def test_bash_write_after_success_is_dropped() -> None:
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写一份报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:基于 3 条资料…", tool_call_id="c1", name=TOOL_NAME),
AIMessage(
content="",
tool_calls=[
{
"id": "b4",
"name": "bash",
"args": {"command": "echo '# 新版' > /mnt/user-data/outputs/report.md"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
patched = update["messages"][0]
assert patched.tool_calls == []
nudged = update["messages"][1]
assert getattr(nudged, "additional_kwargs", {}).get("writing_mode_summary_nudge") is True
def test_wrap_tool_call_rejects_bash_report_write() -> None:
"""漏网到执行层的 bash 手写(如裸 report.md 重定向)也要拒绝并引导走管线。"""
middleware = WritingModeReportMiddleware()
request = SimpleNamespace(
tool_call={
"id": "b5",
"name": "bash",
"args": {"command": "cat > report.md << 'EOF'\n# x\nEOF"},
}
)
called = {"handler": False}
def handler(_request):
called["handler"] = True
return ToolMessage(content="ran", tool_call_id="b5", name="bash")
result = middleware.wrap_tool_call(request, handler)
assert called["handler"] is False
assert isinstance(result, ToolMessage)
assert result.content.startswith("Error:")
assert TOOL_NAME in result.content
def test_wrap_model_call_strips_handwrite_tools_from_binding() -> None:
"""首报轮从模型绑定中剥离 write_file/str_replace/bash——bash 是唯一能写
文件的执行类工具(实测模型会把全文起草进 heredoc 参数流式几十秒后才被
after_model 拦截),剥离后模型没有任何可发起文件写入的工具。"""
middleware = WritingModeReportMiddleware()
def make_request(tools):
return SimpleNamespace(
tools=tools,
override=lambda **kwargs: SimpleNamespace(tools=kwargs.get("tools", [])),
)
seen: dict[str, list] = {}
def handler(request):
seen["tools"] = list(request.tools)
return SimpleNamespace()
middleware.wrap_model_call(
make_request(
[
SimpleNamespace(name="write_file"),
SimpleNamespace(name="web_search"),
SimpleNamespace(name="str_replace"),
SimpleNamespace(name="bash"),
SimpleNamespace(name=TOOL_NAME),
]
),
handler,
)
assert [getattr(t, "name", "") for t in seen["tools"]] == ["web_search", TOOL_NAME]
def test_wrap_model_call_passes_through_when_no_handwrite_tools() -> None:
middleware = WritingModeReportMiddleware()
request = SimpleNamespace(
tools=[SimpleNamespace(name="web_search"), SimpleNamespace(name=TOOL_NAME)],
override=lambda **kwargs: SimpleNamespace(tools=kwargs.get("tools", [])),
)
def handler(passed):
assert passed is request
return "unchanged"
assert middleware.wrap_model_call(request, handler) == "unchanged"
def test_extract_focus_joins_clarification_answers() -> None:
focus = extract_report_focus(
[
HumanMessage(content="写人工智能领域的论文md"),
AIMessage(
content="",
tool_calls=[{"id": "q1", "name": "ask_clarification", "args": {"question": "方向?"}, "type": "tool_call"}],
),
ToolMessage(content="需要澄清", tool_call_id="q1", name="ask_clarification"),
HumanMessage(content="主题:LLM的能力边界;类型:观点评论型"),
],
topic="写人工智能领域的论文md",
)
assert "LLM的能力边界" in focus
assert "观点评论型" in focus
assert "写人工智能领域的论文md" not in focus
def test_rewrite_carries_focus_from_clarification_answers() -> None:
"""改写进管线的调用要带上澄清回答(方向/类型/篇幅),否则管线只拿到
原始请求一句泛写。"""
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写人工智能领域的论文md,有不懂的可以问我"),
AIMessage(
content="",
tool_calls=[{"id": "q1", "name": "ask_clarification", "args": {"question": "主题?"}, "type": "tool_call"}],
),
ToolMessage(content="需要澄清", tool_call_id="q1", name="ask_clarification"),
HumanMessage(content="主题:LLM的能力边界;篇幅:3000-5000字"),
AIMessage(
content="",
tool_calls=[
{
"id": "c1",
"name": "bash",
"args": {"command": "cat > /mnt/user-data/outputs/report.md << 'EOF'\n# x\nEOF"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
patched = update["messages"][0]
assert patched.tool_calls[0]["name"] == TOOL_NAME
assert patched.tool_calls[0]["args"]["topic"].startswith("写人工智能领域的论文md")
assert "LLM的能力边界" in patched.tool_calls[0]["args"]["focus"]
def test_second_offense_after_nudge_gets_fallback_summary() -> None:
"""模型无视总结提示仍要继续:丢弃调用后必须留一段兜底总结,不能静默结束。"""
middleware = WritingModeReportMiddleware()
state = {
"messages": [
HumanMessage(content="写一份新能源汽车报告"),
AIMessage(
content="",
tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}],
),
ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME),
HumanMessage(
content="请总结",
additional_kwargs={"hide_from_ui": True, "writing_mode_summary_nudge": True},
),
AIMessage(
content="",
tool_calls=[
{
"id": "c2",
"name": "read_file",
"args": {"path": "/mnt/user-data/outputs/report.md"},
"type": "tool_call",
}
],
),
]
}
update = middleware.after_model(state, runtime=SimpleNamespace())
assert update is not None
assert update["jump_to"] == "end"
patched = update["messages"][0]
assert patched.tool_calls == []
assert "report.md" in patched.content
assert "已完成" in patched.content
@@ -0,0 +1,166 @@
"""Unit tests for the AI 改写 prompt builder (app.gateway.routers.writing).
Focus: the 仿写 (imitate) action injects the reference sample into the prompt,
and existing actions are unaffected.
"""
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.gateway.routers.writing import (
_REFERENCE_MAX_CHARS,
RewriteRequest,
SelectionInfo,
_release_document_rewrite,
_reserve_document_rewrite,
build_rewrite_messages,
)
def _req(**kwargs) -> RewriteRequest:
base = {
"selection": SelectionInfo(**{"from": 0, "to": 4, "text": "原始文字"}),
}
base.update(kwargs)
return RewriteRequest(**base)
def test_imitate_injects_reference_text():
body = _req(action="imitate", reference_text="这是一段范文,节奏明快,排比有力。")
action, messages = build_rewrite_messages(body)
assert action == "imitate"
human = messages[1].content
assert "范文" in human
assert "这是一段范文,节奏明快,排比有力。" in human
assert "原始文字" in human
def test_imitate_without_reference_has_no_reference_block():
body = _req(action="imitate", reference_text=" ")
action, messages = build_rewrite_messages(body)
assert action == "imitate"
# No reference → the 范文 block is not appended
assert "【范文】" not in messages[1].content
def test_imitate_reference_is_truncated():
long_ref = "甲" * (_REFERENCE_MAX_CHARS + 500)
body = _req(action="imitate", reference_text=long_ref)
_, messages = build_rewrite_messages(body)
human = messages[1].content
# The reference is capped — the 500 extra chars are dropped
assert human.count("甲") == _REFERENCE_MAX_CHARS
def test_polish_action_unaffected_by_reference_field():
# A stray reference_text on a non-imitate action must be ignored
body = _req(action="polish", reference_text="不应出现的范文")
action, messages = build_rewrite_messages(body)
assert action == "polish"
assert "范文" not in messages[1].content
assert "不应出现的范文" not in messages[1].content
def test_unknown_action_falls_back_to_polish():
body = _req(action="not-a-real-action")
action, _ = build_rewrite_messages(body)
assert action == "polish"
def test_imitate_follow_up_preserves_reference_and_previous_result():
body = _req(
action="imitate",
reference_text="范文样例",
previous_result="上一版仿写结果",
follow_up_instruction="再正式一点",
)
_, messages = build_rewrite_messages(body)
# system, human(original w/ reference), ai(previous), human(follow-up)
assert len(messages) == 4
assert "范文样例" in messages[1].content
assert messages[2].content == "上一版仿写结果"
assert messages[3].content == "再正式一点"
@pytest.mark.asyncio
async def test_full_document_rewrite_reservation_rejects_a_second_live_request(tmp_path: Path):
"""Two streams must never generate competing commits for one real file."""
path = tmp_path / "report.md"
path.write_text("# report", encoding="utf-8")
assert await _reserve_document_rewrite(path) is True
try:
assert await _reserve_document_rewrite(path) is False
finally:
await _release_document_rewrite(path)
assert await _reserve_document_rewrite(path) is True
await _release_document_rewrite(path)
@pytest.mark.asyncio
async def test_full_document_rewrite_keeps_committed_file_when_undo_snapshot_fails(
monkeypatch,
tmp_path: Path,
):
"""Undo persistence is best effort and must not restore a valid rewrite."""
from app.gateway.routers import writing
path = tmp_path / "report.md"
path.write_text("# 原文\n\n正文", encoding="utf-8")
class _Model:
async def astream(self, *_args, **_kwargs):
yield "# 改写后\n\n新正文"
class _BrokenVersionStore:
async def create(self, **_kwargs):
raise RuntimeError("version store unavailable")
class _Config:
models: list[object] = []
@staticmethod
def get_model_config(_name):
return None
async def resolve_path(*_args):
return path
async def current_user(*_args):
return "user-a"
async def requirements(*_args, **_kwargs):
yield "保留事实。"
monkeypatch.setattr(writing, "aresolve_thread_virtual_path", resolve_path)
monkeypatch.setattr(writing, "get_current_user", current_user)
monkeypatch.setattr(writing, "create_chat_model", lambda **_kwargs: _Model())
monkeypatch.setattr(writing, "_astream_rewrite_requirements", requirements)
monkeypatch.setattr(writing, "_extract_chunk_parts", lambda chunk: ("", chunk))
response = await writing.rewrite_document(
body=writing.DocumentRewriteRequest(
threadId="thread-a",
path="/mnt/user-data/report.md",
instruction="统一表达风格",
),
request=SimpleNamespace(
_deerflow_test_bypass_auth=True,
app=SimpleNamespace(state=SimpleNamespace(document_rewrite_version_store=_BrokenVersionStore())),
),
config=_Config(),
)
events = "".join([frame async for frame in response.body_iterator])
assert path.read_text(encoding="utf-8") == "# 改写后\n\n新正文"
assert "event: committed" in events
assert '"versionSnapshotFailed": true' in events
assert "event: error" not in events