diff --git a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py index e5959dc117..8bbd6259c4 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py +++ b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py @@ -151,11 +151,16 @@ def start(self, server): server.chat_store.sessions_root / sid / "turns" / f"{turn['turn_id']}.json"]) reply_evidence = {path: path.read_bytes() for path in paths} bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"]) + returned_files = {} + if "--returned-files" in sys.argv: + returned_files, reply_evidence = self.seed_returned_files(server, original) + bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"]) other, _ = runtime.open_session(goal_id=None, agent_id="codex", objective="", mode="new", work_dir=self.root / "project", project_ref=binding["project_ref"]) server.chat_store.append_message(other["session_id"], role="agent", text="Unrelated desktop conversation — must remain separate.") print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}", - "bindingId": binding["binding_id"], "sessionId": sid, "ordinarySessionId": other["session_id"]}), flush=True) + "bindingId": binding["binding_id"], "sessionId": sid, "ordinarySessionId": other["session_id"], + **returned_files}), flush=True) settings_handler = server.RequestHandlerClass._private_conversations history_handler = server.RequestHandlerClass._session_snapshot sessions_handler = server.RequestHandlerClass._list_sessions @@ -253,6 +258,8 @@ def unavailable(handler): result.update(model_turns=sum(json.loads(line).get("method") == "turn/start" for line in (self.root / "synthetic-requests.jsonl").read_text().splitlines()), reply_evidence_unchanged=all(path.read_bytes() == content for path, content in reply_evidence.items())) + if returned_files: + result["provider_sends"] = self.file_sender.calls elif command == "stop": server.shutdown() return @@ -261,6 +268,52 @@ def unavailable(handler): print(json.dumps(result), flush=True) threading.Thread(target=control, daemon=True).start() + def seed_returned_files(self, server, session): + """Publish once through the real result owner, then test read-only history.""" + from hashlib import sha256 + from loopx.capabilities.manager_context import _root, _write, POLICY_SCHEMA, register_ingress, deliver, acknowledge + from loopx.capabilities.manager_context.roundtrip import report, drain + + root = server.runtime_controller.coordination_runtime_root + store, registry = server.chat_store, server.registry_path + sid = session["session_id"] + turn, _ = store.create_turn(sid, client_turn_id="synthetic-files", origin="lark", + message="请核对结果并返回原文件。Please return the original result files.") + target = {"goal_id": "goal-a", "agent_id": "agent-a"} + _write(_root(root) / "policy.json", {"schema_version": POLICY_SCHEMA, "sources": { + session["channel_id"]: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [target]}}}) + register_ingress(root, session_id=sid, client_turn_id=turn["client_turn_id"], channel=session["channel_id"], + sender_id="owner", message=turn["message"], source_id="lark:om_fixture_files") + receipt = deliver(root, registry, session=session, turn=turn, request=target, source_store=store) + store.update_turn(sid, turn["turn_id"], status="completing", response={ + "message": "The receiver will return the original files.", "context_handoff_receipt": receipt}) + store.finalize_managed_turn_completion(sid, turn["turn_id"]) + acknowledge(root, "goal-a", "agent-a", receipt["request_id"], "adopt", "Verify the original files") + files = {"结果.json": b'\xef\xbb\xbf{"result":"verified"}\r\n', "source.bin": b"\x00\xffbinary source\x00"} + for name, raw in files.items(): + (self.root / "project" / name).write_bytes(raw) + report(root, "goal-a", "agent-a", receipt["request_id"], "conclusion", + "### 结果已核验 / Verified result\n\n原始结果和文件均已保存。The original results and files are saved.\n\n" + "已核对结论、来源和失败记录。The result, sources and failed attempts were checked.", + registry=registry, attachment_refs=list(files)) + + class Sender: + supports_result_files = True + calls = 0 + + def __call__(self, *_): + self.calls += 1 + return {"ok": True, "reply_verified": True} + + self.file_sender = Sender() + assert drain(root, registry, store, self.file_sender) == 1 + message = next(row for row in store.messages(sid) if row.get("origin") == "manager_followup") + paths = list(_root(root).rglob("*.json")) + [store.sessions_root / sid / "messages.jsonl", + store.sessions_root / sid / "turns" / f"{turn['turn_id']}.json"] + return {"fileMessageId": message["message_id"], "files": [ + {"name": name, "sha256": sha256(raw).hexdigest(), "size": len(raw)} for name, raw in files.items()]}, { + path: path.read_bytes() for path in paths} + reply_background = ("### 话题背景 / Topic background\n\n" + "完整上下文保持可读。Keep the original context readable. " * 35) @@ -315,9 +368,10 @@ def main(): ' output.write(json.dumps(request) + "\\n")\n method = request.get("method")') fake.write_text(model) fake.chmod(0o700) - steward = any(flag in sys.argv for flag in ("--steward", "--reply-context", "--steward-fallback")) + steward = any(flag in sys.argv for flag in ("--steward", "--reply-context", "--steward-fallback", "--returned-files")) source = SyntheticPrivateSource(root, steward=steward) - peer = SyntheticPrivateSource(root, steward=True, peer=True) if steward and "--reply-context" not in sys.argv else None + peer = SyntheticPrivateSource(root, steward=True, peer=True) if steward and not any( + flag in sys.argv for flag in ("--reply-context", "--returned-files")) else None source.peer_provider = peer with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) diff --git a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs index 55171d455a..a47c2ca3b9 100644 --- a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs @@ -3,16 +3,18 @@ import assert from "node:assert/strict"; import {spawn} from "node:child_process"; import {once} from "node:events"; import {createInterface} from "node:readline"; -import {mkdir} from "node:fs/promises"; +import {mkdir, readFile} from "node:fs/promises"; +import {createHash} from "node:crypto"; import {resolve} from "node:path"; import {resolveTestPython} from "../../../../scripts/test-python.mjs"; import {launchBrowser, loadPlaywright} from "../../../../examples/dashboard-browser-smoke-support.mjs"; const repo = resolve(import.meta.dirname, "../../../.."); +const returnedFiles = process.argv.includes("--returned-files"); const output = resolve(process.env.LOOPX_REPLY_OUTPUT ?? resolve(repo, "output/playwright/conversation-reply")); await mkdir(output, {recursive: true}); const child = spawn(resolveTestPython({repoRoot: repo}), ["-u", - "apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py", "--reply-context"], + "apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py", returnedFiles ? "--returned-files" : "--reply-context"], {cwd: repo, env: {...process.env, LOOPX_USAGE_PING: "0"}, stdio: ["pipe", "pipe", "pipe"]}); const exited = once(child, "exit"), queue = [], waiting = []; let stderr = "", browser; @@ -27,6 +29,76 @@ function next() { if (queue.length) receive(queue.shift()); else waiting.push(receive); }); } +async function checkReturnedFiles(ready, page, errors, writes) { + const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json(); + const answer = snapshot.messages.find(row => row.message_id === ready.fileMessageId); + assert.ok(answer.text.includes("Verified result")); + await page.goto(ready.origin + "/chat/"); + await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click(); + const timeline = page.locator(".personal-channel-timeline"); + await timeline.getByText("结果已核验 / Verified result", {exact: true}).waitFor(); + await page.waitForFunction(() => document.body.innerText.includes("已连接")); + const baseline = process.env.LOOPX_REPLY_BEFORE === "1"; + if (baseline) { + assert.equal(answer.attachments?.length ?? 0, 0); + assert.equal(await timeline.locator("a[download]").count(), 0); + } else { + assert.equal(answer.attachments.length, ready.files.length); + assert.equal(await timeline.locator("a[download]").count(), ready.files.length); + assert.equal(await timeline.locator("img[alt='source.bin']").count(), 0); + for (const file of ready.files) { + const link = timeline.getByRole("link", {name: `下载文件:${file.name}`, exact: false}); + await link.focus(); + const completed = page.waitForEvent("download"); + await page.keyboard.press("Enter"); + const download = await completed; + assert.equal(download.suggestedFilename(), file.name); + const raw = await readFile(await download.path()); + assert.equal(raw.length, file.size); + assert.equal(createHash("sha256").update(raw).digest("hex"), file.sha256); + } + } + await page.screenshot({path: resolve(output, `${baseline ? "before" : "after"}-files-desktop.png`), animations: "disabled"}); + const opened = page.context().waitForEvent("page"); + await timeline.getByRole("link", {name: "单独阅读完整答复"}).click(); + const report = await opened; + report.on("pageerror", error => errors.push(error.message)); + await report.getByRole("heading", {name: "结果已核验 / Verified result", exact: true}).waitFor(); + assert.equal(await report.locator("a[download]").count(), baseline ? 0 : ready.files.length); + await report.screenshot({path: resolve(output, `${baseline ? "before" : "after"}-files-reader.png`), animations: "disabled"}); + if (!baseline) { + for (const file of ready.files) { + const completed = report.waitForEvent("download"); + await report.getByRole("link", {name: `下载文件:${file.name}`, exact: false}).click(); + const download = await completed; + assert.equal(download.suggestedFilename(), file.name); + assert.equal(createHash("sha256").update(await readFile(await download.path())).digest("hex"), file.sha256); + } + await report.setViewportSize({width: 390, height: 844}); + assert.ok(await report.locator("body").evaluate(node => node.scrollWidth <= innerWidth + 1)); + await report.screenshot({path: resolve(output, "after-files-reader-narrow.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await timeline.locator("a[download]").first().scrollIntoViewIfNeeded(); + assert.ok(await page.locator("body").evaluate(node => node.scrollWidth <= innerWidth + 1)); + await page.screenshot({path: resolve(output, "after-files-narrow.png"), animations: "disabled"}); + await report.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await report.reload(); + await report.getByRole("link", {name: "Download file: source.bin", exact: false}).waitFor(); + await report.screenshot({path: resolve(output, "after-files-reader-english.png"), animations: "disabled"}); + const url = ready.origin + answer.attachments[0].data_url; + assert.equal((await page.request.get(url.replace(ready.sessionId, "unrelated-session"))).status(), 404); + assert.equal((await page.request.get(url, {headers: {Origin: "https://unrelated.example"}})).status(), 403); + } + child.stdin.write("inspect\n"); + const inspection = await next(); + assert.equal(inspection.model_turns, 0); + assert.equal(inspection.provider_sends, 1); + assert.equal(inspection.reply_evidence_unchanged, true); + assert.deepEqual(errors, []); + assert.ok(!writes.some(path => /\/turns(?:\/|$)/u.test(path)), "Reading cannot submit a Turn"); + console.log(baseline ? "returned-files-browser: baseline reproduced (published files omitted from history and full reader; zero model Turns or repeat sends)" + : "returned-files-browser: passed (real publisher, bound source, original history/reader, byte-exact downloads, desktop/narrow, keyboard, English, zero model Turns or repeat sends)"); +} try { const ready = await next(); browser = await launchBrowser(loadPlaywright().chromium); @@ -35,6 +107,9 @@ try { const errors = [], writes = []; page.on("pageerror", error => errors.push(error.message)); page.on("request", request => {if (request.method() !== "GET") writes.push(new URL(request.url()).pathname);}); + if (returnedFiles) { + await checkReturnedFiles(ready, page, errors, writes); + } else { const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json(); const source = snapshot.messages.find(row => row.role === "user" && row.turn_id); assert.ok(source.text.startsWith("Referenced message ("), "Canonical model input stays unchanged"); @@ -96,6 +171,7 @@ try { assert.equal(inspection.reply_evidence_unchanged, true); console.log("conversation-reply-browser: passed (real bound source, original body, complete quote, bounded thread, desktop/narrow, keyboard, English and zero model Turns)"); } + } } finally { await browser?.close(); child.stdin.end("stop\n"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx index e0ef26a4e9..5f38dc2034 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx @@ -6,6 +6,7 @@ import {readWorkspaceLocale} from "./i18n"; import {resolveConversationScope} from "../../../../../../loopx/control_plane/collaboration/conversation_scope"; import {ReturnDeliveryStatus} from "./return-delivery-status"; import {MarkdownText} from "./markdown"; +import {DocumentAttachment, isDocumentAttachment} from "./document-attachment"; import {markdownOutline, parseMarkdownBlocks} from "./markdown-blocks"; import {readWorkspaceTheme} from "./workspace-theme"; import "./personal-workspace.css"; @@ -125,6 +126,9 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: { window.requestAnimationFrame(() => document.getElementById(heading.id)?.focus({preventScroll: true}))}>{heading.text} )} : null}
+ {answer.attachments?.map(file => isDocumentAttachment(file.mime_type) + ? + : {file.name}/)}