Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion crates/agent-gui/src/lib/chat/messages/uiMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -352,6 +359,26 @@ export function attachToolResultToRound<TRound extends Pick<UiRound, "blocks">>(
};
}

/**
* 写入运行中命令的输出快照。快照是截至此刻的完整尾部(不是增量),整体替换;
* 已有结果或找不到对应工具块时原样返回,避免晚到的快照盖住最终结果。
*/
export function setToolLiveOutputInRound<TRound extends Pick<UiRound, "blocks">>(
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;
Expand Down
16 changes: 16 additions & 0 deletions crates/agent-gui/src/lib/kbrain/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -644,6 +646,8 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
let thinking = "";
let finalMessage: KBrainMessage | undefined;
const calls = new Map<string, ToolCall>();
// 已出结果的调用:之后到达的运行中输出一律忽略。
const settledCalls = new Set<string>();
let afterSeq = accepted.accepted_seq;
let terminal: "stop" | "error" | "aborted" = "stop";
let errorMessage: string | undefined;
Expand Down Expand Up @@ -739,6 +743,7 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
name: result.toolName,
arguments: {},
};
settledCalls.add(result.toolCallId);
params.onToolResult(call, result);
break;
}
Expand Down Expand Up @@ -792,9 +797,20 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
}
case "tool.status": {
const payload = eventPayload<{
tool_call_id?: string | null;
status?: string | null;
text?: string | null;
message?: string | null;
}>(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;
}
Expand Down
15 changes: 15 additions & 0 deletions crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
attachToolResultToRound,
collapseThinking,
type LiveRound,
setToolLiveOutputInRound,
updateLiveRound,
upsertHostedSearchToRound,
upsertToolCallToRound,
Expand Down Expand Up @@ -216,6 +217,19 @@ export async function runKBrainConversationTurn(params: Params): Promise<void> {
});
} 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 给出下一轮的请求标识,这里挂接上去,
Expand Down Expand Up @@ -288,6 +302,7 @@ export async function runKBrainConversationTurn(params: Params): Promise<void> {
},
onToolCall,
onToolResult,
onToolOutput,
onStatus: status,
onQuestionRequest: async (
request: KBrainQuestionRequest,
Expand Down
111 changes: 111 additions & 0 deletions crates/agent-gui/test/chat/kbrain-live-tool-output.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
45 changes: 42 additions & 3 deletions crates/agent-gui/test/chat/tool-argument-display.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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 = {
Expand Down
11 changes: 8 additions & 3 deletions crates/agent-ui/src/components/chat/ToolSurfaces.tsx
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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<HTMLPreElement>;
}) {
const { children, className, ref } = props;
return (
<pre
ref={ref}
className={cn(
"tool-text-scroll overflow-x-auto overflow-y-auto",
"whitespace-pre break-normal rounded-md px-2.5 py-2 text-xs leading-1p6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,18 @@ function ToolCallItem({
item.toolCall.id,
item.toolCall.arguments,
);
// 命令运行中收到输出(#975)就展开卡片显示实时输出;成功结束后再自动收起,
// 失败时保持展开以便查看错误。用户手动开合过则不再干预。
const hasLiveOutput = isBash && !result && Boolean(item.liveOutput);
const shouldAutoOpen =
!isRedactedToolContent &&
(item.toolCall.name === "Image" || builtinResultKind === "display_image" || shouldKeepAskOpen);
(item.toolCall.name === "Image" ||
builtinResultKind === "display_image" ||
shouldKeepAskOpen ||
hasLiveOutput);
const [open, setOpen] = useState(readOnly || isRedactedToolContent ? false : shouldAutoOpen);
const userInteractedRef = useRef(false);
const liveAutoOpenedRef = useRef(false);
const isSubagentCard = isSubagentCardToolCall(item.toolCall);
const hasArgs = Object.keys(item.toolCall.arguments || {}).length > 0;
const isStreamingFilePreviewTool = FILE_TOOL_TEXT_FIELDS[item.toolCall.name] !== undefined;
Expand Down Expand Up @@ -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;
Expand All @@ -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 &&
Expand Down Expand Up @@ -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)
);
Expand Down
Loading
Loading