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

@@ -101,6 +101,9 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const ragModeEnabled =
@@ -392,7 +395,7 @@ const ChatBox: React.FC<{
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages)) {
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
@@ -429,6 +432,7 @@ const ChatBox: React.FC<{
availableArtifacts,
disableArtifacts,
thread.messages,
writingModeEnabled,
]);
// 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
? null
: latestInFlightWriteFilePreview(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
);
useEffect(() => {
if (!streamingWritePreview) return;
@@ -101,8 +101,14 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
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 skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -849,7 +855,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] {
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
if (message.type === "ai") {
@@ -879,7 +885,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files"
) &&
isInterceptedWritingReportWrite(writePath, message.id, messages)
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
) {
continue;
}
@@ -61,7 +61,27 @@ test("latestInFlightWriteFilePreview keeps markdown when a later json write is i
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([
{
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", () => {
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",
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",
},
},
},
],
},
]);
],
},
],
true,
);
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",
},
},
],
},
],
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([
{
id: "research-report-j1",
id: "m1",
type: "ai",
tool_calls: [
{
id: "research-report-j1-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告\n",
},
},
{ id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/);
assert.match(preview?.artifactPath ?? "", /\.md/);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
@@ -38,7 +38,10 @@ type WriteFileThreadLike = {
messages: Array<{
id?: string;
type?: string;
name?: string;
content?: unknown;
tool_call_id?: string;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{
id?: string;
name?: string;
@@ -95,6 +98,7 @@ export function isWriteFileInFlight(
*/
export function latestInFlightWriteFilePreview(
messages: WriteFileThreadLike["messages"],
writingMode = false,
): { artifactPath: string; previewKey: string } | null {
const thread = { messages };
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
@@ -125,8 +129,9 @@ export function latestInFlightWriteFilePreview(
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)) {
// Opening that filename would 404-poll until report.md lands. Normal
// mode never skips: the write is a real deliverable, not a rewrite.
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
continue;
}
// 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 !== virtualUrl &&
current !== RESEARCH_REPORT_OUTPUT_PATH &&
!isInterceptedWritingReportWrite(currentPath);
// research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
if (keepUserSelection) {
return;
}
@@ -9,18 +9,33 @@ import {
test("custom outputs markdown is always intercepted except the research stub", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true),
true,
);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"research-report-j1",
undefined,
true,
),
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", () => {
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
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",
"m1",
beforePipeline,
true,
),
true,
);
@@ -42,7 +58,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
];
assert.equal(isWritingFirstReportTurn(inFlight), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight),
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true),
true,
);
@@ -56,7 +72,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
];
assert.equal(isWritingFirstReportTurn(succeeded), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded),
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true),
true,
);
});
@@ -70,9 +86,27 @@ test("sandbox auto-open waits for report.md on the first-report turn", () => {
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true),
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(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
true,
@@ -95,7 +129,7 @@ test("a later user turn releases report.md live edits", () => {
];
assert.equal(isWritingFirstReportTurn(followUp), false);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp),
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true),
false,
);
});
+17 -2
View File
@@ -67,12 +67,19 @@ export function isWritingFirstReportTurn(
* 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.
*
* 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(
path: string | null | undefined,
messageId?: string | null,
messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean {
if (!writingMode) {
return false;
}
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
return false;
}
@@ -85,18 +92,26 @@ export function isInterceptedWritingReportWrite(
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(
path: string | null | undefined,
messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean {
if (!path) {
return false;
}
if (!writingMode) {
return true;
}
if (isWritingFirstReportTurn(messages)) {
return isWritingModeReportPath(path);
}
return !isInterceptedWritingReportWrite(path, undefined, messages);
return !isInterceptedWritingReportWrite(path, undefined, messages, true);
}
export function resolveWritingModeReportPath(