diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 5c6e34c7c8..b46265641c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -289,34 +289,75 @@ export function ResearchReportSummary({analysis: a}: {analysis: Controller}) { onExpand={origin => {a.trigger.current = origin; a.show();}} onCitation={a.readCitation}/> : null; } +function CitedToolText({text, zh}: {text: string; zh: boolean}) { + // Decode only the saved direct shell-result envelope, never recursively + // reinterpret arbitrary JSON or infer successful work from its text. + try { + const value: unknown = JSON.parse(text); + if (value && typeof value === "object" && !Array.isArray(value) + && "chunk_id" in value && typeof value.chunk_id === "string" + && "wall_time_seconds" in value && typeof value.wall_time_seconds === "number" + && Number.isFinite(value.wall_time_seconds) && value.wall_time_seconds >= 0 + && "exit_code" in value && (value.exit_code === null || (typeof value.exit_code === "number" && Number.isSafeInteger(value.exit_code))) + && "output" in value && typeof value.output === "string") { + return
+
+ {value.exit_code === null ? (zh ? "尚无退出码" : "No exit code yet") : `${zh ? "退出码" : "Exit code"} ${value.exit_code}`} + {value.wall_time_seconds} s +
+ +
; + } + } catch { /* Plain text and unknown envelopes stay literal. */ } + return ; +} + +function CitedOutput({text, zh}: {text: string; zh: boolean}) { + return text ?
{text}
+ :

{zh ? "输出为空" : "Empty output"}

; +} + function CitationContent({citation, zh}: {citation: Citation; zh: boolean}) { - // A reading projection of the native completed-command envelope, not a - // source verifier. Unknown or mixed formats retain the complete literal. - const commands: {output: string; argv: string[]; exitCode: number; status: string}[] = []; + // Display projections of explicitly typed records only. Unknown, mixed or + // malformed records retain the complete citation, including neighboring lines. + const records: ({output: string; argv: string[]; exitCode: number; status: string} | {texts: string[]})[] = []; try { for (const line of citation.text.split("\n")) { - const event: {type?: unknown; payload?: {type?: unknown; item?: Record}} | null = JSON.parse(line); + const event: {type?: unknown; payload?: {type?: unknown; output?: unknown; item?: Record}} | null = JSON.parse(line); const item = event?.payload?.item; - if (event?.type !== "event_msg" || event.payload?.type !== "item_completed" - || item?.type !== "CommandExecution" || typeof item.aggregated_output !== "string" - || !Array.isArray(item.command) || !item.command.every((arg: unknown) => typeof arg === "string") - || typeof item.exit_code !== "number" || !Number.isSafeInteger(item.exit_code) || typeof item.status !== "string") { + if (event?.type === "event_msg" && event.payload?.type === "item_completed" + && item?.type === "CommandExecution" && typeof item.aggregated_output === "string" + && Array.isArray(item.command) && item.command.every((arg: unknown) => typeof arg === "string") + && typeof item.exit_code === "number" && Number.isSafeInteger(item.exit_code) && typeof item.status === "string") { + records.push({output: item.aggregated_output, argv: item.command, exitCode: item.exit_code, status: item.status}); + } else if (event?.type === "response_item" + && (event.payload?.type === "custom_tool_call_output" || event.payload?.type === "function_call_output")) { + const output = event.payload.output; + if (typeof output === "string") records.push({texts: [output]}); + else if (Array.isArray(output) && output.length > 0 && output.every(part => part && typeof part === "object" + && !Array.isArray(part) && part.type === "input_text" && typeof part.text === "string")) { + records.push({texts: output.map(part => part.text as string)}); + } else return
{citation.text}
; + } else { return
{citation.text}
; } - commands.push({output: item.aggregated_output, argv: item.command, exitCode: item.exit_code, status: item.status}); } } catch { return
{citation.text}
; } return
- {commands.map((command, index) =>
-
- {commands.length > 1 ? {zh ? "行" : "Line"} {citation.first_line + index} : null} - {zh ? "退出码" : "Exit code"} {command.exitCode}{command.status} -
- {command.output ?
{command.output}
- :

{zh ? "输出为空" : "Empty output"}

} -
{zh ? "命令参数" : "Command arguments"}
{JSON.stringify(command.argv, null, 2)}
+ {records.map((record, index) =>
+ {"texts" in record ? <> + {records.length > 1 ?
{zh ? "行" : "Line"} {citation.first_line + index}
: null} + {record.texts.map((text, part) => )} + : <> +
+ {records.length > 1 ? {zh ? "行" : "Line"} {citation.first_line + index} : null} + {zh ? "退出码" : "Exit code"} {record.exitCode}{record.status} +
+ +
{zh ? "命令参数" : "Command arguments"}
{JSON.stringify(record.argv, null, 2)}
+ }
)}
{zh ? "完整原始记录" : "Complete raw record"}
{citation.text}
; diff --git a/docs/reference/trajectory-assessment.md b/docs/reference/trajectory-assessment.md index 01616f0555..d99cc9ac6f 100644 --- a/docs/reference/trajectory-assessment.md +++ b/docs/reference/trajectory-assessment.md @@ -27,6 +27,14 @@ the history action returns to the complete loaded research history without changing the selected evidence pack. Reading either layer, switching report tabs or opening an original never executes an analysis or experiment. +The source reader displays saved completed-command output and text-only +`function_call_output` / `custom_tool_call_output` records in source order. +Direct shell-result envelopes show their literal output and observed exit code; +a missing exit code remains unknown. Command arguments and the complete original +record stay available on demand. Mixed, malformed or unsupported content falls +back to the complete literal citation. Unknown nested transport stays text; +reading never recursively interprets it or turns an exit code into acceptance. + The presentation draws on composable inspection in [Distill's interpretability article](https://distill.pub/2018/building-blocks/) and responsive comparison layouts in the diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-after-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-after-desktop.png new file mode 100644 index 0000000000..8beed2ccca Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-after-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-after-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-after-phone.png new file mode 100644 index 0000000000..1d7f6ff5ed Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-after-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-before-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-before-desktop.png new file mode 100644 index 0000000000..ec9e7352cf Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-before-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-before-phone.png new file mode 100644 index 0000000000..8ba0c0a77e Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/tool-output-before-phone.png differ diff --git a/examples/personal-workspace-browser/research-native-reading.mjs b/examples/personal-workspace-browser/research-native-reading.mjs index db8d99fd30..cbac956f50 100644 --- a/examples/personal-workspace-browser/research-native-reading.mjs +++ b/examples/personal-workspace-browser/research-native-reading.mjs @@ -25,7 +25,8 @@ export const researchNativeReadingScenario = { expected: [0.0000001234567890123456, 0.0000001234567890123456, 0.0000001123456789012345, 0.0000001123456789012345], display: ["1.235E-7", "1.235E-7", "1.123E-7", "1.123E-7"], gain: "-1.111E-8"}, {id: "large", values: [123456789.12345679], higher: true, retained: 0, expected: [123456789.12345679, 123456789.12345679, 123456789.12345679, 123456789.12345679], display: ["1.235E8", "1.235E8", "1.235E8", "1.235E8"], gain: "0"}, - ...["command", "mixed", "damaged", "empty-output", "unsafe-exit"].map(source => ({id: `${source}-source`, source, + ...["command", "mixed", "damaged", "empty-output", "unsafe-exit", "tool-text", "tool-shell", "tool-pending", + "tool-function", "tool-mixed", "tool-damaged", "tool-unsafe-exit", "tool-empty"].map(source => ({id: `${source}-source`, source, values: [2, 1], higher: false, retained: 0, expected: [1, 2, 1, 2]})), ]; const root = await mkdtemp(resolve(tmpdir(), "loopx-native-reading-")); @@ -37,6 +38,9 @@ export const researchNativeReadingScenario = { fixture.analyses = cases.map(c => { const a = structuredClone(original), native = JSON.parse(a.artifacts["native.json"]); a.title = c.id; + // These supplied outputs use the legacy assessment format, independent + // of the current registration default for newly generated reports. + a.config = {model: "fixture", effort: "max", service_tier: "fast", research_synthesis: false}; a.input.scope.run_id = c.id; native.scope = {...a.input.scope}; native.complete = !c.partial; @@ -62,6 +66,20 @@ export const researchNativeReadingScenario = { ? JSON.stringify({type: "unknown_event", text: "Unrecognized source MUST_REMAIN_LITERAL"}) : c.source === "damaged" ? "{malformed MUST_REMAIN_LITERAL" : command(c.source === "empty-output" ? "" : longFailure, c.source === "unsafe-exit" ? Number.MAX_SAFE_INTEGER + 1 : 17)].join("\n"); + if (c.source.startsWith("tool-")) { + const shell = (text, exit_code) => JSON.stringify({chunk_id: "synthetic", wall_time_seconds: 0.25, exit_code, output: text}); + const tool = output => JSON.stringify({type: "response_item", payload: { + type: c.source === "tool-function" ? "function_call_output" : "custom_tool_call_output", output}}); + const part = text => ({type: "input_text", text}); + const first = ["tool-text", "tool-function"].includes(c.source) ? output : c.source === "tool-empty" ? "" + : [part("Recorded tool response\n"), part(c.source === "tool-unsafe-exit" + ? '{"chunk_id":"synthetic","wall_time_seconds":0.25,"exit_code":9007199254740993,"output":"MUST_REMAIN_LITERAL"}' + : shell(output, c.source === "tool-pending" ? null : 0))]; + const second = c.source === "tool-mixed" ? [part(longFailure), {type: "input_image", image_url: "synthetic"}] + : c.source === "tool-damaged" ? [part(longFailure), {type: "input_text", text: 12}] + : c.source === "tool-empty" ? "" : [part(shell(longFailure, 17))]; + text = [tool(first), tool(second)].join("\n"); + } native.samples[0].citation.last_line = 2; } a.artifacts["facts.txt"] = text; @@ -73,7 +91,7 @@ export const researchNativeReadingScenario = { await writeFile(path, JSON.stringify(fixture)); // Same public synthetic data for reproducible before/after inspection. await writeFile(resolve(outputDir, "research-citation-reader-fixture.json"), JSON.stringify({...fixture, - analyses: fixture.analyses.filter(a => a.title === "command-source")})); + analyses: fixture.analyses.filter(a => ["command-source", "tool-shell-source"].includes(a.title))})); owner = await startHistoryOwner(path); page = await browser.newPage({viewport: {width: 1280, height: 900}, hasTouch: true}); const errors = []; @@ -139,7 +157,7 @@ export const researchNativeReadingScenario = { const citation = page.getByRole("region", {name: "引用原文", exact: true}); await citation.waitFor(); const literal = fixture.analyses.find(a => a.title === c.id).artifacts["facts.txt"]; - if (["mixed", "damaged", "unsafe-exit"].includes(c.source)) { + if (["mixed", "damaged", "unsafe-exit", "tool-mixed", "tool-damaged"].includes(c.source)) { await citation.locator("pre").waitFor(); assert.equal(await citation.locator("pre").textContent(), literal, "Never discard unsupported or malformed neighboring evidence"); assert.equal(await citation.locator("details").count(), 0); @@ -147,17 +165,28 @@ export const researchNativeReadingScenario = { await citation.locator(".analysis-source-content").waitFor(); assert.equal(await citation.locator(".analysis-source-command").count(), 2); await citation.getByText("行 2", {exact: true}).waitFor(); - await citation.getByText("退出码 17", {exact: true}).waitFor(); + if (c.source !== "tool-empty") await citation.getByText("退出码 17", {exact: true}).waitFor(); + if (c.source === "tool-pending") await citation.getByText("尚无退出码", {exact: true}).waitFor(); const raw = citation.locator(".analysis-source-raw"); assert.equal(await raw.getAttribute("open"), null); await raw.locator("summary").tap(); assert.equal(await raw.locator("pre").textContent(), literal, "Raw evidence remains byte-for-byte available"); await raw.locator("summary").tap(); - if (c.source === "empty-output") assert.equal(await citation.getByText("输出为空", {exact: true}).count(), 2); + if (["empty-output", "tool-empty"].includes(c.source)) assert.equal(await citation.getByText("输出为空", {exact: true}).count(), 2); else { - const records = literal.split("\n").map(line => JSON.parse(line).payload.item); - for (const [i, record] of records.entries()) { - assert.equal(await citation.locator(".analysis-source-output").nth(i).textContent(), record.aggregated_output); + const records = literal.split("\n").map(line => JSON.parse(line).payload); + const outputs = await citation.locator(".analysis-source-output").allTextContents(); + if (c.source.startsWith("tool-")) { + const main = JSON.stringify({cases: 8, improved: 3, regressed: 2, unchanged: 3, complete_cost: null}, null, 2); + assert(outputs.includes(c.source === "tool-unsafe-exit" ? records[0].output[1].text : main)); + if (c.source === "tool-unsafe-exit") { + assert.equal(await citation.getByText("退出码 9007199254740992", {exact: true}).count(), 0, "Unsafe numeric metadata must not be rounded into a display fact"); + assert(outputs.some(text => text.includes("9007199254740993"))); + } + if (["tool-shell", "tool-pending", "tool-unsafe-exit"].includes(c.source)) assert.equal(outputs[0], "Recorded tool response\n", "Preserve text blocks and order, including surrounding caveats"); + assert.equal(outputs.at(-1), JSON.parse(records[1].output[0].text).output); + } else { + for (const [i, record] of records.entries()) assert.equal(outputs[i], record.item.aggregated_output); } assert.equal(await citation.locator("script").count(), 0); assert.equal(await page.evaluate(() => window.sourceExecuted), undefined); @@ -191,12 +220,13 @@ export const researchNativeReadingScenario = { await page.getByRole("button", {name: "About native measurements", exact: true}).focus(); await page.getByRole("tooltip").waitFor({state: "visible"}); await page.keyboard.press("Escape"); - await selector.selectOption({label: "command-source"}); + await selector.selectOption({label: "tool-pending-source"}); const selected = await selector.inputValue(); await page.locator(`.research-progress-scope[data-pack-id="${selected}"]`).waitFor(); await page.getByRole("region", {name: "Native measurements", exact: true}).getByRole("button", {name: /Native retained result.*Read original evidence/}).click(); const originalPanel = page.getByRole("region", {name: "Cited original", exact: true}); await originalPanel.getByText("Exit code 17", {exact: true}).waitFor(); + await originalPanel.getByText("No exit code yet", {exact: true}).waitFor(); await originalPanel.getByText("Complete raw record", {exact: true}).waitFor(); await originalPanel.press("Escape"); const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json();