diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index 745f17868f..4caf1f808a 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -176,6 +176,7 @@ jobs: node perf/mcp-actions.mjs node perf/web-access.mjs node perf/peers-settings.mjs + node perf/output-cap.mjs node perf/trust-wait.mjs node perf/nest-nodes.mjs node perf/provider-ports.mjs diff --git a/desktop/frontend-next/perf/output-cap.mjs b/desktop/frontend-next/perf/output-cap.mjs new file mode 100644 index 0000000000..b65b4125d6 --- /dev/null +++ b/desktop/frontend-next/perf/output-cap.mjs @@ -0,0 +1,57 @@ +// What the page holds of finished tool output is bounded: a long session of large results stays light, +// and a card says what it left out. +import { chromium } from "playwright"; + +const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html"; +const RESULTS = Number(process.env.PERF_RESULTS ?? 600); +const HEAP_MAX_MB = Number(process.env.PERF_HEAP_MAX ?? 150); +const fails = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); + if (!ok) fails.push(name); +}; +const shotDir = process.env.SHOTS; + +const browser = await chromium.launch({ args: ["--js-flags=--expose-gc"] }); +for (const scheme of ["light", "dark"]) { + const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" }); + const page = await ctx.newPage(); + page.on("pageerror", (e) => fails.push("page error: " + e.message)); + const cdp = await ctx.newCDPSession(page); + await cdp.send("Performance.enable"); + await page.goto(`${BASE}?turns=2&pref=zh`, { waitUntil: "networkidle" }); + await page.waitForSelector(".compose"); + await page.evaluate((n) => { + window.__feed({ kind: "turn_started" }); + const body = (i) => Array.from({ length: 300 }, (_, k) => `result ${i} line ${k}: matched in internal/pkg${k % 40}/file${k}.go — some context text`).join("\n"); + for (let i = 0; i < n; i++) { + window.__feed({ kind: "tool_dispatch", tool: { id: `r${i}`, name: "bash", args: JSON.stringify({ command: "x" }) } }); + window.__feed({ kind: "tool_result", tool: { id: `r${i}`, name: "bash", args: JSON.stringify({ command: "x" }), output: body(i), durationMs: 200 } }); + } + }, RESULTS); + await page.waitForTimeout(800); + await cdp.send("HeapProfiler.collectGarbage"); + await cdp.send("HeapProfiler.collectGarbage"); + const heap = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value / 1048576; + check(`${scheme}: ${RESULTS} results of about 30 KB leave the page's heap under ${HEAP_MAX_MB} MB`, heap < HEAP_MAX_MB, `${heap.toFixed(1)} MB`); + const said = await page.evaluate(() => document.body.textContent.includes("没有放在页面里")); + check(`${scheme}: a card says what it left out`, said); + if (shotDir) { + await page.evaluate(() => { + const grp = [...document.querySelectorAll("details")].filter((d) => d.querySelector('[data-call="r599"]')).pop(); + if (grp) grp.open = true; + const card = document.querySelector('[data-call="r599"] details.tool-disclosure'); + if (card) { card.open = true; card.dispatchEvent(new Event("toggle")); } + document.querySelector('[data-call="r599"]')?.scrollIntoView({ block: "start" }); + }); + await page.waitForTimeout(400); + await page.screenshot({ path: `${shotDir}/${scheme}-output-cap.png`, clip: { x: 360, y: 40, width: 1000, height: 520 } }); + } + await ctx.close(); +} +await browser.close(); +if (fails.length) { + console.log(`\n${fails.length} failed:\n ` + fails.join("\n ")); + process.exit(1); +} +console.log("\nall passed"); diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index 4c9aefd726..c867c289ed 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -141,6 +141,7 @@ export const ACTIONS: UIAction[] = [ { id: "image.close", kind: "view", target: "none", proof: "interaction" }, { id: "image.focus-trap", kind: "navigation", target: "none", proof: "interaction" }, { id: "tool.show-all", kind: "view", target: "none", proof: "interaction" }, + { id: "tool.show-full", kind: "view", target: "none", proof: "interaction" }, { id: "agent.open", kind: "view", target: "entity", proof: "interaction" }, { id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" }, { id: "agent.close", kind: "view", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 338dfb5be4..ca1babcec0 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -513,6 +513,10 @@ export const EN: Record = { "完整输出已保存({lines} 行 · {size}),模型按需读取": "Full output saved to disk ({lines} lines · {size}); the model reads it on demand", "已显示开头部分,模型可继续往下读": "Showing the start; the model can read on from there", "模型只收到 {kept},共 {size} — 其余未进入上下文": "The model received only {kept} of {size} — the rest never entered its context", + "中间 {n} 个字符没有放在页面里,只显示了开头和结尾": "{n} characters from the middle are not held in the page; only the start and end are shown", + "显示完整输出": "Show full output", + "正在取回…": "Fetching…", + "取回失败,请重试": "Could not fetch it; try again", "在 {name} 下新建会话": "Start a new session in {name}", "关闭这个会话面板": "Close this pane", "不会删除任何文件": "No files are deleted", diff --git a/desktop/frontend-next/src/i18n/en_kernel.ts b/desktop/frontend-next/src/i18n/en_kernel.ts index 5358b48a57..b5aead4438 100644 --- a/desktop/frontend-next/src/i18n/en_kernel.ts +++ b/desktop/frontend-next/src/i18n/en_kernel.ts @@ -466,6 +466,8 @@ export const EN_KERNEL: Record = { "该插件未安装": "That plugin is not installed", "图片数据不是 base64 编码": "The image data is not base64", "HTTP 上不提供 shell 命令": "Shell commands are not served over HTTP", + "这个会话里没有这条调用的输出": "This session has no output for that call", + "要取的输出范围不对,请重新展开这条输出": "The part of the output asked for is not valid; open the output again", "不存在「{role}」这个角色": "There is no “{role}” role", "没有已配置的模型匹配「{model}」": "No configured model matches “{model}”", "「{model}」是决策来源,只服务 system_one 的询问,不能用作对话模型": "“{model}” is a decision source: it only answers system_one's questions and cannot be used as a conversation model", diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index 60f42dffd1..bf2b9933ea 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -468,6 +468,8 @@ const SAID: Record = { "history.query_required": "请输入要搜索的内容", "history.search_failed": "对话内容索引暂时无法回答,稍后再试", "job.not_running": "这个后台任务已经不在运行了", + "tool.output_unknown": "这个会话里没有这条调用的输出", + "tool.output_range": "要取的输出范围不对,请重新展开这条输出", "mux.unknown_connection": "事件流连接已经关闭,需要重新打开并重新订阅各窗格", "mux.pane_not_local": "这个窗格由另一台机器驱动,使用它自己的事件流", "job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待", diff --git a/desktop/frontend-next/src/port/mock_tooloutput.ts b/desktop/frontend-next/src/port/mock_tooloutput.ts new file mode 100644 index 0000000000..e9ad4dd43e --- /dev/null +++ b/desktop/frontend-next/src/port/mock_tooloutput.ts @@ -0,0 +1,19 @@ +import { HttpError } from "./http_error"; +import type { ToolOutputPage } from "./session"; +import { MockGit } from "./mock_git"; + +// The windows onto a call's whole output that a page asks for when it kept only +// the ends of it. +export class MockToolOutput extends MockGit { + // The full output a call produced, by call id, for the windows toolOutput serves. + fullOutputs: Record = {}; + + async toolOutput(callId: string, offset = 0, limit = 64 * 1024): Promise { + const text = this.fullOutputs[callId]; + if (text === undefined) throw new HttpError(404, "no tool call with this id in this session", { code: "tool.output_unknown" }); + const bytes = new TextEncoder().encode(text); + const from = Math.min(offset, bytes.length); + const to = Math.min(bytes.length, from + limit); + return { callId, text: new TextDecoder().decode(bytes.slice(from, to)), bytes: bytes.length, offset: from, truncated: to < bytes.length }; + } +} diff --git a/desktop/frontend-next/src/port/mock_workspace.ts b/desktop/frontend-next/src/port/mock_workspace.ts index b4daddb542..5e3aac14ff 100644 --- a/desktop/frontend-next/src/port/mock_workspace.ts +++ b/desktop/frontend-next/src/port/mock_workspace.ts @@ -2,7 +2,7 @@ import { reason } from "../i18n/kernel"; import { HttpError } from "./http_error"; import type { KeptText } from "./attachment"; import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace"; -import { MockGit } from "./mock_git"; +import { MockToolOutput } from "./mock_tooloutput"; export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo { return { current: root, canSwitch: true, canIsolate: true, recents: [ @@ -19,7 +19,7 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor ] }; } -export class MockWorkspace extends MockGit { +export class MockWorkspace extends MockToolOutput { opensFiles() { return false; } async openFile() {} diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index 5433d7f80f..e1bd824bd7 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -24,7 +24,7 @@ export type { MemoryEdit } from "./memory"; export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage"; import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model"; import type { NetworkProbe, NetworkSettings } from "./network"; -import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session"; +import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session"; import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell"; import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill"; import type { SubagentRun, SubagentTranscript } from "./subagent"; @@ -704,6 +704,10 @@ export interface AgentPort { // not take output from the model's own reader; a job that ended is refused // (job.not_running). jobOutput(jobId: string): Promise; + // A window onto one tool call's output as the history carries it, for a page + // that kept only the ends. Refused with tool.output_unknown for a call this + // session has no record of and tool.output_range for a window it cannot cut. + toolOutput(callId: string, offset?: number, limit?: number): Promise; // Re-freezes the files an entry references. A line that waited through the // work that changed them would otherwise arrive quoting what is no longer. refreshQueued(itemId: string): Promise; diff --git a/desktop/frontend-next/src/port/session.ts b/desktop/frontend-next/src/port/session.ts index 278c249f57..5cda84f516 100644 --- a/desktop/frontend-next/src/port/session.ts +++ b/desktop/frontend-next/src/port/session.ts @@ -324,6 +324,16 @@ export interface JobOutput { truncated: boolean; } +// One window onto the output of one tool call as the history carries it: where +// Text starts, the whole length, and whether more follows. +export interface ToolOutputPage { + callId: string; + text: string; + bytes: number; + offset: number; + truncated: boolean; +} + // The UI depends on this and nothing else. SsePort talks to internal/frontend/serve; // MockPort replays a fixture. Neither is allowed to leak transport details // upward, which is what keeps the same UI usable in a browser and in a shell. diff --git a/desktop/frontend-next/src/port/sse_workspace.ts b/desktop/frontend-next/src/port/sse_workspace.ts index c49f604f74..39fa7de8bc 100644 --- a/desktop/frontend-next/src/port/sse_workspace.ts +++ b/desktop/frontend-next/src/port/sse_workspace.ts @@ -1,6 +1,6 @@ import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port"; import type { DiscardRequest, GitCommitFiles, GitHistory, GitRepos, PushPlan, PushRequest, PushResult } from "./workspace"; -import type { JobOutput } from "./session"; +import type { JobOutput, ToolOutputPage } from "./session"; import type { SubagentRun, SubagentTranscript } from "./subagent"; import { SseFeedback } from "./sse_feedback"; import { host } from "./host"; @@ -95,4 +95,8 @@ export class SseWorkspace extends SseFeedback { jobOutput(jobId: string) { return this.get("/jobs/" + encodeURIComponent(jobId) + "/output"); } + toolOutput(callId: string, offset = 0, limit?: number) { + const q = new URLSearchParams({ offset: String(offset), ...(limit === undefined ? {} : { limit: String(limit) }) }); + return this.get("/history/tool/" + encodeURIComponent(callId) + "?" + q); + } } diff --git a/desktop/frontend-next/src/state/outputcap.test.ts b/desktop/frontend-next/src/state/outputcap.test.ts new file mode 100644 index 0000000000..67037b35f4 --- /dev/null +++ b/desktop/frontend-next/src/state/outputcap.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { capToolOutput, OUTPUT_CAP, OUTPUT_EDGE } from "./outputcap"; +import type { Tool } from "../port/wire"; + +const tool = (output: string, over: Partial = {}): Tool => ({ id: "c1", name: "bash", args: "{}", output, readOnly: false, ...over }); + +describe("what the page keeps of a finished call's output", () => { + it("is all of it up to the cap", () => { + const t = tool("x".repeat(OUTPUT_CAP)); + expect(capToolOutput(t)).toBe(t); + }); + + it("is the first and last 4 KB beyond it, counting the middle it let go of", () => { + const body = "a".repeat(OUTPUT_EDGE) + "m".repeat(20000) + "z".repeat(OUTPUT_EDGE); + const capped = capToolOutput(tool(body)); + expect(capped.output).toBe("a".repeat(OUTPUT_EDGE) + "\n⋯\n" + "z".repeat(OUTPUT_EDGE)); + expect(capped.hidden).toBe(20000); + expect(capped.output!.length + capped.hidden! - 3).toBe(body.length); + }); + + it("cuts no character in half at either end", () => { + const emoji = "😀"; + const body = "a".repeat(OUTPUT_EDGE - 1) + emoji + "m".repeat(20000) + emoji + "b".repeat(OUTPUT_EDGE - 1); + const out = capToolOutput(tool(body)).output!; + expect(/[\ud800-\udbff]$/.test(out.split("\n⋯\n")[0])).toBe(false); + expect(/^[\udc00-\udfff]/.test(out.split("\n⋯\n")[1])).toBe(false); + }); + + it("leaves a diff, a plan and a remembered fact whole, because something else reads them", () => { + const big = "x".repeat(OUTPUT_CAP * 2); + expect(capToolOutput(tool(big, { outputDiff: true })).output).toBe(big); + expect(capToolOutput(tool(big, { name: "todo_write" })).output).toBe(big); + expect(capToolOutput(tool(big, { name: "remember" })).output).toBe(big); + }); +}); diff --git a/desktop/frontend-next/src/state/outputcap.ts b/desktop/frontend-next/src/state/outputcap.ts new file mode 100644 index 0000000000..c2bd5cf67c --- /dev/null +++ b/desktop/frontend-next/src/state/outputcap.ts @@ -0,0 +1,28 @@ +import type { Tool } from "../port/wire"; + +/** A finished call's output longer than this is kept as its two ends. The kernel + * already bounds a result for the model; this bounds what the page holds of it. */ +export const OUTPUT_CAP = 16 * 1024; +export const OUTPUT_EDGE = 4 * 1024; + +// The calls whose output something other than the output card reads: a plan, a +// remembered fact, a diff drawn as a diff. +const WHOLE = new Set(["todo_write", "remember", "update_goal"]); + +/** What the page keeps of a tool: the same call with `hidden` counting the + * characters of the middle of its output that it let go of. */ +export type StateTool = Tool & { hidden?: number }; + +export function capToolOutput(tool: StateTool): StateTool { + const out = tool.output; + if (!out || out.length <= OUTPUT_CAP || tool.outputDiff || tool.diff || WHOLE.has(tool.name)) return tool; + let head = OUTPUT_EDGE; + let tail = out.length - OUTPUT_EDGE; + // A cut between the halves of a surrogate pair would end or start an end on half a character. + if (isHigh(out.charCodeAt(head - 1))) head--; + if (isLow(out.charCodeAt(tail))) tail++; + return { ...tool, output: out.slice(0, head) + "\n⋯\n" + out.slice(tail), hidden: tail - head }; +} + +const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff; +const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff; diff --git a/desktop/frontend-next/src/state/session.ts b/desktop/frontend-next/src/state/session.ts index 78b64817e8..c17a6f0662 100644 --- a/desktop/frontend-next/src/state/session.ts +++ b/desktop/frontend-next/src/state/session.ts @@ -24,6 +24,7 @@ import { worked } from "./worked"; import { chipLabel, IDLE, RUNNING } from "./chip"; import { foldStall } from "./stall"; import { nameQueued, withoutPendingRow } from "./queued"; +import { capToolOutput } from "./outputcap"; import { dropTool, foldLastRead, foldProgress, foldTool, isSubagentProgress, mergeReads, notePhase, noteRan } from "./fold"; import { rebuild } from "./rebuild"; export { chipLabel }; @@ -430,7 +431,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState { executions, doing: handedBack ? WAITING_MODEL : s.doing, waiting: handedBack ? { ...s.waiting, ttftSince: Date.now() } : s.waiting, - items: mergeReads(foldTool(s.items, ev.tool, false)), + items: mergeReads(foldTool(s.items, capToolOutput(ev.tool), false)), }; } @@ -778,12 +779,12 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions } out[at] = { ...prev, - tool: { + tool: capToolOutput({ ...prev.tool, output: m.content, ...(m.outputDiff ? { outputDiff: true } : {}), ...(m.toolFailed ? { err: m.content, refusalCode: m.toolRefusalCode } : {}), - }, + }), }; } } diff --git a/desktop/frontend-next/src/state/session_types.ts b/desktop/frontend-next/src/state/session_types.ts index 1323d3b381..13e53ca456 100644 --- a/desktop/frontend-next/src/state/session_types.ts +++ b/desktop/frontend-next/src/state/session_types.ts @@ -1,5 +1,6 @@ // What a session is made of, apart from the reducer that maintains it: the // rows the transcript draws and the state one turn hands the next. +import type { StateTool } from "./outputcap"; import type { Ask, Approval, Compaction, CostCoverage, ExtensionSurface, Guardian, Receipt, StoredAttachment, Tool, Via, WorkspaceLease } from "../port/wire"; import type { Sample } from "../port/tokens"; import type { Executions } from "./executions"; @@ -34,7 +35,7 @@ export type Item = // source names which model wrote it — a turn can carry two, and the card is // where the answer has to survive. | { t: "say"; id: string; text: string; reasoning?: string; done: boolean; thoughtMs?: number; source?: string; model?: string } - | { t: "tool"; id: string; tool: Tool; running: boolean; children: Tool[] } + | { t: "tool"; id: string; tool: StateTool; running: boolean; children: StateTool[] } | { t: "reads"; id: string; tools: Tool[] } | { t: "guardian"; id: string; g: Guardian } // by is who settled it when that was not this screen: the device named on diff --git a/desktop/frontend-next/src/ui/Transcript.tsx b/desktop/frontend-next/src/ui/Transcript.tsx index 846d6cadad..31bba4256a 100644 --- a/desktop/frontend-next/src/ui/Transcript.tsx +++ b/desktop/frontend-next/src/ui/Transcript.tsx @@ -39,7 +39,7 @@ import { chunkText, heldBlocks, type Held } from "./chunk_text"; interface Props { items: Item[]; - port?: Pick & Partial>; + port?: Pick & Partial>; // Changes only when the transcript's composition does — see state/session. revision: number; waiting: Waiting; @@ -745,6 +745,7 @@ const Row = memo(function Row({ running={it.running} activity={activity} children={it.children} + readOutput={port?.toolOutput?.bind(port)} takeover={it.tool.id ? takeovers[`tool:${it.tool.id}`] : undefined} onExtInvoke={onExtInvoke} onPrepareFileRevert={onPrepareFileRevert} diff --git a/desktop/frontend-next/src/ui/cards/ToolCard.tsx b/desktop/frontend-next/src/ui/cards/ToolCard.tsx index aae30b390f..b78cf5a83b 100644 --- a/desktop/frontend-next/src/ui/cards/ToolCard.tsx +++ b/desktop/frontend-next/src/ui/cards/ToolCard.tsx @@ -20,6 +20,8 @@ import { ExtensionView } from "./ExtensionView"; import { toolChangedFile, toolErrorDetail, toolFailed, toolFailureLabel, toolOutputEchoed, toolRefusalReason } from "./outcome"; import { StudioIcon } from "../StudioIcon"; import { registerNest } from "./nestreveal"; +import { useFullOutput, type ReadOutput } from "./useFullOutput"; +import type { StateTool } from "../../state/outputcap"; import { ImagePreview } from "../ImagePreview"; // The spec pops a symbol as it settles — colour arriving is the finish signal. @@ -61,12 +63,14 @@ function useSettling(running: boolean): { pop: boolean; swap: boolean } { } export function ToolCard({ - tool, running, children = [], takeover, onExtInvoke, onPrepareFileRevert, onCommitFileRevert, onStop, onBring, pinned = false, activity = false, + tool, running, children = [], readOutput, takeover, onExtInvoke, onPrepareFileRevert, onCommitFileRevert, onStop, onBring, pinned = false, activity = false, }: { - tool: Tool; + tool: StateTool; running: boolean; activity?: boolean; - children?: Tool[]; + children?: StateTool[]; + // Reads a call's whole output back from the kernel when the page kept only its ends. + readOutput?: ReadOutput; // Reverting the file this call wrote. Optional: a card drawn outside a live // pane (history, a fixture) has nothing to revert against. onPrepareFileRevert?: (path: string) => Promise; @@ -117,6 +121,7 @@ export function ToolCard({ // model as output and to the reader as an error. Rendering both prints it // twice, and the second copy reads like a second failure. const echoed = toolOutputEchoed(tool); + const full = useFullOutput(tool, readOutput); // update_goal's payload is the model's own prose about the turn. Its shape is // one claim — done, still going, or stuck — so the card says the claim rather // than the object carrying it. @@ -243,7 +248,10 @@ export function ToolCard({ )} {!tool.diff && !tool.outputDiff && !goal && tool.name !== "todo_write" && tool.output && !echoed && children.length === 0 && ( - + <> + {full.note} + + )} @@ -274,7 +282,7 @@ export function ToolCard({ <>
{children.map((c) => ( - + ))}
{/* What the delegate handed back closes its own panel: outside it, @@ -355,12 +363,14 @@ function changeCounts(tool: Tool): { added: number; removed: number } | null { const NESTED_PREVIEW = 400; const NESTED_WHOLE_CAP = 200_000; -export function NestedCall({ tool, whole = false }: { tool: Tool; whole?: boolean }) { +export function NestedCall({ tool, whole = false, read }: { tool: StateTool; whole?: boolean; read?: ReadOutput }) { const [all, setAll] = useState(false); + const full = useFullOutput(tool, read); + const body = full.text ?? ""; const shown = tool.resolvedName || tool.name; const tag = tagFor(tool); const bad = toolFailed(tool); - const clipped = !whole && (tool.output?.length ?? 0) > NESTED_PREVIEW; + const clipped = !whole && body.length > NESTED_PREVIEW; const echoed = toolOutputEchoed(tool); const refusal = toolRefusalReason(tool); const errorDetail = toolErrorDetail(tool); @@ -379,8 +389,9 @@ export function NestedCall({ tool, whole = false }: { tool: Tool; whole?: boolea {tool.output && !echoed && (
- - {(whole || all) && tool.output.length > NESTED_WHOLE_CAP &&
{t("输出过长,仅显示前 200000 个字符")}
} + {full.note} + + {(whole || all) && body.length > NESTED_WHOLE_CAP &&
{t("输出过长,仅显示前 200000 个字符")}
} {clipped && (
{all ? null : t("仅显示前 400 个字符")} diff --git a/desktop/frontend-next/src/ui/cards/output-cap.interaction.test.tsx b/desktop/frontend-next/src/ui/cards/output-cap.interaction.test.tsx new file mode 100644 index 0000000000..5e1349dbab --- /dev/null +++ b/desktop/frontend-next/src/ui/cards/output-cap.interaction.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "../testkit"; +import { Transcript } from "../Transcript"; +import { HttpError } from "../../port/http_error"; +import { capToolOutput, OUTPUT_EDGE } from "../../state/outputcap"; +import { fromHistory } from "../../state/session"; +import type { Item } from "../../state/session"; +import type { ToolOutputPage } from "../../port/session"; + +Element.prototype.getAnimations ??= () => []; + +afterEach(cleanup); + +const noop = async () => undefined as never; +const same = { entering: [] as string[], onEntered: () => {}, waiting: {}, scroll: { current: null }, onExtInvoke: () => {}, checkpoints: new Map(), onOpenProject: () => {}, onKeepHere: () => {}, takeovers: {} }; +type Reader = (id: string, offset?: number, limit?: number) => Promise; +const view = (items: Item[], toolOutput?: Reader) => ( + "", ...(toolOutput ? { toolOutput } : {}) } as never} revision={items.length} hidden={false} focus={null} onApprove={noop} onFullAccess={noop} onPlan={noop} onAnswer={noop} onForget={noop} + onExtSubmit={noop} onPrepareRewind={noop} onCommitRewind={noop} onPrepareFileRevert={noop} onCommitFileRevert={noop} needsProject={false} /> +); + +const FULL = "head-" + "x".repeat(OUTPUT_EDGE - 5) + "M".repeat(30_000) + "y".repeat(OUTPUT_EDGE - 5) + "-tail"; +const capped = (id = "c1") => capToolOutput({ id, name: "bash", args: "{}", output: FULL, readOnly: false }); +const item = (): Item => ({ t: "tool", id: "r1", running: false, children: [], tool: capped() }); +const pages = (): Reader => vi.fn(async (_id, offset = 0, limit = 8192) => { + const bytes = new TextEncoder().encode(FULL); + const end = Math.min(bytes.length, offset + Math.min(limit, 8192)); + return { callId: "c1", text: new TextDecoder().decode(bytes.slice(offset, end)), bytes: bytes.length, offset, truncated: end < bytes.length }; +}); +const open = () => { const d = document.querySelector("details.tool-disclosure")!; d.open = true; fireEvent(d, new Event("toggle")); }; + +describe("a finished call whose middle the page let go of", () => { + it("says so and keeps the start and the end", () => { + render(view([item()], pages())); + open(); + expect(screen.getByText(/中间 30,000 个字符没有放在页面里/)).toBeTruthy(); + expect(document.body.textContent).toContain("head-"); + expect(document.body.textContent).toContain("-tail"); + expect(document.body.textContent).not.toContain("M".repeat(100)); + }); + + it("brings the whole output back page by page when asked, and stops saying anything was left out", async () => { + const read = pages(); + render(view([item()], read)); + open(); + fireEvent.click(screen.getByRole("button", { name: "显示完整输出" })); + await waitFor(() => expect(document.body.textContent).toContain("M".repeat(100))); + expect(read).toHaveBeenCalled(); + expect((read as unknown as { mock: { calls: unknown[][] } }).mock.calls.length).toBeGreaterThan(1); + expect(screen.queryByText(/没有放在页面里/)).toBeNull(); + }); + + it("says why it could not be fetched, and can try again", async () => { + const read = vi.fn().mockRejectedValueOnce(new HttpError(404, "x", { code: "tool.output_unknown", error: "x" })).mockImplementation(pages()); + render(view([item()], read)); + open(); + fireEvent.click(screen.getByRole("button", { name: "显示完整输出" })); + expect((await screen.findByRole("alert")).textContent).toContain("这个会话里没有这条调用的输出"); + fireEvent.click(screen.getByRole("button", { name: "重试" })); + await waitFor(() => expect(document.body.textContent).toContain("M".repeat(100))); + }); + + it("offers nothing to press where the window has no way to read it back, but still says what was left out", () => { + render(view([item()])); + open(); + expect(screen.getByText(/没有放在页面里/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "显示完整输出" })).toBeNull(); + }); + + it("is the same for a sub-agent's child call", async () => { + const read = pages(); + const parent: Item = { t: "tool", id: "p1", running: false, children: [capped("c1")], tool: { id: "p1", name: "task", args: "{}", output: "report", readOnly: false } }; + render(view([parent], read)); + open(); + fireEvent.click(await screen.findByRole("button", { name: "显示完整输出" })); + await waitFor(() => expect(read).toHaveBeenCalled()); + }); +}); + +describe("what the page keeps when a session is rebuilt", () => { + it("is the capped form of a long result, with the count", () => { + const msgs = [ + { role: "assistant", content: "", msgIndex: 0, toolCalls: [{ id: "c1", name: "bash", arguments: "{}" }] }, + { role: "tool", toolCallId: "c1", toolName: "bash", content: FULL, msgIndex: 1 }, + ]; + const built = fromHistory(msgs as never); + const tool = (built.items.find((i) => i.t === "tool") as Extract).tool; + expect(tool.hidden).toBe(30_000); + expect(tool.output!.length).toBeLessThan(FULL.length / 2); + }); +}); diff --git a/desktop/frontend-next/src/ui/cards/useFullOutput.tsx b/desktop/frontend-next/src/ui/cards/useFullOutput.tsx new file mode 100644 index 0000000000..a05da16ff0 --- /dev/null +++ b/desktop/frontend-next/src/ui/cards/useFullOutput.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { t } from "../../i18n"; +import { reason } from "../../i18n/kernel"; +import { count } from "../../i18n/format"; +import type { ToolOutputPage } from "../../port/session"; +import type { StateTool } from "../../state/outputcap"; + +export type ReadOutput = (callId: string, offset?: number, limit?: number) => Promise; + +const PAGE = 64 * 1024; +// Four megabytes is more than a person reads in a card; past it the rest stays in the kernel. +const MAX_PAGES = 64; + +type Fetch = { phase: "idle" } | { phase: "loading" } | { phase: "failed"; why: string } | { phase: "done"; text: string }; + +// A call whose middle the page let go of can bring it back whole: the pages are +// read from the kernel in turn and kept only by the card that asked. +export function useFullOutput(tool: StateTool, read?: ReadOutput) { + const [got, setGot] = useState({ phase: "idle" }); + const hidden = tool.hidden ?? 0; + const load = async () => { + if (!read || !tool.id || got.phase === "loading") return; + setGot({ phase: "loading" }); + try { + let text = ""; + let offset = 0; + for (let i = 0; i < MAX_PAGES; i++) { + const page = await read(tool.id, offset, PAGE); + text += page.text; + offset = page.offset + new TextEncoder().encode(page.text).length; + if (!page.truncated) break; + } + setGot({ phase: "done", text }); + } catch (e) { + setGot({ phase: "failed", why: reason(e) || t("取回失败,请重试") }); + } + }; + return { + text: got.phase === "done" ? got.text : tool.output, + note: hidden > 0 && got.phase !== "done" ?