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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -125,6 +126,9 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: {
<a href={`#${encodeURIComponent(heading.id)}`} onClick={() => window.requestAnimationFrame(() => document.getElementById(heading.id)?.focus({preventScroll: true}))}>{heading.text}</a>
</li>)}</ol></nav></details> : null}
<div className="answer-report-content"><MarkdownText text={answerText} headingPrefix={headingPrefix}/></div>
{answer.attachments?.map(file => isDocumentAttachment(file.mime_type)
? <DocumentAttachment key={file.id} dataUrl={file.data_url} name={file.name} size={file.size} zh={zh}/>
: <img key={file.id} src={file.data_url} alt={file.name}/>)}
<footer>
<details><summary>{zh ? "来源与版本" : "Source and version"}</summary><p>{zh ? "来自本机保存的对话消息;打开此页不会重新运行 Agent。" : "From a locally saved conversation message. Opening this page does not rerun the Agent."}</p>
<dl><dt>{zh ? "消息" : "Message"}</dt><dd><code>{answer.message_id}</code></dd>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {DocumentAttachment, isTextDocument} from "./document-attachment";
import {DocumentAttachment, isDocumentAttachment} from "./document-attachment";
import { MessageActivity } from "./message-activity";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
Expand Down Expand Up @@ -210,7 +210,7 @@ export function ChannelTimeline({
<header><strong>{item.message.role === "user" ? item.message.agentLabel ?? t("common.you") : item.message.agentLabel ?? t("header.manager")}</strong>{item.message.time ? <time>{item.message.time}</time> : null}</header>
<ReturnDeliveryStatus delivery={item.message.returnDelivery} />
{item.message.queueInputCancelled ? <span>{locale === "zh-CN" ? "已取消排队" : "Cancelled before starting"}</span> : null}
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => isTextDocument(attachment.mimeType) ? <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => isDocumentAttachment(attachment.mimeType) ? <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <HistoricalMessageText text={item.message.text}
initiallyCollapsed={Boolean(historical?.has(messageKey(item.message)))}
focused={Boolean(messageFocus && messageKey(item.message) === focusKey)} zh={locale === "zh-CN"} onRead={onReadMessage}/> : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import {useState} from "react";

export const isTextDocument = (mime: string) => mime === "text/plain" || mime === "text/markdown";
export const isDocumentAttachment = (mime: string) => isTextDocument(mime) || mime === "application/octet-stream";

/** Render source as inert text; downloading preserves the original UTF-8 bytes. */
export function DocumentAttachment({dataUrl, name, size, zh}: {dataUrl: string; name: string; size: number; zh: boolean}) {
const [open, setOpen] = useState(false);
// Only the original-message API can supply returned file bytes. This does
// not add a MIME type or remote URL to the input attachment allowlist.
if (/^\/api\/chat\/sessions\/[A-Za-z0-9._-]{1,160}\/messages\/handoff\.[a-f0-9]{64}\/files\/[a-f0-9]{64}\.[0-3]$/.test(dataUrl)) {
return <div className="personal-document-attachment"><a href={dataUrl} download={name}>
{zh ? "下载文件:" : "Download file: "}{name} · {size.toLocaleString()} B
</a></div>;
}
let text: string;
try {
if (!/^data:text\/(plain|markdown);base64,[A-Za-z0-9+/=]*$/.test(dataUrl)) throw new Error("Invalid source");
Expand Down
Loading
Loading