diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
index b876ee17..0b23eaf7 100644
--- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -428,11 +428,15 @@ export function ArtifactFileDetail({
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
- const artifactContentPending =
- isCodeFile &&
- !useWriteFileStreaming &&
- content === undefined &&
- isArtifactContentLoading;
+ const artifactContentPending =
+ isCodeFile &&
+ !useWriteFileStreaming &&
+ content === undefined &&
+ isArtifactContentLoading;
+ // `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
+ // 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
+ const streamingWriteContentPending =
+ useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
@@ -1766,10 +1770,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
- {artifactContentPending && viewMode === "code" && (
-
-
- 正在加载文档内容…
+ {(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
+
+
+ {streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
)}
{artifactContentUnavailable && viewMode === "code" && (
@@ -1787,7 +1791,7 @@ export function ArtifactFileDetail({
)}
- {isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
+ {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
{
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
+
+test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
+ assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
+ assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
+ assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
+ assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
+});
diff --git a/frontend-web/src/core/artifacts/streaming-write-preview.ts b/frontend-web/src/core/artifacts/streaming-write-preview.ts
index c2ec791f..4fc73091 100644
--- a/frontend-web/src/core/artifacts/streaming-write-preview.ts
+++ b/frontend-web/src/core/artifacts/streaming-write-preview.ts
@@ -59,6 +59,23 @@ export function isArtifactApiPath(path: string): boolean {
);
}
+/**
+ * Whether a streamed write is a user-facing deliverable that may open the
+ * artifact panel automatically. Agents often use ``workspace/`` for helper
+ * scripts and intermediate data; previewing those files is noisy and can show
+ * an empty sandbox before the real output exists.
+ */
+export function isAutoPreviewableArtifactPath(path: string): boolean {
+ if (isProvisionalStreamingArtifactPath(path)) {
+ return true;
+ }
+ const normalized = normalizeStreamingArtifactPath(path)
+ .split(/[?#]/, 1)[0]
+ ?.replace(/^\/+/, "")
+ .replace(/\\/g, "/");
+ return normalized === "mnt/user-data/outputs" || normalized?.startsWith("mnt/user-data/outputs/");
+}
+
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts
index 827504e6..a16f5c82 100644
--- a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts
+++ b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts
@@ -208,6 +208,34 @@ test("normal mode keeps previewing pathless streaming drafts", () => {
assert.match(preview?.artifactPath ?? "", /\.md/);
});
+test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
+ const preview = latestInFlightWriteFilePreview([
+ {
+ id: "m1",
+ type: "ai",
+ tool_calls: [
+ {
+ id: "w1",
+ name: "write_file",
+ args: {
+ path: "/mnt/user-data/workspace/scan_structure.py",
+ content: "print('scan')",
+ },
+ },
+ {
+ id: "w2",
+ name: "write_file",
+ args: {
+ path: "/mnt/user-data/workspace/draft.html",
+ content: "",
+ },
+ },
+ ],
+ },
+ ]);
+ assert.equal(preview, null);
+});
+
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([
{
diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.ts b/frontend-web/src/core/artifacts/write-file-in-flight.ts
index 477af28e..07cc111a 100644
--- a/frontend-web/src/core/artifacts/write-file-in-flight.ts
+++ b/frontend-web/src/core/artifacts/write-file-in-flight.ts
@@ -4,6 +4,7 @@ import { isImageFile } from "../utils/files";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
+ isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
@@ -140,6 +141,9 @@ export function latestInFlightWriteFilePreview(
if (!languageOfStreamingArtifact(sourcePath)) {
continue;
}
+ if (!isAutoPreviewableArtifactPath(sourcePath)) {
+ continue;
+ }
return {
artifactPath: buildWriteFileVirtualUrl({
path: sourcePath,
diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx
index 7121f32b..f6b02c5e 100644
--- a/frontend-web/src/pages/AgentChatPage.tsx
+++ b/frontend-web/src/pages/AgentChatPage.tsx
@@ -38,6 +38,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
+import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks";
@@ -92,6 +93,8 @@ import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
+/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */
+const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
@@ -226,6 +229,7 @@ export default function AgentChatPage({
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
+ const { baseMode } = useTheme();
const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter();
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
@@ -677,13 +681,18 @@ export default function AgentChatPage({
settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath }
: {};
+ const pageReportThemeContext =
+ agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
+ ? { presentation_theme: baseMode }
+ : {};
void sendMessage(threadId, message, {
...writingExtraContext,
...(extraContext ?? {}),
agent_id,
+ ...pageReportThemeContext,
});
},
- [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath],
+ [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -757,9 +766,14 @@ export default function AgentChatPage({
async (answer: string) => {
setHasStartedConversation(true);
await stopActiveThreadBeforeClarificationReply(threadId, thread);
- await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
+ await sendMessage(threadId, { text: answer, files: [] }, {
+ agent_id,
+ ...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
+ ? { presentation_theme: baseMode }
+ : {}),
+ });
},
- [sendMessage, thread, threadId, agent_id],
+ [sendMessage, thread, threadId, agent_id, baseMode],
);
const handleStop = useCallback(async () => {
diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts
index 2a574f35..5f3b309b 100644
--- a/frontend-web/src/roundtable-planning/api/multi-agent.ts
+++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts
@@ -809,8 +809,20 @@ export async function streamMultiAgent(
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
ingestToolCallChunks(toolCallChunks);
+ // Some providers serialize `tool_calls` before their `args` have
+ // been decoded, leaving args as the raw JSON string. Treating that
+ // as a complete call suppresses the accumulated tool_call_chunks,
+ // so write_file reaches the sandbox with a path but no content.
const hasCompleteToolCalls =
- Array.isArray(toolCalls) && toolCalls.length > 0;
+ Array.isArray(toolCalls) &&
+ toolCalls.length > 0 &&
+ toolCalls.every(
+ (toolCall) =>
+ !!toolCall &&
+ typeof toolCall === "object" &&
+ !!(toolCall as Record).args &&
+ typeof (toolCall as Record).args === "object",
+ );
let synthetic: SyntheticToolCall[] = [];
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
synthetic = buildSyntheticToolCalls();
diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
index 55212c66..dc6abc97 100644
--- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
@@ -36,6 +36,7 @@ import { uploadFiles } from "@/core/uploads/api";
import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview";
import {
collectRoundtableDeliverablePaths,
+ mergeRoundtableStreamingToolArgs,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
@@ -48,15 +49,21 @@ type RunFileRef = NonNullable[number];
type SeatDelivery = { name: string; content: string };
/**
- * `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活,
- * 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。
+ * `agent_orchestration` / 写文件工具可在一轮内重复调用。用 tool call id 区分每一笔
+ * 派活或写入,避免用户点击较早的步骤时误打开后来同名调用的文件。
*/
function streamStepKey(
phase: StreamPhase,
toolName?: string,
toolCallId?: string,
): string {
- if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) {
+ if (
+ phase === "tool_calling" &&
+ (toolName === "agent_orchestration" ||
+ toolName === "write_file" ||
+ toolName === "str_replace") &&
+ toolCallId
+ ) {
return `${phase}:${toolName}:${toolCallId}`;
}
return `${phase}:${toolName ?? ""}`;
@@ -912,7 +919,12 @@ export function useStep2Orchestration(
sourceToolCallId:
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
name: toolName,
- args,
+ // 进度步骤只保存可见参数(通常只有 path),不能让一次手动点击把先前
+ // 流进来的 content 覆盖掉;同理也要保住 content-first 帧随后补齐的 path。
+ args:
+ idx >= 0
+ ? mergeRoundtableStreamingToolArgs(baseMsg.tool_calls[idx].args, args)
+ : { ...args },
};
const nextToolCalls =
idx >= 0
@@ -1067,12 +1079,16 @@ export function useStep2Orchestration(
const findSeatArtifactSource = useCallback(
(
rawPath: string,
+ preferredToolCallId?: string,
): {
seatThreadId: string;
messageId: string;
toolCallId: string;
/** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */
content?: string;
+ isWriteFile: boolean;
+ /** 当前 write_file 尚未收到对应 tool result,真实文件可能还没有落盘。 */
+ inFlight: boolean;
} | null => {
const target = rawPath.trim();
if (!target) return null;
@@ -1081,30 +1097,70 @@ export function useStep2Orchestration(
const c = (tc.args as { content?: unknown })?.content;
return typeof c === "string" ? c : undefined;
};
+ let exactMatch:
+ | {
+ seatThreadId: string;
+ messageId: string;
+ toolCallId: string;
+ content?: string;
+ isWriteFile: boolean;
+ inFlight: boolean;
+ }
+ | null = null;
let basenameMatch:
- | { seatThreadId: string; messageId: string; toolCallId: string; content?: string }
+ | {
+ seatThreadId: string;
+ messageId: string;
+ toolCallId: string;
+ content?: string;
+ isWriteFile: boolean;
+ inFlight: boolean;
+ }
| null = null;
for (const [seatThreadId, msgs] of Object.entries(
seatStubMessagesRef.current,
)) {
const msg = msgs?.[0];
if (!msg) continue;
- for (const tc of msg.tool_calls) {
+ // 同一路径可能在同一轮被重写;倒序保证文件卡/步骤优先打开最后一次写入。
+ for (let toolIndex = msg.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
+ const tc = msg.tool_calls[toolIndex];
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
+ const isWriteFile = tc.name === "write_file";
+ const inFlight =
+ isWriteFile &&
+ !msgs.some(
+ (message) => message.type === "tool" && message.tool_call_id === tc.id,
+ );
+ const source = {
+ seatThreadId,
+ messageId: msg.id,
+ toolCallId: tc.id,
+ content: readContent(tc),
+ isWriteFile,
+ inFlight,
+ };
if (p === target) {
- return { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) };
+ if (
+ preferredToolCallId &&
+ (tc.id === preferredToolCallId || tc.sourceToolCallId === preferredToolCallId)
+ ) {
+ return source;
+ }
+ if (!exactMatch) exactMatch = source;
+ continue;
}
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
- basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) };
+ basenameMatch = source;
}
}
}
- return basenameMatch;
+ return exactMatch ?? basenameMatch;
},
[],
);
@@ -1119,11 +1175,15 @@ export function useStep2Orchestration(
if (!normalized) return;
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
- // 先反查 stub:**仅当 stub 内存里确有内容**(流式期间 / 草稿恢复时捕获了 write_file content)
- // 才走虚拟预览——关闭沙箱后再点也能显示,且不受总控 coordinator thread 在 artifacts 路由
- // not found 的影响。
+ // 先反查 stub:write_file 尚未完成时即使正文还没有抵达,也必须留在虚拟预览。
+ // 真实文件尚未写盘时错误走 HTTP 会造成“点文件 → 加载 → 找不到文件”;正文到达后
+ // stub 的同一 tool call 会自动刷新预览。完成且无正文的旧草稿才降级走 HTTP。
const seatSource = findSeatArtifactSource(normalized);
- if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) {
+ if (
+ seatSource &&
+ seatSource.isWriteFile &&
+ (seatSource.inFlight || typeof seatSource.content === "string")
+ ) {
const virtualUrl = buildWriteFileVirtualUrl({
path: normalized,
messageId: seatSource.messageId,
@@ -1270,18 +1330,41 @@ export function useStep2Orchestration(
) => {
const seatThreadId = dialogue.agentThreadId;
if (!seatThreadId) return;
- const [, toolName] = step.key.split(":");
- if (toolName === "write_file" || toolName === "str_replace") {
+ const [, toolName, sourceToolCallId] = step.key.split(":");
+ if (toolName === "write_file") {
if (!step.args) return;
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
if (!path) return;
- const { messageId, toolCallId } = upsertSeatToolCall(
+ // 点击步骤优先复用已由 upstream frame 建好的同一条 stub,绝不再次用
+ // `step.args`(只有路径)写回它。否则会把正在接收的正文覆盖为空。
+ const source = findSeatArtifactSource(path, sourceToolCallId);
+ if (
+ source &&
+ source.isWriteFile &&
+ (source.inFlight || typeof source.content === "string")
+ ) {
+ const virtualUrl = buildWriteFileVirtualUrl({
+ path,
+ messageId: source.messageId,
+ toolCallId: source.toolCallId,
+ });
+ openSeatVirtualArtifact(source.seatThreadId, virtualUrl, false);
+ return;
+ }
+ // 极早期还没拿到 upstream frame 的兜底:先建虚拟调用,后续帧会合并内容。
+ const { messageId, toolCallId } = upsertSeatToolCall(seatThreadId, toolName, step.args);
+ openSeatVirtualArtifact(
seatThreadId,
- toolName,
- step.args,
+ buildWriteFileVirtualUrl({ path, messageId, toolCallId }),
+ false,
);
- const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
- openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
+ return;
+ }
+ // str_replace 没有完整 content,不适合虚拟 write_file 预览;它的文件已存在,
+ // 直接走真实产物读取,避免打开一个永远为空的虚拟编辑器。
+ if (toolName === "str_replace") {
+ const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
+ if (path) openArtifactPreview(seatThreadId, path);
return;
}
// read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能
@@ -1298,6 +1381,7 @@ export function useStep2Orchestration(
},
[
buildWriteFileVirtualUrl,
+ findSeatArtifactSource,
openArtifactPreview,
openSeatVirtualArtifact,
upsertSeatToolCall,
@@ -2192,7 +2276,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(a.bubbleId, label);
- appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
+ appendStepIfNew(a.bubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
},
onUpstreamFrame: (frame) => {
@@ -2662,7 +2746,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
- appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
+ appendStepIfNew(subBubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
},
onUpstreamFrame: (frame) => {
@@ -3081,7 +3165,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
- appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
+ appendStepIfNew(bubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
},
onUpstreamFrame: (frame) => {
@@ -3293,7 +3377,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
- appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
+ appendStepIfNew(bubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
},
onUpstreamFrame: (frame) => {
diff --git a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts
index 7348ccfe..1ee83846 100644
--- a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts
+++ b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts
@@ -3,11 +3,29 @@ import test from "node:test";
import {
collectRoundtableDeliverablePaths,
+ mergeRoundtableStreamingToolArgs,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
} from "./streaming-artifacts";
+test("roundtable keeps streamed file bytes when a compact progress step only carries path", () => {
+ assert.deepEqual(
+ mergeRoundtableStreamingToolArgs(
+ { path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" },
+ { path: "/mnt/user-data/outputs/report.md" },
+ ),
+ { path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" },
+ );
+ assert.deepEqual(
+ mergeRoundtableStreamingToolArgs(
+ { content: "# 内容先到" },
+ { path: "/mnt/user-data/outputs/report.md", content: "" },
+ ),
+ { path: "/mnt/user-data/outputs/report.md", content: "# 内容先到" },
+ );
+});
+
test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
diff --git a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts
index 2ea40a43..ff756ee1 100644
--- a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts
+++ b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts
@@ -42,6 +42,37 @@ export function resolveRoundtableStreamingWritePreview(
return languageOfStreamingArtifact(path) ? path : null;
}
+/**
+ * Merge successive snapshots of one streaming tool call without losing bytes
+ * that arrived in an earlier SSE frame.
+ *
+ * LangChain's partial JSON snapshots frequently contain only `path` while the
+ * complete `content` has already been received (and the inverse can happen
+ * while the path key is still incomplete). A click on a progress step also
+ * carries the deliberately compact `{ path }` payload. Blindly replacing the
+ * prior args with either shape empties the virtual write_file preview.
+ */
+export function mergeRoundtableStreamingToolArgs(
+ previous: Record | undefined,
+ incoming: Record,
+): Record {
+ if (!previous) return { ...incoming };
+
+ const merged = { ...previous, ...incoming };
+ for (const key of ["path", "content"] as const) {
+ const prior = previous[key];
+ const next = incoming[key];
+ if (
+ typeof prior === "string" &&
+ prior.length > 0 &&
+ !(typeof next === "string" && next.length > 0)
+ ) {
+ merged[key] = prior;
+ }
+ }
+ return merged;
+}
+
/** Final artifact cards intentionally expose only user-facing deliverables. */
export function collectRoundtableDeliverablePaths(values: Iterable): string[] {
const seen = new Set();