From 14d0f469df1b0d9a625dad736491f24c6241d3b6 Mon Sep 17 00:00:00 2001 From: xiaofei Date: Sun, 1 Nov 2026 21:42:07 -0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(chat):=20Bash=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8D=A1=E5=AE=9E=E6=97=B6=E6=98=BE=E7=A4=BA=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=BE=93=E5=87=BA=EF=BC=88#975=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编译等长命令运行时,工具卡此前只显示"运行中",看不到进度。K-brain 以 tool.status{tool_call_id,status:"running",message} 推送输出尾部快照(有上限、 已节流),桌面端接入: - turn.ts:带 tool_call_id 的 running 状态交给新的 onToolOutput 回调,不再当作 整轮状态文字覆盖运行进度;未知调用或已出结果的调用的快照忽略 - 实时轮次:ToolTraceItem 新增 liveOutput,setToolLiveOutputInRound 整体替换 快照;结果到达时丢弃,不进历史、持久化消息或 WebUI 转发 - ShellToolDisplay:无结果时显示实时输出,去掉终端控制序列、按 \r 只保留进度条 最后一次重绘,最多 200 行 / 8000 字符;贴底自动滚动,用户上翻时不抢位置 - ToolCallItem:收到输出时自动展开,成功结束后自动收起,失败保持展开;用户 手动开合后不再干预;memo 比较包含 liveOutput --- .../src/lib/chat/messages/uiMessages.ts | 29 ++++- crates/agent-gui/src/lib/kbrain/turn.ts | 16 +++ .../chat/turns/runKBrainConversationTurn.ts | 15 +++ .../chat/kbrain-live-tool-output.test.mjs | 111 ++++++++++++++++++ .../test/chat/tool-argument-display.test.mjs | 45 ++++++- .../src/components/chat/ToolSurfaces.tsx | 11 +- .../chat/assistant-bubble/ToolCallItem.tsx | 25 +++- .../assistant-bubble/ToolResultDisplay.tsx | 41 ++++++- .../agent-ui/src/lib/chat/liveShellOutput.ts | 33 ++++++ crates/agent-ui/src/lib/chat/uiMessages.ts | 9 +- 10 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 crates/agent-gui/test/chat/kbrain-live-tool-output.test.mjs create mode 100644 crates/agent-ui/src/lib/chat/liveShellOutput.ts diff --git a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts index 40841ff65..1f04f548a 100644 --- a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts +++ b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts @@ -27,6 +27,11 @@ import { isSubagentCardToolCall } from "../../subagents/card"; export type ToolTraceItem = { toolCall: ToolCall; toolResult?: ToolResultMessage; + /** + * 命令运行中由后端推送的输出尾部快照(K-brain tool.status,有上限)。只活在 + * 实时轮次里:结果到达即丢弃,不写入历史或持久化消息。 + */ + liveOutput?: string; }; export type UiRoundContentBlock = @@ -313,10 +318,12 @@ function upsertToolBlock( const existing = blocks[existingIdx]; if (existing.kind !== "tool") return blocks; const next = blocks.slice(); + // 结果到达后由最终输出取代运行中快照。 + const { liveOutput: _liveOutput, ...settledItem } = existing.item; next[existingIdx] = { kind: "tool", item: { - ...existing.item, + ...(toolResult ? settledItem : existing.item), toolCall: toolCallSnapshot, toolResult: toolResult ?? existing.item.toolResult, }, @@ -352,6 +359,26 @@ export function attachToolResultToRound>( }; } +/** + * 写入运行中命令的输出快照。快照是截至此刻的完整尾部(不是增量),整体替换; + * 已有结果或找不到对应工具块时原样返回,避免晚到的快照盖住最终结果。 + */ +export function setToolLiveOutputInRound>( + round: TRound, + toolCallId: string, + output: string, +): TRound { + const idx = round.blocks.findIndex( + (block) => block.kind === "tool" && block.item.toolCall.id === toolCallId, + ); + const existing = idx >= 0 ? round.blocks[idx] : undefined; + if (existing?.kind !== "tool" || existing.item.toolResult) return round; + if (existing.item.liveOutput === output) return round; + const blocks = round.blocks.slice(); + blocks[idx] = { kind: "tool", item: { ...existing.item, liveOutput: output } }; + return { ...round, blocks }; +} + function findLastTextBlockIndex(blocks: UiRoundContentBlock[]) { for (let index = blocks.length - 1; index >= 0; index -= 1) { if (blocks[index]?.kind === "text") return index; diff --git a/crates/agent-gui/src/lib/kbrain/turn.ts b/crates/agent-gui/src/lib/kbrain/turn.ts index 6dd1237e0..8eec2e178 100644 --- a/crates/agent-gui/src/lib/kbrain/turn.ts +++ b/crates/agent-gui/src/lib/kbrain/turn.ts @@ -190,6 +190,8 @@ export type KBrainTurnParams = { onHostedSearch?: (search: HostedSearchBlock) => void; onToolCall: (call: ToolCall) => void; onToolResult: (call: ToolCall, result: ToolResultMessage) => void; + /** 命令运行中的输出尾部快照(整体替换,非增量);结果到达后不再回调。 */ + onToolOutput?: (toolCallId: string, output: string) => void; onSubagent?: (subagent: KBrainSubagent) => void; onStatus?: (status: string | null) => void; content?: KBrainContentBlock[]; @@ -644,6 +646,8 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise(); + // 已出结果的调用:之后到达的运行中输出一律忽略。 + const settledCalls = new Set(); let afterSeq = accepted.accepted_seq; let terminal: "stop" | "error" | "aborted" = "stop"; let errorMessage: string | undefined; @@ -739,6 +743,7 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise(event); + // 带 tool_call_id 的 running 状态是命令运行中的输出快照(#975):交给对应 + // 工具卡展示,不能当作整轮的状态文字覆盖运行进度。 + const toolCallId = typeof payload?.tool_call_id === "string" ? payload.tool_call_id : ""; + if (toolCallId && payload?.status === "running" && typeof payload.message === "string") { + if (calls.has(toolCallId) && !settledCalls.has(toolCallId)) { + params.onToolOutput?.(toolCallId, payload.message); + } + break; + } reportStatus(payload?.status ?? payload?.text ?? null); break; } diff --git a/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts index d14ab75e7..f44f9b83f 100644 --- a/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts @@ -13,6 +13,7 @@ import { attachToolResultToRound, collapseThinking, type LiveRound, + setToolLiveOutputInRound, updateLiveRound, upsertHostedSearchToRound, upsertToolCallToRound, @@ -216,6 +217,19 @@ export async function runKBrainConversationTurn(params: Params): Promise { }); } else projectToolResult(call, result); }; + // 运行中命令的输出快照只更新实时轮次里的工具卡,不进历史,也不转发给 WebUI。 + const onToolOutput = (toolCallId: string, output: string) => { + if (results.has(toolCallId)) return; + const callRound = callRounds.get(toolCallId); + if (callRound === undefined) return; + params.batchLiveRoundsUpdate( + (previous) => + updateLiveRound(previous, callRound, (target) => + setToolLiveOutputInRound(target, toolCallId, output), + ), + transcriptStore, + ); + }; hookLifecycle.startAgent(); const command = "composerCommand" in params ? params.composerCommand : undefined; // 目标续跑:后端在每轮终止前用 goal.updated 给出下一轮的请求标识,这里挂接上去, @@ -288,6 +302,7 @@ export async function runKBrainConversationTurn(params: Params): Promise { }, onToolCall, onToolResult, + onToolOutput, onStatus: status, onQuestionRequest: async ( request: KBrainQuestionRequest, diff --git a/crates/agent-gui/test/chat/kbrain-live-tool-output.test.mjs b/crates/agent-gui/test/chat/kbrain-live-tool-output.test.mjs new file mode 100644 index 000000000..bad993819 --- /dev/null +++ b/crates/agent-gui/test/chat/kbrain-live-tool-output.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// #975:命令运行中的输出由 K-brain 以 tool.status{tool_call_id,status:"running",message} +// 推送尾部快照;桌面端把它挂到对应工具卡,结果到达后由最终输出取代。 +const loader = createTsModuleLoader(); +const { runKBrainTurn } = loader.loadModule("src/lib/kbrain/turn.ts"); +const { attachToolResultToRound, setToolLiveOutputInRound, upsertToolCallToRound } = + loader.loadModule("src/lib/chat/messages/uiMessages.ts"); +const { formatLiveShellOutput, LIVE_SHELL_OUTPUT_MAX_LINES } = loader.loadModule( + "../agent-ui/src/lib/chat/liveShellOutput.ts", +); + +test("tool.status snapshots with a tool_call_id reach the tool card, not the turn status", async () => { + let seq = 1; + const event = (type, payload) => + `data: ${JSON.stringify({ version: "kbrain.agent.v1", conversation_id: "s", run_id: "r", seq: ++seq, type, payload })}\n\n`; + const stream = [ + event("tool.call", { tool_call: { id: "bash-1", name: "Bash", arguments: { command: "make" } } }), + // 未知调用的快照被忽略。 + event("tool.status", { tool_call_id: "unknown", status: "running", message: "stray" }), + event("tool.status", { tool_call_id: "bash-1", status: "running", message: "step 1\n" }), + event("tool.status", { tool_call_id: "bash-1", status: "running", message: "step 1\nstep 2\n" }), + event("tool.result", { tool_result: { id: "bash-1", name: "Bash", output: "step 1\nstep 2\ndone" } }), + // 结果之后晚到的快照不能再覆盖卡片。 + event("tool.status", { tool_call_id: "bash-1", status: "running", message: "late" }), + // 不带 tool_call_id 的状态(如压缩)仍按整轮状态处理。 + event("tool.status", { tool: "compaction", status: "compacting" }), + event("assistant.text.delta", { text: "ok" }), + event("run.completed", {}), + ].join(""); + const fetch = async (url) => { + const path = new URL(url).pathname; + if (path === "/v1/sessions") return Response.json({ id: "s" }); + if (path === "/v1/health") return Response.json({}); + if (path.endsWith("/runs")) + return Response.json({ version: "kbrain.agent.v1", conversation_id: "s", run_id: "r", accepted_seq: 1 }); + if (path.endsWith("/events")) + return new Response(stream, { headers: { "Content-Type": "text/event-stream" } }); + throw new Error(`Unexpected request ${path}`); + }; + const outputs = []; + const statuses = []; + const results = []; + const result = await runKBrainTurn({ + baseUrl: "http://fixture.test", + fetch, + model: { provider: "fixture", model: "test" }, + sessionId: "frontend", + conversationId: "live-output", + cwd: "/tmp", + context: { messages: [] }, + prompt: "build", + signal: new AbortController().signal, + onStatus: (status) => statuses.push(status), + onTextDelta() {}, + onThinkingDelta() {}, + onToolCall() {}, + onToolResult: (call) => results.push(call.id), + onToolOutput: (id, output) => outputs.push([id, output]), + }); + assert.equal(result.stopReason, "stop"); + assert.deepEqual(outputs, [ + ["bash-1", "step 1\n"], + ["bash-1", "step 1\nstep 2\n"], + ]); + assert.deepEqual(results, ["bash-1"]); + assert.ok(!statuses.includes("running"), "live output must not overwrite the turn status"); + assert.ok(statuses.includes("compacting")); +}); + +test("live output attaches to a running tool block and is dropped when the result lands", () => { + const call = { type: "toolCall", id: "bash-1", name: "Bash", arguments: { command: "make" } }; + const empty = { round: 1, key: "r1", blocks: [] }; + const running = upsertToolCallToRound(empty, call); + const live = setToolLiveOutputInRound(running, "bash-1", "compiling a.c\n"); + assert.equal(live.blocks[0].item.liveOutput, "compiling a.c\n"); + // 相同快照不产生新对象;未知调用原样返回。 + assert.equal(setToolLiveOutputInRound(live, "bash-1", "compiling a.c\n"), live); + assert.equal(setToolLiveOutputInRound(live, "missing", "x"), live); + // 参数更新保留快照。 + assert.equal(upsertToolCallToRound(live, call).blocks[0].item.liveOutput, "compiling a.c\n"); + + const toolResult = { + role: "toolResult", + toolCallId: "bash-1", + toolName: "Bash", + content: [{ type: "text", text: "done" }], + isError: false, + }; + const settled = attachToolResultToRound(live, call, toolResult); + assert.equal(settled.blocks[0].item.toolResult, toolResult); + assert.equal("liveOutput" in settled.blocks[0].item, false); + // 结果之后的快照不再写入。 + assert.equal(setToolLiveOutputInRound(settled, "bash-1", "late"), settled); +}); + +test("live shell output strips terminal control sequences, keeps the last redraw and stays bounded", () => { + assert.equal(formatLiveShellOutput(""), ""); + assert.equal( + formatLiveShellOutput("\u001b[32mCompiling\u001b[0m foo\r\nprogress 10%\rprogress 55%\n\n"), + "Compiling foo\nprogress 55%", + ); + const many = Array.from({ length: LIVE_SHELL_OUTPUT_MAX_LINES + 50 }, (_, i) => `line ${i}`).join("\n"); + const bounded = formatLiveShellOutput(many); + const lines = bounded.split("\n"); + assert.equal(lines.length, LIVE_SHELL_OUTPUT_MAX_LINES); + assert.equal(lines.at(-1), `line ${LIVE_SHELL_OUTPUT_MAX_LINES + 49}`); + assert.ok(formatLiveShellOutput("x".repeat(20_000)).length <= 8000); +}); diff --git a/crates/agent-gui/test/chat/tool-argument-display.test.mjs b/crates/agent-gui/test/chat/tool-argument-display.test.mjs index ae80e748f..26fa89943 100644 --- a/crates/agent-gui/test/chat/tool-argument-display.test.mjs +++ b/crates/agent-gui/test/chat/tool-argument-display.test.mjs @@ -135,10 +135,10 @@ function createToolArgsRenderer() { }, }), ); - renderToolArgs.renderShell = (toolCall, result) => + renderToolArgs.renderShell = (toolCall, result, liveOutput) => renderToStaticMarkup( jsxRuntime.jsx(ShellToolDisplay, { - item: { toolCall, toolResult: result }, + item: liveOutput === undefined ? { toolCall, toolResult: result } : { toolCall, toolResult: result, liveOutput }, result, }), ); @@ -320,7 +320,11 @@ function createToolCallItemRenderer() { return (toolCall, options = {}) => renderToStaticMarkup( jsxRuntime.jsx(MemoToolCallItem, { - item: options.result ? { toolCall, toolResult: options.result } : { toolCall }, + item: { + toolCall, + ...(options.result ? { toolResult: options.result } : {}), + ...(options.liveOutput !== undefined ? { liveOutput: options.liveOutput } : {}), + }, isRunning: options.isRunning, onOpenFileLink: options.onOpenFileLink, }), @@ -585,6 +589,41 @@ test("shell display combines command and output without runtime session metadata assert.doesNotMatch(html, /bash-private|session_id|status:|cursor:|duration_ms|shell: zsh/); }); +test("running shell shows the live output tail until the final result replaces it (#975)", () => { + const renderToolDisplay = createToolArgsRenderer(); + const toolCall = { type: "toolCall", id: "bash-live", name: "Bash", arguments: { command: "cargo build" } }; + const live = renderToolDisplay.renderShell( + toolCall, + undefined, + "\u001b[32m Compiling\u001b[0m serde\r\n[=====> ] 50%\r[==========] 100%\n", + ); + assert.match(live, /data-shell-live-output=""/); + assert.match(live, /cargo build/); + assert.match(live, /Compiling serde/); + assert.match(live, /\[==========\] 100%/); + assert.doesNotMatch(live, /50%|\u001b/); + + const result = { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: "Finished dev profile" }], + isError: false, + }; + const settled = renderToolDisplay.renderShell(toolCall, result, "stale live tail"); + assert.match(settled, /Finished dev profile/); + assert.doesNotMatch(settled, /data-shell-live-output|stale live tail/); +}); + +test("a running Bash card opens to show live output; without output it stays collapsed (#975)", () => { + const renderToolCallItem = createToolCallItemRenderer(); + const toolCall = { type: "toolCall", id: "bash-live-card", name: "Bash", arguments: { command: "make" } }; + const quiet = renderToolCallItem(toolCall, { isRunning: true }); + const streaming = renderToolCallItem(toolCall, { isRunning: true, liveOutput: "cc -c a.c\n" }); + assert.match(quiet, /aria-expanded="false"/); + assert.match(streaming, /aria-expanded="true"/); +}); + test("historical shell envelopes fall back to clean stdout and stderr only", () => { const renderToolDisplay = createToolArgsRenderer(); const toolCall = { diff --git a/crates/agent-ui/src/components/chat/ToolSurfaces.tsx b/crates/agent-ui/src/components/chat/ToolSurfaces.tsx index 4d7559974..c9a911bf7 100644 --- a/crates/agent-ui/src/components/chat/ToolSurfaces.tsx +++ b/crates/agent-ui/src/components/chat/ToolSurfaces.tsx @@ -1,5 +1,5 @@ import { cn } from "@liveagent/ui/lib/shared/utils"; -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; // Presentational primitives shared by the tool cards (args displays, result // displays, streaming previews). @@ -108,10 +108,15 @@ export function MetaTags({ tags }: { tags: MetaTag[] }) { ); } -export function ToolScrollablePre(props: { children: ReactNode; className?: string }) { - const { children, className } = props; +export function ToolScrollablePre(props: { + children: ReactNode; + className?: string; + ref?: Ref; +}) { + const { children, className, ref } = props; return (
 0;
   const isStreamingFilePreviewTool = FILE_TOOL_TEXT_FIELDS[item.toolCall.name] !== undefined;
