feat(studio): a finished call's long output is kept as its two ends in the page and fetched whole from the kernel on request

This commit is contained in:
esengine committed 2026-10-19 00:22:56 -07:00
1 parent 60aba758a8
commit 0c5e8e2264
25 files changed
+611 -20

No files matched your search

+1
View File
@@ -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
+57
View File
@@ -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");
+1
View File
@@ -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" },
+4
View File
@@ -513,6 +513,10 @@ export const EN: Record<string, string> = {
"完整输出已保存({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",
@@ -466,6 +466,8 @@ export const EN_KERNEL: Record<string, string> = {
"该插件未安装": "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",
+2
View File
@@ -468,6 +468,8 @@ const SAID: Record<string, string> = {
"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": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
@@ -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<string, string> = {};
async toolOutput(callId: string, offset = 0, limit = 64 * 1024): Promise<ToolOutputPage> {
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 };
}
}
@@ -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() {}
+5 -1
View File
@@ -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<JobOutput>;
// 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<ToolOutputPage>;
// 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<void>;
+10
View File
@@ -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.
@@ -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<JobOutput>("/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<ToolOutputPage>("/history/tool/" + encodeURIComponent(callId) + "?" + q);
}
}
@@ -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> = {}): 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);
});
});
@@ -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;
+4 -3
View File
@@ -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 } : {}),
},
}),
};
}
}
@@ -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
+2 -1
View File
@@ -39,7 +39,7 @@ import { chunkText, heldBlocks, type Held } from "./chunk_text";
interface Props {
items: Item[];
port?: Pick<AgentPort, "workspaceImageURL"> & Partial<Pick<AgentPort, "surfaceAction" | "surfaceActionState" | "cancelQueued" | "engageQuestion" | "stopToolCall" | "refinePrompt">>;
port?: Pick<AgentPort, "workspaceImageURL"> & Partial<Pick<AgentPort, "surfaceAction" | "surfaceActionState" | "cancelQueued" | "engageQuestion" | "stopToolCall" | "refinePrompt" | "toolOutput">>;
// 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}
@@ -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<RewindPlan>;
@@ -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({
</div>
)}
{!tool.diff && !tool.outputDiff && !goal && tool.name !== "todo_write" && tool.output && !echoed && children.length === 0 && (
<ToolOutput name={shown} text={tool.output} bound={tool.bound} id={tool.id} />
<>
{full.note}
<ToolOutput name={shown} text={full.text ?? ""} bound={tool.bound} id={tool.id} />
</>
)}
<ToolShots images={tool.images} />
<ToolVideos videos={tool.videos} />
@@ -274,7 +282,7 @@ export function ToolCard({
<>
<div className="nest-bd">
{children.map((c) => (
<NestedCall key={c.id} tool={c} />
<NestedCall key={c.id} tool={c} read={readOutput} />
))}
</div>
{/* 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
</div>
{tool.output && !echoed && (
<div className="out">
<Term text={clipped && !all ? tool.output.slice(0, NESTED_PREVIEW) : tool.output.slice(0, NESTED_WHOLE_CAP)} />
{(whole || all) && tool.output.length > NESTED_WHOLE_CAP && <div className="bound bad">{t("输出过长,仅显示前 200000 个字符")}</div>}
{full.note}
<Term text={clipped && !all ? body.slice(0, NESTED_PREVIEW) : body.slice(0, NESTED_WHOLE_CAP)} />
{(whole || all) && body.length > NESTED_WHOLE_CAP && <div className="bound bad">{t("输出过长,仅显示前 200000 个字符")}</div>}
{clipped && (
<div className="bound">
{all ? null : t("仅显示前 400 个字符")}
@@ -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<ToolOutputPage>;
const view = (items: Item[], toolOutput?: Reader) => (
<Transcript items={items} {...same} port={{ workspaceImageURL: () => "", ...(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<HTMLDetailsElement>("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<Reader>().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<Item, { t: "tool" }>).tool;
expect(tool.hidden).toBe(30_000);
expect(tool.output!.length).toBeLessThan(FULL.length / 2);
});
});
@@ -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<ToolOutputPage>;
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<Fetch>({ 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" ? <HiddenNote hidden={hidden} got={got} canLoad={!!read && !!tool.id} onLoad={() => void load()} /> : null,
};
}
function HiddenNote({ hidden, got, canLoad, onLoad }: { hidden: number; got: Fetch; canLoad: boolean; onLoad: () => void }) {
return (
<div className="bound">
{t("中间 {n} 个字符没有放在页面里,只显示了开头和结尾", { n: count(hidden) })}
{canLoad && got.phase !== "loading" && (
<button type="button" className="out-more" data-action="tool.show-full" onClick={onLoad}>
{got.phase === "failed" ? t("重试") : t("显示完整输出")}
</button>
)}
{got.phase === "loading" && <span role="status"> {t("正在取回…")}</span>}
{got.phase === "failed" && <span role="alert" className="bad"> {got.why}</span>}
</div>
);
}
+95 -1
View File
@@ -3,6 +3,8 @@ package serve
import (
"net/http"
"reasonix/internal/state/sessionstore"
"strconv"
"unicode/utf8"
"reasonix/internal/base/diff"
"reasonix/internal/contract/event"
@@ -153,9 +155,101 @@ func historyMessagesWith(msgs []provider.Message, attachments func(string) []eve
// if the client sends If-None-Match with the current ETag, the server returns
// 304 Not Modified with no body, saving bandwidth on reconnects.
func (s *Server) history(w http.ResponseWriter, r *http.Request) {
writeJSONCached(w, r, s.projectedHistory())
}
// projectedHistory is the one reading of the message log a frontend is given:
// everything that serves a part of it goes through here, so a part can never
// carry more than the whole does.
func (s *Server) projectedHistory() []historyMessage {
msgs := historyMessagesWith(s.ctl().History(), s.ctl().StoredAttachments)
markHistoryDiffs(msgs, s.ctl().EmbeddedDiffDetectionEnabled())
writeJSONCached(w, r, msgs)
return msgs
}
func (s *Server) registerHistoryRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /history", s.history)
mux.HandleFunc("GET /history/tool/{id}", s.historyToolOutput)
}
// ToolOutputPage is one window onto the output of one tool call as /history
// carries it. Bytes is the whole length, Offset where Text starts, and
// Truncated says there is more after it.
type ToolOutputPage struct {
CallID string `json:"callId"`
Text string `json:"text"`
Bytes int `json:"bytes"`
Offset int `json:"offset"`
Truncated bool `json:"truncated"`
}
const (
toolOutputDefault = 64 << 10
toolOutputMax = 256 << 10
)
// historyToolOutput serves a call's output a window at a time, for a page that
// kept only the ends of it. It reads the same projection /history does and the
// id is only a lookup key into it: nothing from the request names a file.
func (s *Server) historyToolOutput(w http.ResponseWriter, r *http.Request) {
offset, limit, ok := toolOutputWindow(r)
if !ok {
refuse(w, http.StatusBadRequest, "tool.output_range", "offset and limit must be whole numbers, limit above zero", nil)
return
}
id := r.PathValue("id")
var text string
found := false
for _, m := range s.projectedHistory() {
if m.Role == string(provider.RoleTool) && m.ToolCallID == id {
text, found = m.Content, true
}
}
if !found {
refuse(w, http.StatusNotFound, "tool.output_unknown", "no tool call with this id in this session", nil)
return
}
if offset > len(text) || (offset == len(text) && len(text) > 0) {
refuse(w, http.StatusBadRequest, "tool.output_range", "offset is past the end of the output", nil)
return
}
start := runeStartAtOrBefore(text, offset)
end := runeStartAtOrBefore(text, min(len(text), start+limit))
if end <= start && start < len(text) {
end = min(len(text), start+utf8.UTFMax)
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, ToolOutputPage{CallID: id, Text: text[start:end], Bytes: len(text), Offset: start, Truncated: end < len(text)})
}
// toolOutputWindow reads offset and limit: whole non-negative numbers, the
// limit above zero and no larger than a response may carry.
func toolOutputWindow(r *http.Request) (offset, limit int, ok bool) {
limit = toolOutputDefault
q := r.URL.Query()
if raw := q.Get("offset"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0, 0, false
}
offset = n
}
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return 0, 0, false
}
limit = min(n, toolOutputMax)
}
return offset, limit, true
}
// runeStartAtOrBefore moves i back to the first byte of the character it falls in.
func runeStartAtOrBefore(s string, i int) int {
for i > 0 && i < len(s) && !utf8.RuneStart(s[i]) {
i--
}
return i
}
// markHistoryDiffs re-derives the whole-diff tag on a rebuilt transcript, since
@@ -0,0 +1,161 @@
package serve
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
type historyCtl struct {
control.SessionAPI
msgs []provider.Message
}
func (h historyCtl) History() []provider.Message { return h.msgs }
func (historyCtl) StoredAttachments(string) []event.Attachment { return nil }
func (historyCtl) EmbeddedDiffDetectionEnabled() bool { return false }
func historyRoutes(msgs []provider.Message) http.Handler {
mux := http.NewServeMux()
(&Server{ctrl: historyCtl{msgs: msgs}}).registerHistoryRoutes(mux)
return mux
}
func getHistory(h http.Handler, path string, device bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
if device {
req = req.WithContext(withDeviceReach(req.Context(), "phone-1", 1))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// A result with multi-byte text, so a page boundary can land inside a character.
func toolMessages(content string) []provider.Message {
return []provider.Message{
{Role: provider.RoleUser, Content: "go"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "bash"}}},
{Role: provider.RoleTool, ToolCallID: "call-1", Name: "bash", Content: content},
}
}
func TestToolOutputPagesJoinToWhatHistoryCarriesForThatCall(t *testing.T) {
content := strings.Repeat("输出行 héllo 😀 end\n", 9000)
h := historyRoutes(toolMessages(content))
var fromHistory string
var all []historyMessage
if err := json.Unmarshal(getHistory(h, "/history", false).Body.Bytes(), &all); err != nil {
t.Fatal(err)
}
for _, m := range all {
if m.ToolCallID == "call-1" {
fromHistory = m.Content
}
}
if fromHistory != content {
t.Fatal("the fixture's history does not carry the content whole")
}
var joined strings.Builder
next, pages := 0, 0
for {
rec := getHistory(h, fmt.Sprintf("/history/tool/call-1?offset=%d&limit=1000", next), false)
if rec.Code != http.StatusOK {
t.Fatalf("page at %d: %d %s", next, rec.Code, rec.Body.String())
}
var page ToolOutputPage
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if page.Offset != next || page.Bytes != len(fromHistory) || page.CallID != "call-1" {
t.Fatalf("page = {offset %d bytes %d call %q}, want {%d %d call-1}", page.Offset, page.Bytes, page.CallID, next, len(fromHistory))
}
joined.WriteString(page.Text)
next += len(page.Text)
pages++
if !page.Truncated {
break
}
if pages > 10000 {
t.Fatal("the pages never end")
}
}
if joined.String() != fromHistory {
t.Fatalf("pages joined to %d bytes that differ from /history's %d", joined.Len(), len(fromHistory))
}
if rec := getHistory(h, "/history/tool/call-1", false); rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
}
}
func TestToolOutputIsCutOnACharacterAndAWholeCharacterFits(t *testing.T) {
h := historyRoutes(toolMessages("😀😀😀😀"))
rec := getHistory(h, "/history/tool/call-1?offset=1&limit=6", false)
var page ToolOutputPage
_ = json.Unmarshal(rec.Body.Bytes(), &page)
if page.Offset != 0 || page.Text != "😀" || !page.Truncated {
t.Fatalf("a mid-character offset starts at the character and a page ends on one: %+v", page)
}
}
func TestToolOutputRefusesWhatItCannotAnswerWithItsOwnCodes(t *testing.T) {
h := historyRoutes(toolMessages("short"))
for _, tc := range []struct {
path, code string
status int
}{
{"/history/tool/missing", "tool.output_unknown", http.StatusNotFound},
{"/history/tool/..%2F..%2Fetc%2Fpasswd", "tool.output_unknown", http.StatusNotFound},
{"/history/tool/call-1?offset=-1", "tool.output_range", http.StatusBadRequest},
{"/history/tool/call-1?offset=abc", "tool.output_range", http.StatusBadRequest},
{"/history/tool/call-1?limit=0", "tool.output_range", http.StatusBadRequest},
{"/history/tool/call-1?limit=x", "tool.output_range", http.StatusBadRequest},
{"/history/tool/call-1?offset=6", "tool.output_range", http.StatusBadRequest},
} {
rec := getHistory(h, tc.path, false)
var reason Reason
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
if rec.Code != tc.status || reason.Code != tc.code {
t.Errorf("%s: %d %q, want %d %s (%s)", tc.path, rec.Code, reason.Code, tc.status, tc.code, rec.Body.String())
}
}
}
func TestToolOutputClampsALargeLimit(t *testing.T) {
h := historyRoutes(toolMessages(strings.Repeat("a", 400*1024)))
rec := getHistory(h, "/history/tool/call-1?limit=999999999", false)
var page ToolOutputPage
_ = json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Text) != toolOutputMax || !page.Truncated {
t.Fatalf("page of %d bytes, truncated=%v, want %d and more to come", len(page.Text), page.Truncated, toolOutputMax)
}
if rec := getHistory(h, "/history/tool/call-1", false); func() int { var p ToolOutputPage; _ = json.Unmarshal(rec.Body.Bytes(), &p); return len(p.Text) }() != toolOutputDefault {
t.Fatal("the default page is not the default size")
}
}
// The new route holds nothing /history does not: a paired device reaches both or neither.
func TestToolOutputIsReachedExactlyAsHistoryIs(t *testing.T) {
h := historyRoutes(toolMessages("output"))
for _, device := range []bool{false, true} {
hist := getHistory(h, "/history", device)
tool := getHistory(h, "/history/tool/call-1", device)
if (hist.Code == http.StatusOK) != (tool.Code == http.StatusOK) {
t.Errorf("device=%v: /history answered %d and /history/tool %d", device, hist.Code, tool.Code)
}
}
if hostOnlyRoute(http.MethodGet, "/history/tool/call-1") != hostOnlyRoute(http.MethodGet, "/history") {
t.Fatal("the route's host-only status differs from /history's")
}
}
+2
View File
@@ -20,6 +20,8 @@ var hostOnlyRoutes = []hostRoute{
{http.MethodPost, "/feedback/board/leave"},
}
// GET /history/tool/{id} is not listed on purpose: it is a window onto what GET /history
// carries, so a device that may read one may read the other; a test holds them together.
func hostOnlyRoute(method, p string) bool {
if method == http.MethodHead {
method = http.MethodGet
+1 -1
View File
@@ -20,7 +20,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /provider-setup/connection", s.connectionSave)
mux.HandleFunc("GET /events", s.events)
mux.HandleFunc("GET /events/replay", s.eventsReplay)
mux.HandleFunc("GET /history", s.history)
s.registerHistoryRoutes(mux)
mux.HandleFunc("GET /context", s.context)
mux.HandleFunc("GET /adjudications", s.adjudications)
s.registerBrowserRoutes(mux)
@@ -0,0 +1,2 @@
- 改进:已完成的长输出在页面里只保留开头和结尾各 4 KB,并说明中间省略了多少,可点「显示完整输出」从内核取回;长会话占用的内存更少。关联 #13899。
- Improved: A finished call's long output is kept in the page as its first and last 4 KB with a note of what was left out, and "Show full output" fetches it from the kernel. Refs #13899.
+1
View File
@@ -100,6 +100,7 @@ var mirroredWireTypes = []wireMirror{
{"internal/frontend/serve/todos.go", "todoItem", tsSessionFile, "HostTodo"},
{"internal/frontend/serve/session_usage.go", "SessionUsage", tsSessionFile, "SessionUsage"},
{"internal/frontend/serve/jobs.go", "JobOutput", tsSessionFile, "JobOutput"},
{"internal/frontend/serve/history.go", "ToolOutputPage", tsSessionFile, "ToolOutputPage"},
// The background-job list. A name or full command the page cannot read is
// a task it can only call by its short preview.
{"internal/tools/jobs/jobs_view.go", "View", tsSessionFile, "JobEntry"},