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:
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,
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user