From f0ddade8a749d7021937c3a7241005a38e33f301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 31 Aug 2026 16:45:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(roundtable):=20=E5=A2=9E=E5=BC=BA=E5=A4=9A?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=20Step2=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E4=B8=8E=E4=BA=A7=E7=89=A9=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交内容:将 roundtable 的流式消息拆分为文本与 think 推理,支持思考内容以 形式内联渲染,解决仅返回 reasoning token 时的内容空洞问题。新增 stream-reasoning 工具与单元测试,保证 reasoning 首包到正文的闭合行为稳定。 交互与预览修正:write_file/str_replace 工具的沙箱打开策略改为更接近实时席位行为,不再因为文件后缀限制阻塞临时脚本写入可视化;通过新增 streaming-artifacts 工具统一处理可预览路径、最终交付产物过滤与目录快照比对,支持 bash/脚本直接落盘场景在流式阶段自动发现新增文档。 体验细节:步骤面板文件卡片从工具路径与最终 artifact 统一收敛,避免 read_file 点击误触发;自动滚动逻辑新增布局高度抖动识别,防止模型推理渲染导致的误判向上滚动。 --- .../roundtable-planning/api/multi-agent.ts | 32 +- .../components/MessageStepsCard.tsx | 17 +- .../components/Step2Panel.tsx | 17 +- .../hooks/useAutoScroll.ts | 20 +- .../hooks/useStep2Orchestration.ts | 349 +++++++++++++----- .../lib/stream-reasoning.test.ts | 21 ++ .../lib/stream-reasoning.ts | 30 ++ .../lib/streaming-artifacts.test.ts | 107 ++++++ .../lib/streaming-artifacts.ts | 113 ++++++ 9 files changed, 600 insertions(+), 106 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/lib/stream-reasoning.test.ts create mode 100644 frontend-web/src/roundtable-planning/lib/stream-reasoning.ts create mode 100644 frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts create mode 100644 frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 9c0df9b4..2a574f35 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report"; +import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning"; export type ThreadIdMap = Record; @@ -469,7 +470,9 @@ function isFinalStatusFrame(value: unknown): value is { * structured content blocks. Returns null when the frame is not an AI text * chunk (e.g., tool calls, tool results, value snapshots). */ -function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { +function extractAiTextDelta( + parsed: unknown, +): { content: string; reasoning: string; id: string } | null { if (!Array.isArray(parsed) || parsed.length < 1) return null; const chunk = parsed[0]; if (!chunk || typeof chunk !== "object") return null; @@ -492,9 +495,18 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul } } } - if (!text) return null; + const additionalKwargs = obj.additional_kwargs; + const directReasoning = obj.reasoning_content ?? obj.reasoning; + const nestedReasoning = + additionalKwargs && typeof additionalKwargs === "object" + ? (additionalKwargs as Record).reasoning_content ?? + (additionalKwargs as Record).reasoning + : undefined; + const rawReasoning = nestedReasoning ?? directReasoning; + const reasoning = typeof rawReasoning === "string" ? rawReasoning : ""; + if (!text && !reasoning) return null; const id = typeof obj.id === "string" ? obj.id : ""; - return { text, id }; + return { content: text, reasoning, id }; } /** @@ -623,6 +635,10 @@ export async function streamMultiAgent( let currentPhaseArgsSignature = ""; let currentPhaseToolCallId = ""; req.onPhaseChange?.("connecting"); + // The normal chat renders provider reasoning from a distinct message field. + // Roundtable's bespoke SSE reader keeps that field inline so its existing + // `` renderer remains the single display path for Steps 2 and 3. + const inlineAiDelta = createInlineReasoningDeltaFormatter(); const setPhase = ( next: StreamPhase, name?: string, @@ -788,6 +804,7 @@ export async function streamMultiAgent( const toolCalls = chunkObj.tool_calls; const toolCallChunks = chunkObj.tool_call_chunks; const content = chunkObj.content; + const streamDelta = extractAiTextDelta(parsed); // 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。 // 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。 @@ -843,7 +860,9 @@ export async function streamMultiAgent( setPhase("tool_calling", toolName, args, toolCallId); } else if ( (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && - ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) + (Boolean(streamDelta) || + (typeof content === "string" && content.length > 0) || + (Array.isArray(content) && content.length > 0)) ) { setPhase("streaming"); } @@ -911,8 +930,9 @@ export async function streamMultiAgent( const delta = extractAiTextDelta(parsed); if (delta) { - if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`); - req.onTextDelta?.(delta.text, delta.id); + const text = inlineAiDelta(delta); + if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`); + if (text) req.onTextDelta?.(text, delta.id); } req.onUpstreamFrame?.(parsed); diff --git a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx index 95a281a9..825e7aa0 100644 --- a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx +++ b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx @@ -22,7 +22,7 @@ import { ChainOfThoughtStep, } from "@/components/ai-elements/chain-of-thought"; import { Button } from "@/components/ui/button"; -import { languageOfStreamingArtifact } from "@/core/artifacts/streaming-write-preview"; +import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts"; import { cn } from "@/lib/utils"; import { getStepDisplay, @@ -178,17 +178,16 @@ function ToolStep({ const display = getStepDisplay(step.key, step.label, step.args); const argNode = renderStepArgs(step.key, step.args, resolveAgentName); const [, toolName] = step.key.split(":"); - // 只让「自己生成/展示的文件」可点,与主聊天页一致。read_file 不可点 —— 否则智能体 - // 调用技能时读取的 SOUL.md、执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。 - // write_file / str_replace 还要过与文件卡同一道门槛(`languageOfStreamingArtifact`, - // 只认 .md/.html/.mermaid 等可预览成品):席位写的过程文件(fetch.py / r1.txt)与 - // 畸形路径(mnt/user-data/outputs)只作步骤展示、点了也不会打开沙箱。 - // present_files 由后端校验"outputs 下真实存在",保持可点。 + // 步骤条是工具活动的实时视图:所有 write_file 都应能打开沙箱,包括为后续执行 + // 而创建的 .py/.txt 等过程文件。消息气泡下的最终文件卡片才收紧为用户可交付类型。 + // read_file 仍不可点,避免技能/SOUL 等输入文件被误当作产物。 + // str_replace 沿用原有的文档预览范围(它没有完整写入内容可供虚拟沙箱读取)。 const stepPath = typeof step.args?.path === "string" ? step.args.path : ""; const isArtifactStep = toolName === "present_files" || - ((toolName === "write_file" || toolName === "str_replace") && - !!languageOfStreamingArtifact(stepPath)); + toolName === "write_file" || + (toolName === "str_replace" && + collectRoundtableDeliverablePaths([stepPath]).length > 0); return ( 0; - const presentedFiles = collectPresentedFiles(msg.steps); + const presentedFiles = collectPresentedFiles(msg.steps, msg.artifactPaths); const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0"); // 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。 @@ -1193,23 +1193,25 @@ function RoundtableSubtaskCard({ * 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了 * 成品文件就出可点的卡片"。 * - * write_file/str_replace 的 path 需过 `languageOfStreamingArtifact`(只认 .md/.html/ + * write_file/str_replace 的 path 需过同一套交付物门槛(只认 .md/.html/ * .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套): * 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。 */ function collectPresentedFiles( steps: Step2Dialogue["steps"], + settledArtifactPaths?: string[], ): string[] { - if (!Array.isArray(steps) || steps.length === 0) return []; + const toolSteps = Array.isArray(steps) ? steps : []; + if (toolSteps.length === 0 && (!settledArtifactPaths || settledArtifactPaths.length === 0)) return []; const out: string[] = []; const seen = new Set(); const add = (fp: unknown, requirePreviewable = false) => { if (typeof fp !== "string" || !fp || seen.has(fp)) return; - if (requirePreviewable && !languageOfStreamingArtifact(fp)) return; + if (requirePreviewable && collectRoundtableDeliverablePaths([fp]).length === 0) return; seen.add(fp); out.push(fp); }; - for (const step of steps) { + for (const step of toolSteps) { if (step.key === "tool_calling:present_files") { const fps = step.args?.filepaths; if (Array.isArray(fps)) fps.forEach((fp) => add(fp)); @@ -1222,6 +1224,9 @@ function collectPresentedFiles( add(step.args?.path, true); } } + // A weak model may create a report through bash rather than present_files; + // the completed dialogue adds only real, user-facing document paths here. + settledArtifactPaths?.forEach((path) => add(path, true)); return out; } diff --git a/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts index 7c8e6679..36fd7aeb 100644 --- a/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts +++ b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts @@ -53,16 +53,22 @@ export function useAutoScroll( // - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随 // - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随 const lastObservedScrollTopRef = useRef(0); + // 思考块 / Markdown 代码块完成重排时,浏览器可能在没有用户输入的情况下 + // clamp scrollTop。记录高度后可把这种布局变化与真实向上滚动区分开。 + const lastObservedScrollHeightRef = useRef(0); useEffect(() => { const el = ref.current; if (!el) return; let prevScrollTop = el.scrollTop; lastObservedScrollTopRef.current = prevScrollTop; + lastObservedScrollHeightRef.current = el.scrollHeight; const onScroll = () => { const currentScrollTop = el.scrollTop; + const currentScrollHeight = el.scrollHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; const isAtBottom = distanceFromBottom <= threshold; + const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current; const programmaticTarget = programmaticScrollTargetRef.current; if ( @@ -73,6 +79,7 @@ export function useAutoScroll( programmaticScrollTargetRef.current = null; prevScrollTop = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop; + lastObservedScrollHeightRef.current = currentScrollHeight; // 程序滚动后通常落在底部,把 atBottom 维持 true。 if (!atBottomRef.current && isAtBottom) { atBottomRef.current = true; @@ -86,7 +93,7 @@ export function useAutoScroll( // 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**, // 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。 - if (currentScrollTop < prevScrollTop && atBottomRef.current) { + if (currentScrollTop < prevScrollTop && atBottomRef.current && !layoutChanged) { atBottomRef.current = false; setAtBottom(false); } else if (isAtBottom && !atBottomRef.current) { @@ -99,6 +106,7 @@ export function useAutoScroll( // 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。 prevScrollTop = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop; + lastObservedScrollHeightRef.current = currentScrollHeight; }; el.addEventListener("scroll", onScroll, { passive: true }); // 初次测量 @@ -116,21 +124,25 @@ export function useAutoScroll( const el = ref.current; if (!el) return; const currentScrollTop = el.scrollTop; + const currentScrollHeight = el.scrollHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; // 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。 const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1; - if (userScrolledUp && distanceFromBottom > threshold) { + const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current; + if (userScrolledUp && !layoutChanged && distanceFromBottom > threshold) { // 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。 if (atBottomRef.current) { atBottomRef.current = false; setAtBottom(false); } lastObservedScrollTopRef.current = currentScrollTop; + lastObservedScrollHeightRef.current = currentScrollHeight; return; } if (!atBottomRef.current) { // 之前已退出自动跟随,不要因为内容增长又把用户拽回去。 lastObservedScrollTopRef.current = currentScrollTop; + lastObservedScrollHeightRef.current = currentScrollHeight; return; } // 在底部 (或仅是内容刚增长) → 同步拉到新底部。 @@ -140,6 +152,7 @@ export function useAutoScroll( ); el.scrollTop = el.scrollHeight; lastObservedScrollTopRef.current = el.scrollTop; + lastObservedScrollHeightRef.current = el.scrollHeight; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); @@ -165,6 +178,7 @@ export function useAutoScroll( ); el.scrollTop = el.scrollHeight; lastObservedScrollTopRef.current = el.scrollTop; + lastObservedScrollHeightRef.current = el.scrollHeight; }); }); observer.observe(content); @@ -185,6 +199,7 @@ export function useAutoScroll( el.scrollHeight - el.clientHeight, ); el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + lastObservedScrollHeightRef.current = el.scrollHeight; atBottomRef.current = true; setAtBottom(true); }, [ref]); @@ -201,6 +216,7 @@ export function useAutoScroll( ); el.scrollTop = el.scrollHeight; lastObservedScrollTopRef.current = el.scrollTop; + lastObservedScrollHeightRef.current = el.scrollHeight; atBottomRef.current = true; setAtBottom(true); }, [ref]); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 5b70d648..55212c66 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -22,6 +22,7 @@ import { } from "@/components/workspace/artifacts"; import { initMultiAgent, + listSessionArtifacts, streamMultiAgent, type ModelSwitchInfo, type ClarificationPayload, @@ -32,7 +33,13 @@ import { } from "@/roundtable-planning/api/multi-agent"; import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning"; import { uploadFiles } from "@/core/uploads/api"; -import { languageOfStreamingArtifact } from "@/core/artifacts/streaming-write-preview"; +import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview"; +import { + collectRoundtableDeliverablePaths, + newestChangedRoundtableArtifact, + resolveRoundtableStreamingWritePreview, + roundtableArtifactDiskSnapshot, +} from "@/roundtable-planning/lib/streaming-artifacts"; import { useUpdateSubtask } from "@/core/tasks/context"; /** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */ @@ -167,6 +174,8 @@ export type Step2Dialogue = { streaming?: boolean; /** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */ agentThreadId?: string; + /** Stream settlement scans the real sandbox output directory for these cards. */ + artifactPaths?: string[]; /** * 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具 * 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片 @@ -204,6 +213,8 @@ export interface ParallelSeatView { */ export interface StubToolCall { id: string; + /** Original LangGraph id when a pre-id stream chunk needed a local fallback. */ + sourceToolCallId?: string; name: string; args: Record; } @@ -227,25 +238,34 @@ export function completeStubWriteFile( messages: StubAiMessage[], toolCallId: string, ): StubAiMessage[] { - if (!toolCallId || messages.some((message) => message.type === "tool" && message.tool_call_id === toolCallId)) { + if (!toolCallId) { return messages; } - const source = messages.find( - (message) => - message.type === "ai" && - message.tool_calls.some( - (toolCall) => toolCall.id === toolCallId && toolCall.name === "write_file", - ), + const source = messages.find((message) => + message.type === "ai" && + message.tool_calls.some( + (toolCall) => + toolCall.name === "write_file" && + (toolCall.id === toolCallId || toolCall.sourceToolCallId === toolCallId), + ), ); - if (!source) return messages; + const matchedCall = source?.tool_calls.find( + (toolCall) => + toolCall.name === "write_file" && + (toolCall.id === toolCallId || toolCall.sourceToolCallId === toolCallId), + ); + if (!source || !matchedCall) return messages; + if (messages.some((message) => message.type === "tool" && message.tool_call_id === matchedCall.id)) { + return messages; + } return [ ...messages, { - id: `roundtable-stub-tool-result-${source.id}-${toolCallId}`, + id: `roundtable-stub-tool-result-${source.id}-${matchedCall.id}`, type: "tool", content: "", tool_calls: [], - tool_call_id: toolCallId, + tool_call_id: matchedCall.id, }, ]; } @@ -591,6 +611,12 @@ export function useStep2Orchestration( }, [artifacts]); /** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */ const hasAutoOpenedRef = useRef>(new Set()); + /** 技能脚本内部落盘没有 write_file SSE;保留目录快照以发现新增/覆盖文件。 */ + const artifactDirectorySnapshotsRef = useRef< + Map> + >(new Map()); + /** 每轮、每线程、每路径只自动打开一次,尊重用户随后手动关闭沙箱。 */ + const autoOpenedDiscoveredArtifactsRef = useRef>(new Set()); // ─── State ───────────────────────────────────────────────────────── const [activeArtifactThreadId, setActiveArtifactThreadId] = useState( @@ -855,6 +881,7 @@ export function useStep2Orchestration( seatThreadId: string, toolName: string, args: Record, + sourceToolCallId?: string, ): { messageId: string; toolCallId: string } => { const path = typeof args.path === "string" ? args.path : ""; const current = seatStubMessagesRef.current[seatThreadId] ?? []; @@ -865,17 +892,25 @@ export function useStep2Orchestration( content: "", tool_calls: [], }; - const idx = baseMsg.tool_calls.findIndex( + const bySourceId = sourceToolCallId + ? baseMsg.tool_calls.findIndex( + (tc) => tc.id === sourceToolCallId || tc.sourceToolCallId === sourceToolCallId, + ) + : -1; + const byPath = baseMsg.tool_calls.findIndex( (tc) => tc.name === toolName && String((tc.args as { path?: unknown })?.path ?? "") === path, ); + const idx = bySourceId >= 0 ? bySourceId : byPath; const toolCallId = idx >= 0 ? baseMsg.tool_calls[idx].id - : `tc-${seatThreadId}-${baseMsg.tool_calls.length}`; + : sourceToolCallId || `tc-${seatThreadId}-${baseMsg.tool_calls.length}`; const nextToolCall: StubToolCall = { id: toolCallId, + sourceToolCallId: + sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined), name: toolName, args, }; @@ -990,19 +1025,9 @@ export function useStep2Orchestration( [openStreamedArtifact, selectArtifact, setArtifactsOpen], ); - const buildWriteFileVirtualUrl = useCallback( - (path: string, messageId: string, toolCallId: string) => - new URL( - `write-file:${path}?message_id=${encodeURIComponent( - messageId, - )}&tool_call_id=${encodeURIComponent(toolCallId)}`, - ).toString(), - [], - ); - /** * sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。 - * 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。 + * 从该 thread 的 stub messages 里倒序找第一个可预览的 write_file / str_replace。 * 无产物返回 false(调用方据此关闭沙箱)。 */ const openSeatLatestArtifact = useCallback( @@ -1012,15 +1037,13 @@ export function useStep2Orchestration( for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) { const tc = stub.tool_calls[i]; if (tc.name !== "write_file" && tc.name !== "str_replace") continue; - const path = - typeof (tc.args as { path?: unknown })?.path === "string" - ? ((tc.args as { path?: string }).path as string) - : ""; + const args = + tc.args && typeof tc.args === "object" + ? (tc.args as Record) + : undefined; + const path = resolveRoundtableStreamingWritePreview(tc.name, args); if (!path) continue; - // 同 handleArtifactToolPhase 的门槛:跳过 .py/.txt 等过程文件与畸形路径, - // 沙箱跟随只落在该席位最近的**成品**文件上。 - if (!languageOfStreamingArtifact(path)) continue; - const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id); + const virtualUrl = buildWriteFileVirtualUrl({ path, messageId: stub.id, toolCallId: tc.id }); // manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化 // 后续实时自动打开,见 openSeatVirtualArtifact 注释)。 openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false }); @@ -1101,11 +1124,11 @@ export function useStep2Orchestration( // not found 的影响。 const seatSource = findSeatArtifactSource(normalized); if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) { - const virtualUrl = buildWriteFileVirtualUrl( - normalized, - seatSource.messageId, - seatSource.toolCallId, - ); + const virtualUrl = buildWriteFileVirtualUrl({ + path: normalized, + messageId: seatSource.messageId, + toolCallId: seatSource.toolCallId, + }); openSeatVirtualArtifact( seatSource.seatThreadId, virtualUrl, @@ -1150,6 +1173,96 @@ export function useStep2Orchestration( ], ); + // 对齐普通聊天的产物目录刷新:技能可以通过 bash / Python 在 outputs 中直接落盘, + // 这种路径没有顶层 write_file 工具帧。只要对应气泡仍在流式,就每 1.5 秒扫描一次; + // 发现新文件或同路径被覆盖时自动打开真实沙箱。最终消息卡片仍由 + // collectRoundtableDeliverablePaths 单独限制为 md/html/mermaid。 + const streamingArtifactThreadSignature = useMemo(() => { + const ids = new Set(); + for (const dialogue of step2RoundtableDialogues) { + if (dialogue.streaming && dialogue.agentThreadId) { + ids.add(dialogue.agentThreadId); + } + } + return [...ids].sort().join("\n"); + }, [step2RoundtableDialogues]); + + useEffect(() => { + if (currentStep !== 2 || !streamingArtifactThreadSignature) return; + const watchedThreadIds = streamingArtifactThreadSignature + .split("\n") + .filter(Boolean); + if (watchedThreadIds.length === 0) return; + + const controller = new AbortController(); + const watchStartedAtMs = Date.now(); + let stopped = false; + let polling = false; + + const poll = async () => { + if (polling || stopped) return; + polling = true; + try { + const results = await Promise.allSettled( + watchedThreadIds.map(async (threadId) => ({ + threadId, + listing: await listSessionArtifacts(threadId, { + signal: controller.signal, + }), + })), + ); + if (stopped) return; + + let newest: + | { threadId: string; path: string; modifiedAt: string } + | null = null; + let newestTime = Number.NEGATIVE_INFINITY; + + for (const result of results) { + if (result.status !== "fulfilled") continue; + const { threadId, listing } = result.value; + const previous = artifactDirectorySnapshotsRef.current.get(threadId); + const changed = newestChangedRoundtableArtifact( + listing.files, + previous, + watchStartedAtMs, + ); + artifactDirectorySnapshotsRef.current.set( + threadId, + roundtableArtifactDiskSnapshot(listing.files), + ); + if (!changed) continue; + const changedTime = Date.parse(changed.modifiedAt); + const sortableTime = Number.isFinite(changedTime) ? changedTime : 0; + if (!newest || sortableTime > newestTime) { + newest = { + threadId, + path: changed.path, + modifiedAt: changed.modifiedAt, + }; + newestTime = sortableTime; + } + } + + if (!newest) return; + const autoOpenKey = `${orchestrationEpochRef.current}:${newest.threadId}:${newest.path}`; + if (autoOpenedDiscoveredArtifactsRef.current.has(autoOpenKey)) return; + autoOpenedDiscoveredArtifactsRef.current.add(autoOpenKey); + openArtifactPreview(newest.threadId, newest.path, { auto: true }); + } finally { + polling = false; + } + }; + + void poll(); + const intervalId = window.setInterval(() => void poll(), 1_500); + return () => { + stopped = true; + controller.abort(); + window.clearInterval(intervalId); + }; + }, [currentStep, openArtifactPreview, streamingArtifactThreadSignature]); + const openArtifactFromStep = useCallback( ( dialogue: Step2Dialogue, @@ -1159,16 +1272,15 @@ export function useStep2Orchestration( if (!seatThreadId) return; const [, toolName] = step.key.split(":"); if (toolName === "write_file" || toolName === "str_replace") { - const path = step.args?.path; - if (typeof path !== "string" || !step.args) return; - // 与 MessageStepsCard 的可点判定同一道门槛:过程文件/畸形路径点了不开沙箱。 - if (!languageOfStreamingArtifact(path)) return; + if (!step.args) return; + const path = resolveRoundtableStreamingWritePreview(toolName, step.args); + if (!path) return; const { messageId, toolCallId } = upsertSeatToolCall( seatThreadId, toolName, step.args, ); - const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); + const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId }); openSeatVirtualArtifact(seatThreadId, virtualUrl, false); return; } @@ -1198,32 +1310,22 @@ export function useStep2Orchestration( toolName: string | undefined, args: Record | undefined, seatThreadId: string | undefined, + sourceToolCallId?: string, ) => { if (phase !== "tool_calling") return; if (!seatThreadId) return; - const isArtifactTool = - toolName === "write_file" || toolName === "str_replace"; - if (!isArtifactTool) return; - const path = args?.path; - if (typeof path !== "string" || !path || !args) { - if (typeof window !== "undefined") { - console.debug( - "[roundtable-sandbox] tool_calling without path yet", - { toolName, args }, - ); - } - return; - } - // 与主聊天 ArtifactWriteToolCall 的流式预览同一道门槛:只有 .md/.html/.mermaid - // 等可预览产物才自动打开沙箱。席位写的过程文件(fetch.py / r*.txt)与无扩展名的 - // 畸形路径不再触发自动预览,免得右侧沙箱被无意义文件反复抢占。 - if (!languageOfStreamingArtifact(path)) return; + if (toolName !== "write_file" && toolName !== "str_replace") return; + // `write_file` can stream its body before its path. It opens the sandbox + // for every file type; only final message-list cards are filtered. + const path = resolveRoundtableStreamingWritePreview(toolName, args); + if (!path || !args) return; const { messageId, toolCallId } = upsertSeatToolCall( seatThreadId, toolName as string, args, + sourceToolCallId, ); - const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); + const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId }); openSeatVirtualArtifact(seatThreadId, virtualUrl, true); }, [ @@ -1239,7 +1341,9 @@ export function useStep2Orchestration( * - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开; * - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界, * 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。 - * 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。 + * - 同一 messages 帧可先写文件、再读技能。阶段层只会把最后一个 read_skill 当作 + * active tool,因而这里必须补一次 write_file 自动打开;以 tool-call 虚拟 URL 去重, + * 不会在后续内容增量或用户手动关闭后反复弹出。 */ const handleUpstreamFrame = useCallback( (frame: unknown, seatThreadId: string | undefined) => { @@ -1265,14 +1369,79 @@ export function useStep2Orchestration( const tcArgs = tcObj.args; if (!tcArgs || typeof tcArgs !== "object") continue; const argsObj = tcArgs as Record; - const path = argsObj.path; - if (typeof path !== "string" || !path) continue; - upsertSeatToolCall(seatThreadId, name, argsObj); + const sourceToolCallId = typeof tcObj.id === "string" ? tcObj.id : undefined; + // Keep the content-first call in the stub as well. The virtual preview + // reads this message directly, so dropping pathless chunks recreates the + // exact race fixed in the normal chat. + const { messageId, toolCallId } = upsertSeatToolCall( + seatThreadId, + name, + argsObj, + sourceToolCallId, + ); + // `setPhase` 只报告一个当前工具。若 AI chunk 同时携带 + // `write_file → read_skill`,它会落在 read_skill,旧逻辑便遗漏了这里的 + // 实时沙箱。逐条扫描所有 write_file,把过程脚本和最终文档一律接入沙箱; + // 最终消息卡片仍由 collectRoundtableDeliverablePaths 单独过滤。 + if (name !== "write_file") continue; + const previewPath = resolveRoundtableStreamingWritePreview(name, argsObj); + if (!previewPath) continue; + openSeatVirtualArtifact( + seatThreadId, + buildWriteFileVirtualUrl({ + path: previewPath, + messageId, + toolCallId, + }), + true, + ); } }, - [completeSeatStubWriteFile, upsertSeatToolCall], + [ + buildWriteFileVirtualUrl, + completeSeatStubWriteFile, + openSeatVirtualArtifact, + upsertSeatToolCall, + ], ); + /** + * A weak model may create the final report through bash after an earlier + * write_file call. Those tools have no `present_files` step, so mirror the + * normal chat's post-stream artifact-directory refresh and attach only the + * user-facing document types to this completed bubble. + */ + const attachSettledArtifactCards = useCallback((dialogueId: number, threadId?: string) => { + if (!threadId) return; + void listSessionArtifacts(threadId) + .then(({ files }) => { + const artifactPaths = collectRoundtableDeliverablePaths(files.map((file) => file.path)); + if (artifactPaths.length === 0) return; + setStep2RoundtableDialogues((previous) => { + const index = previous.findIndex((dialogue) => dialogue.id === dialogueId); + if (index < 0) return previous; + const dialogue = previous[index]; + const merged = collectRoundtableDeliverablePaths([ + ...(dialogue.artifactPaths ?? []), + ...artifactPaths, + ]); + if ( + merged.length === (dialogue.artifactPaths ?? []).length && + merged.every((path, itemIndex) => path === dialogue.artifactPaths?.[itemIndex]) + ) { + return previous; + } + const next = [...previous]; + next[index] = { ...dialogue, artifactPaths: merged }; + return next; + }); + }) + .catch(() => { + // The normal message/tool path remains usable when directory listing is + // unavailable; no noisy toast is warranted for this recovery fallback. + }); + }, []); + // ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)────────── // ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里: // (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress); @@ -1773,8 +1942,12 @@ export function useStep2Orchestration( (id: number, finalContent: string, confidence?: string) => { // 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。 // 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。 - completeSeatStubWriteFiles(dialogueThreadIdRef.current[id]); - // 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。 + const threadId = dialogueThreadIdRef.current[id]; + completeSeatStubWriteFiles(threadId); + attachSettledArtifactCards(id, threadId); + // 完成帧可能与最后一批节流 token 落在同一任务:把尚未 flush 的增量合并进 + // 收尾内容,既防止旧增量晚到反写,也不能丢最后的思考/正文 token。 + const pendingDelta = directStreamDeltaRef.current.get(id) ?? ""; directStreamDeltaRef.current.delete(id); if ( directStreamDeltaRef.current.size === 0 && @@ -1787,10 +1960,18 @@ export function useStep2Orchestration( let changed = false; const next = prev.map((d) => { if (d.id !== id) return d; + const streamed = `${d.content ?? ""}${pendingDelta}`; + const streamedVisible = stripReasoning(streamed).trim(); + const finalVisible = stripReasoning(finalContent).trim(); + // Special-seat terminal frames intentionally return the visible + // deliverable without provider reasoning. When it matches the + // streamed body, retain the inline trace already rendered. const next = - finalContent && finalContent.trim().length > 0 - ? finalContent - : d.content || "(无文本输出)"; + streamed.trim() && streamedVisible === finalVisible + ? streamed + : finalContent && finalContent.trim().length > 0 + ? finalContent + : streamed || "(无文本输出)"; if ( d.content === next && d.confidence === (confidence ?? d.confidence) && @@ -1809,7 +1990,7 @@ export function useStep2Orchestration( return changed ? next : prev; }); }, - [completeSeatStubWriteFiles], + [attachSettledArtifactCards, completeSeatStubWriteFiles], ); const highlightSpeaker = useCallback( @@ -2008,11 +2189,11 @@ export function useStep2Orchestration( onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel), onTextDelta: (text) => a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(a.bubbleId, label); appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args); - handleArtifactToolPhase(phase, toolName, args, a.seatThreadId); + handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { handleUpstreamFrame(frame, a.seatThreadId); @@ -2116,7 +2297,7 @@ export function useStep2Orchestration( streamStepKey(phase, toolName, toolCallId), args, ); - handleArtifactToolPhase(phase, toolName, args, leaderThreadId); + handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -2399,7 +2580,7 @@ export function useStep2Orchestration( streamStepKey(phase, toolName, toolCallId), args, ); - handleArtifactToolPhase(phase, toolName, args, leaderThreadId); + handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), }); @@ -2478,11 +2659,11 @@ export function useStep2Orchestration( signal: subAborter.signal, onModelSwitch: makeModelSwitchHandler(subBubbleId, false), onTextDelta: (text) => appendToDialogue(subBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(subBubbleId, label); appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); - handleArtifactToolPhase(phase, toolName, args, seatThreadId); + handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { handleUpstreamFrame(frame, seatThreadId); @@ -2590,7 +2771,7 @@ export function useStep2Orchestration( streamStepKey(phase, toolName, toolCallId), args, ); - handleArtifactToolPhase(phase, toolName, args, leaderThreadId); + handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), }); @@ -2774,7 +2955,7 @@ export function useStep2Orchestration( streamStepKey(phase, toolName, toolCallId), args, ); - handleArtifactToolPhase(phase, toolName, args, leaderThreadId); + handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), }); @@ -2897,11 +3078,11 @@ export function useStep2Orchestration( onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel), onTextDelta: (text) => isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(bubbleId, label); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); - handleArtifactToolPhase(phase, toolName, args, seatThreadId); + handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { handleUpstreamFrame(frame, seatThreadId); @@ -3109,11 +3290,11 @@ export function useStep2Orchestration( signal: subAborter.signal, onModelSwitch: makeModelSwitchHandler(bubbleId, true), onTextDelta: (text) => ingestParallelDelta(bubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(bubbleId, label); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); - handleArtifactToolPhase(phase, toolName, args, seatThreadId); + handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { handleUpstreamFrame(frame, seatThreadId); @@ -3656,6 +3837,8 @@ export function useStep2Orchestration( setArtifacts([]); resetSeatStubMessages(); hasAutoOpenedRef.current = new Set(); + artifactDirectorySnapshotsRef.current = new Map(); + autoOpenedDiscoveredArtifactsRef.current = new Set(); pendingInjectPromptRef.current = null; dagPlanExecutedRef.current = false; humanValidationContinuationRef.current = null; diff --git a/frontend-web/src/roundtable-planning/lib/stream-reasoning.test.ts b/frontend-web/src/roundtable-planning/lib/stream-reasoning.test.ts new file mode 100644 index 00000000..1717806b --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/stream-reasoning.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createInlineReasoningDeltaFormatter } from "./stream-reasoning"; + +test("provider reasoning chunks form one live think block before answer content", () => { + const format = createInlineReasoningDeltaFormatter(); + + assert.equal( + format({ id: "msg-1", reasoning: "先检查约束", content: "" }), + "先检查约束", + ); + assert.equal( + format({ id: "msg-1", reasoning: ",再组织答案", content: "" }), + ",再组织答案", + ); + assert.equal( + format({ id: "msg-1", reasoning: "", content: "最终结论" }), + "最终结论", + ); +}); diff --git a/frontend-web/src/roundtable-planning/lib/stream-reasoning.ts b/frontend-web/src/roundtable-planning/lib/stream-reasoning.ts new file mode 100644 index 00000000..9babc4f4 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/stream-reasoning.ts @@ -0,0 +1,30 @@ +export type StreamedAiTextDelta = { + content: string; + reasoning: string; + id: string; +}; + +/** + * Preserve an open `` block across provider reasoning-only chunks and + * close it exactly when visible content begins. The resulting text feeds the + * same Step2Message renderer used for literal `` model output. + */ +export function createInlineReasoningDeltaFormatter(): (delta: StreamedAiTextDelta) => string { + const reasoningOpenByMessageId = new Set(); + return (delta) => { + const key = delta.id || "__roundtable-anonymous-reasoning__"; + let text = ""; + if (delta.reasoning) { + if (!reasoningOpenByMessageId.has(key)) { + reasoningOpenByMessageId.add(key); + text += ""; + } + text += delta.reasoning; + } + if (delta.content) { + if (reasoningOpenByMessageId.delete(key)) text += ""; + text += delta.content; + } + return text; + }; +} diff --git a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts new file mode 100644 index 00000000..7348ccfe --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectRoundtableDeliverablePaths, + newestChangedRoundtableArtifact, + resolveRoundtableStreamingWritePreview, + roundtableArtifactDiskSnapshot, +} from "./streaming-artifacts"; + +test("roundtable opens a provisional Markdown preview when content arrives before path", () => { + assert.equal( + resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }), + "未命名文件.md", + ); + assert.equal( + resolveRoundtableStreamingWritePreview("write_file", { path: "/mnt/user-data/outputs/report.m", content: "# 报告" }), + "未命名文件.md", + ); +}); + +test("roundtable live-previews every write_file type but filters final cards", () => { + assert.equal( + resolveRoundtableStreamingWritePreview("write_file", { + path: "/mnt/user-data/outputs/report.md", + content: "# 报告", + }), + "/mnt/user-data/outputs/report.md", + ); + assert.equal( + resolveRoundtableStreamingWritePreview("write_file", { + path: "/mnt/user-data/workspace/fetch.py", + content: "print('fetch')", + }), + "/mnt/user-data/workspace/fetch.py", + ); + assert.equal( + resolveRoundtableStreamingWritePreview("write_file", { + path: "/mnt/user-data/outputs/task-reports.json", + content: "[{}]", + }), + "/mnt/user-data/outputs/task-reports.json", + ); + assert.equal( + resolveRoundtableStreamingWritePreview("str_replace", { content: "# 报告" }), + null, + ); + assert.deepEqual( + collectRoundtableDeliverablePaths([ + "/mnt/user-data/outputs/report.md", + "/mnt/user-data/outputs/report.md", + "/mnt/user-data/outputs/page.html", + "/mnt/user-data/outputs/chart.mermaid", + "/mnt/user-data/workspace/fetch.py", + "/mnt/user-data/workspace/notes.txt", + ]), + [ + "/mnt/user-data/outputs/report.md", + "/mnt/user-data/outputs/page.html", + "/mnt/user-data/outputs/chart.mermaid", + ], + ); +}); + +test("roundtable detects files created or overwritten by a skill without write_file SSE", () => { + const oldFile = { + path: "/mnt/user-data/outputs/report.md", + sizeBytes: 10, + modifiedAt: "2026-08-31T01:00:00.000Z", + }; + const baseline = roundtableArtifactDiskSnapshot([oldFile]); + const overwritten = { + ...oldFile, + sizeBytes: 20, + modifiedAt: "2026-08-31T01:00:05.000Z", + }; + const generatedBySkill = { + path: "/mnt/user-data/outputs/chart.html", + sizeBytes: 30, + modifiedAt: "2026-08-31T01:00:06.000Z", + }; + + assert.deepEqual( + newestChangedRoundtableArtifact( + [overwritten, generatedBySkill], + baseline, + Date.parse("2026-08-31T01:00:04.000Z"), + ), + generatedBySkill, + ); + assert.equal( + newestChangedRoundtableArtifact( + [oldFile], + undefined, + Date.parse("2026-08-31T01:00:10.000Z"), + ), + null, + ); + assert.deepEqual( + newestChangedRoundtableArtifact( + [generatedBySkill], + undefined, + Date.parse("2026-08-31T01:00:07.000Z"), + ), + generatedBySkill, + ); +}); diff --git a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts new file mode 100644 index 00000000..2ea40a43 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts @@ -0,0 +1,113 @@ +import { + hasCompleteArtifactExtension, + languageOfStreamingArtifact, + normalizeStreamingArtifactPath, + provisionalWriteFilePath, +} from "@/core/artifacts/streaming-write-preview"; + +/** + * Resolve the path that may be live-previewed while a roundtable tool call is + * still streaming. + * + * `write_file` is deliberately unrestricted here: the sandbox is a live tool + * activity view, not the final-deliverable list. Agents commonly write a + * temporary Python script before reading a skill or running it; suppressing + * that preview makes the page look stalled even though the backend is busy. + * The separate `collectRoundtableDeliverablePaths` gate remains responsible + * for limiting message-list file cards to Markdown, HTML, and Mermaid. + * + * `str_replace` retains its existing document-only behavior. It has no full + * content payload for a virtual preview, and is not a new-file write. + */ +export function resolveRoundtableStreamingWritePreview( + toolName: string | undefined, + args: Record | undefined, +): string | null { + const rawPath = typeof args?.path === "string" ? args.path : ""; + const path = normalizeStreamingArtifactPath(rawPath); + if (toolName === "write_file") { + // Weak models can emit content before the `path` JSON key. Give that same + // call a temporary name so the sandbox opens and continues receiving data. + const content = typeof args?.content === "string" ? args.content : ""; + // A complete path of *any* file type is previewable. Keep an incomplete + // suffix such as `report.m` provisional while content is already flowing, + // so it upgrades cleanly to Markdown once the final `d` arrives. + if (path && (hasCompleteArtifactExtension(path) || !content.trim())) { + return path; + } + return content.trim() ? provisionalWriteFilePath(content) : null; + } + + // Preserve the prior, conservative behavior for in-place replacement. + return languageOfStreamingArtifact(path) ? path : null; +} + +/** Final artifact cards intentionally expose only user-facing deliverables. */ +export function collectRoundtableDeliverablePaths(values: Iterable): string[] { + const seen = new Set(); + const paths: string[] = []; + for (const value of values) { + if (typeof value !== "string") continue; + const path = normalizeStreamingArtifactPath(value); + if (!path || !languageOfStreamingArtifact(path) || seen.has(path)) continue; + seen.add(path); + paths.push(path); + } + return paths; +} + +export interface RoundtableWatchedArtifact { + path: string; + sizeBytes: number; + modifiedAt: string; +} + +/** Stable disk signature used to notice both newly created and overwritten files. */ +export function roundtableArtifactDiskSnapshot( + files: Iterable, +): Map { + const snapshot = new Map(); + for (const file of files) { + const path = normalizeStreamingArtifactPath(file.path); + if (!path) continue; + snapshot.set(path, `${file.sizeBytes}:${file.modifiedAt}`); + } + return snapshot; +} + +/** + * Pick the newest file created or changed since the prior directory poll. + * + * On the first poll there is no baseline yet. A small grace window catches a + * skill output that lands between mounting the streaming bubble and the first + * HTTP response, without reopening unrelated files from older roundtable runs. + */ +export function newestChangedRoundtableArtifact( + files: readonly RoundtableWatchedArtifact[], + previous: ReadonlyMap | undefined, + watchStartedAtMs: number, + firstPollGraceMs = 2_000, +): RoundtableWatchedArtifact | null { + let newest: RoundtableWatchedArtifact | null = null; + let newestTime = Number.NEGATIVE_INFINITY; + + for (const file of files) { + const path = normalizeStreamingArtifactPath(file.path); + if (!path) continue; + const signature = `${file.sizeBytes}:${file.modifiedAt}`; + const modifiedTime = Date.parse(file.modifiedAt); + const changed = previous + ? previous.get(path) !== signature + : Number.isFinite(modifiedTime) && + modifiedTime >= watchStartedAtMs - firstPollGraceMs; + if (!changed) continue; + + const sortableTime = Number.isFinite(modifiedTime) ? modifiedTime : 0; + if (!newest || sortableTime > newestTime) { + newest = { ...file, path }; + newestTime = sortableTime; + } + } + + return newest; +}