@@ -299,6 +306,7 @@ function ToolCallItem({
   const statusTextClass =
     result?.isError || shellSessionFailed ? "text-[hsl(var(--chat-error))]" : "text-foreground/45";
 
+  const liveOutputSettledOk = Boolean(result) && !result?.isError && !shellSessionFailed;
   useEffect(() => {
     if (readOnly || isRedactedToolContent) return;
     if (userInteractedRef.current) return;
@@ -307,9 +315,21 @@ function ToolCallItem({
     } else if (shouldCloseAnsweredAsk) {
       setOpen(false);
     } else if (shouldAutoOpen) {
+      if (hasLiveOutput) liveAutoOpenedRef.current = true;
       setOpen(true);
+    } else if (liveAutoOpenedRef.current && liveOutputSettledOk) {
+      liveAutoOpenedRef.current = false;
+      setOpen(false);
     }
-  }, [isRedactedToolContent, readOnly, shouldAutoOpen, shouldCloseAnsweredAsk, shouldKeepAskOpen]);
+  }, [
+    hasLiveOutput,
+    isRedactedToolContent,
+    liveOutputSettledOk,
+    readOnly,
+    shouldAutoOpen,
+    shouldCloseAnsweredAsk,
+    shouldKeepAskOpen,
+  ]);
 
   const canExpand =
     !isRedactedToolContent &&
@@ -655,6 +675,7 @@ export function areToolTraceItemsEqual(previous: ToolTraceItem, next: ToolTraceI
   return (
     previous.toolCall.id === next.toolCall.id &&
     previous.toolCall.name === next.toolCall.name &&
+    previous.liveOutput === next.liveOutput &&
     areStableValuesEqual(previous.toolCall.arguments, next.toolCall.arguments) &&
     areToolResultsEqual(previous.toolResult, next.toolResult)
   );
diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx
index 0192da0e2..044407205 100644
--- a/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx
+++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx
@@ -34,12 +34,14 @@ import {
   toolResultMessageToText,
   type WriteResultDetails,
 } from "@liveagent/ui/lib/chat/assistantBubbleAdapter";
+import { formatLiveShellOutput } from "@liveagent/ui/lib/chat/liveShellOutput";
 import { cn } from "@liveagent/ui/lib/shared/utils";
 import type {
   SubagentBatchDetails,
   SubagentCardDetails,
   SubagentMessageDetails,
 } from "@liveagent/ui/lib/subagents/protocol";
+import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
 import { Search } from "../../IconSet";
 import {
   displayString,
@@ -53,6 +55,9 @@ import {
 } from "./assistantBubbleUtils";
 import { getToolResultImages, ToolResultImagePreview } from "./ToolImages";
 
+// 距底部不超过这么多像素视为“跟随最新输出”;用户上翻查看时不抢滚动位置。
+const LIVE_OUTPUT_STICK_THRESHOLD_PX = 24;
+
 type ShellResultDetails = {
   exit_code: number;
   shell: string;
@@ -448,7 +453,30 @@ export function ShellToolDisplay({
       ? item.toolCall.arguments.command.trim()
       : "";
   const output = getShellDisplayOutput(result);
-  if (!command && !output) return null;
+  // 尚无结果时展示后端推送的输出尾部(编译进度等,#975);结果到达后由最终输出取代。
+  const rawLiveOutput = result ? "" : (item.liveOutput ?? "");
+  const liveOutput = useMemo(() => formatLiveShellOutput(rawLiveOutput), [rawLiveOutput]);
+  const isLive = liveOutput !== "";
+  const preRef = useRef(null);
+  const followRef = useRef(true);
+  useEffect(() => {
+    const element = preRef.current;
+    if (!element || !isLive) return;
+    const onScroll = () => {
+      followRef.current =
+        element.scrollHeight - element.scrollTop - element.clientHeight <=
+        LIVE_OUTPUT_STICK_THRESHOLD_PX;
+    };
+    element.addEventListener("scroll", onScroll, { passive: true });
+    return () => element.removeEventListener("scroll", onScroll);
+  }, [isLive]);
+  useLayoutEffect(() => {
+    const element = preRef.current;
+    if (!element || !liveOutput || !followRef.current) return;
+    element.scrollTop = element.scrollHeight;
+  }, [liveOutput]);
+  const body = output ? previewText(output, 6000) : liveOutput;
+  if (!command && !body) return null;
 
   return (
     
Shell
$ {command} - {output ? "\n" : null} + {body ? "\n" : null} ) : null} - {output ? previewText(output, 6000) : null} + {isLive && !output ? ( + + {liveOutput} + + ) : ( + body || null + )}
); diff --git a/crates/agent-ui/src/lib/chat/liveShellOutput.ts b/crates/agent-ui/src/lib/chat/liveShellOutput.ts new file mode 100644 index 000000000..e7eb481a2 --- /dev/null +++ b/crates/agent-ui/src/lib/chat/liveShellOutput.ts @@ -0,0 +1,33 @@ +// 运行中命令输出(K-brain tool.status 快照)的展示整理(#975)。后端已把快照 +// 限制在几 KB 的尾部,这里再按行数/字符数兜底,保证常驻 DOM 有界。 +export const LIVE_SHELL_OUTPUT_MAX_LINES = 200; +export const LIVE_SHELL_OUTPUT_MAX_CHARS = 8000; + +// CSI(颜色、光标移动、清行)与 OSC(终端标题、超链接)序列;进度条输出里常见。 +// biome-ignore lint/suspicious/noControlCharactersInRegex: 匹配终端控制序列本身 +const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; + +/** + * 去掉终端控制序列,按回车(\r)只保留每行最后一次重绘(进度条),再截取尾部。 + */ +export function formatLiveShellOutput(raw: string): string { + if (!raw) return ""; + const lines = raw + .replace(ANSI_PATTERN, "") + .split("\n") + .map((line) => { + const text = line.endsWith("\r") ? line.slice(0, -1) : line; + const lastReturn = text.lastIndexOf("\r"); + return lastReturn >= 0 ? text.slice(lastReturn + 1) : text; + }); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + let text = lines.slice(-LIVE_SHELL_OUTPUT_MAX_LINES).join("\n"); + if (text.length > LIVE_SHELL_OUTPUT_MAX_CHARS) { + text = text.slice(text.length - LIVE_SHELL_OUTPUT_MAX_CHARS); + const firstBreak = text.indexOf("\n"); + if (firstBreak >= 0 && firstBreak < LIVE_SHELL_OUTPUT_MAX_CHARS / 2) { + text = text.slice(firstBreak + 1); + } + } + return text; +} diff --git a/crates/agent-ui/src/lib/chat/uiMessages.ts b/crates/agent-ui/src/lib/chat/uiMessages.ts index 4452acd52..226494013 100644 --- a/crates/agent-ui/src/lib/chat/uiMessages.ts +++ b/crates/agent-ui/src/lib/chat/uiMessages.ts @@ -52,6 +52,11 @@ const GLOBAL_BASH_MAX_TIMEOUT_MS = 600_000; export type ToolTraceItem = { toolCall: ToolCall; toolResult?: ToolResultMessage; + /** + * 命令运行中由后端推送的输出尾部快照(K-brain tool.status,有上限)。只活在 + * 实时轮次里:结果到达即丢弃,不写入历史或持久化消息。 + */ + liveOutput?: string; }; export type UiRoundContentBlock = @@ -1243,10 +1248,12 @@ function upsertToolBlock( const existing = baseBlocks[existingIdx]; if (existing.kind !== "tool") return baseBlocks; const next = baseBlocks.slice(); + // 结果到达后由最终输出取代运行中快照。 + const { liveOutput: _liveOutput, ...settledItem } = existing.item; next[existingIdx] = { kind: "tool", item: { - ...existing.item, + ...(toolResult ? settledItem : existing.item), toolCall: toolCallSnapshot, toolResult: toolResult ?? existing.item.toolResult, }, From e712edd1cb44a960d77fdd5d59888f255cf95d08 Mon Sep 17 00:00:00 2001 From: xiaofei Date: Sun, 1 Nov 2026 21:53:52 -0800 Subject: [PATCH 2/2] chore(kbrain): pin release v0.107.7 --- scripts/release/kbrain.lock.json | 44 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/scripts/release/kbrain.lock.json b/scripts/release/kbrain.lock.json index d5a9ebf4f..69eeb0195 100644 --- a/scripts/release/kbrain.lock.json +++ b/scripts/release/kbrain.lock.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "repository": "https://github.com/Stack-Cairn/K-brain", - "sourceRevision": "0d84c4121b70079bced8b78feab565e1cd3f4b60", + "sourceRevision": "637efbf13762dc95ba74fb5e43a87977f944d8c1", "protocol": "kbrain.agent.v1", "requiredBackendFlags": [ "-parent-stdio", @@ -46,65 +46,65 @@ }, "downloads": { "aarch64-apple-darwin": { - "sourceRevision": "0d84c4121b70079bced8b78feab565e1cd3f4b60", + "sourceRevision": "637efbf13762dc95ba74fb5e43a87977f944d8c1", "assets": { "backend": "k-brain-darwin-arm64", "computer": "k-brain-computer-darwin-arm64" }, "sha256": { - "backend": "f50b8bd71207fda97b178382d0c501386eeb346e401e04cd33796c6db1899d6a", - "computer": "0e89dcbbb57593d8362774daf2b0730aafc85798adf743ee771e67cc4e819150" + "backend": "eff7fd82228c8c092c83496039255550d22b63c0a1215c245991fe22941120db", + "computer": "23a9c7fb87a56dd8eb4598fd2a1da2cb65cf55fd08326b820753e828659ec205" }, "urls": { - "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-darwin-arm64", - "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-computer-darwin-arm64" + "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-darwin-arm64", + "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-computer-darwin-arm64" } }, "x86_64-apple-darwin": { - "sourceRevision": "0d84c4121b70079bced8b78feab565e1cd3f4b60", + "sourceRevision": "637efbf13762dc95ba74fb5e43a87977f944d8c1", "assets": { "backend": "k-brain-darwin-x64", "computer": "k-brain-computer-darwin-x64" }, "sha256": { - "backend": "48e162dcd8237906b91b7c75f638b6e3723564bc12dfd7acf81a8361be484d92", - "computer": "84ed3458f7aafbf6eff289c856d786e66c7b89bd8f99d7ec7d33fd051307448b" + "backend": "d7fe5b8ac59165b46106f891387876704d1e23baa9116178d5e663ae0228c219", + "computer": "39adaf552d4e3dc4c68b6f068b9311aadc85a725b6f4c3f8e6b008e9b8a2eccc" }, "urls": { - "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-darwin-x64", - "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-computer-darwin-x64" + "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-darwin-x64", + "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-computer-darwin-x64" } }, "x86_64-unknown-linux-gnu": { - "sourceRevision": "0d84c4121b70079bced8b78feab565e1cd3f4b60", + "sourceRevision": "637efbf13762dc95ba74fb5e43a87977f944d8c1", "assets": { "backend": "k-brain-linux-x64", "computer": "k-brain-computer-linux-x64" }, "sha256": { - "backend": "411b0e8919f5b5a95ef071daaee5dd6a73544b392ac2fcab1aea89735a36a89d", - "computer": "fcdf4c3252a6783cf597cb6a1ae67f80914dbed81c3d490316769135c5830411" + "backend": "b93064a23fc8977921e5372ccac4ccda2fa4f9dfdc13c6dacaef0949ab50441e", + "computer": "3cd8c5a8b3510a8e83e837b20f033a51ae71d9c42f25c9502299715a2a88625c" }, "urls": { - "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-linux-x64", - "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-computer-linux-x64" + "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-linux-x64", + "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-computer-linux-x64" } }, "x86_64-pc-windows-msvc": { - "sourceRevision": "0d84c4121b70079bced8b78feab565e1cd3f4b60", + "sourceRevision": "637efbf13762dc95ba74fb5e43a87977f944d8c1", "assets": { "backend": "k-brain-windows-x64.exe", "computer": "k-brain-computer-windows-x64.exe" }, "sha256": { - "backend": "8f2278aab43436094deac1012c0f907e758a872432b934a148dd0454cfeded99", - "computer": "bdaad0bb23a21777e9af294e0d17eff0650ed2fd2a9e06a45fb6a4bec99635ee" + "backend": "092660f3b8dea93f2dfaa5931513e63aca7f2fa28488f3d20b86f027bf5a2a73", + "computer": "10251a0f871f2849f574016baedd4909d2c32f2791afe7884361efdb7906a1b4" }, "urls": { - "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-windows-x64.exe", - "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.6/k-brain-computer-windows-x64.exe" + "backend": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-windows-x64.exe", + "computer": "https://github.com/Stack-Cairn/K-brain/releases/download/v0.107.7/k-brain-computer-windows-x64.exe" } } }, - "releaseTag": "v0.107.6" + "releaseTag": "v0.107.7" }