From d87a694ebd4527562fcb5d0a1e649873a54a1ae3 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:17:32 +0800 Subject: [PATCH 1/2] fix(chat): distinguish recipient disposition from reply delivery Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../dashboard/src/data/chat-model.ts | 2 + .../personal-workspace/collaboration-card.tsx | 12 +++-- .../chat-recovery.mjs | 18 +++++++ .../manager_context/presentation.py | 5 +- .../capabilities/manager_context/roundtrip.py | 4 +- tests/test_manager_context_roundtrip.py | 54 ++++++++++++++++++- 6 files changed, 87 insertions(+), 8 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index 08bc199339..f11f0d161d 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -83,6 +83,8 @@ export type CollaborationReadback = { schema_version: "collaboration_request_readback_v0"; request_id: string; agent_id: string; + goal_id?: string; + decision_reason?: string; brief: { purpose: string; context: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx index 73771d2d9e..c40283d31d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx @@ -8,14 +8,16 @@ const copy = { acceptance: "验收要求", return: "需要回传", supplied: "已提供给接收方", pending: "等待接收方读取", decision: "接收方判断", unknown: "尚未记录", unavailable: "暂时无法读取", adopt: "已采纳", defer: "已暂缓", reject: "未采纳", no_change: "无需调整", - result: "结论已保存", delivered: "结论已回传", details: "查看交办内容", + result: "回复等待送达", delivered: "回复已送达", unverified: "回复送达尚未核验", details: "查看交办内容", + owner: "接收方", reason: "原因", }, en: { title: "Delegation brief", context: "Context & corrections", constraints: "Constraints", inputs: "Inputs", acceptance: "Acceptance", return: "Expected return", supplied: "Supplied to receiver", pending: "Awaiting receiver read", decision: "Receiver decision", unknown: "Not recorded", unavailable: "Readback unavailable", adopt: "Adopted", defer: "Deferred", reject: "Rejected", no_change: "No change needed", - result: "Conclusion saved", delivered: "Conclusion returned", details: "View delegation details", + result: "Reply awaiting delivery", delivered: "Reply delivered", unverified: "Reply delivery unverified", details: "View delegation details", + owner: "Recipient", reason: "Reason", }, }; @@ -27,12 +29,14 @@ export function CollaborationCard({ request }: { request?: CollaborationReadback const decision = request.decision as "adopt" | "defer" | "reject" | "no_change"; const conclusion = request.returns.find((reply) => reply.phase === "conclusion"); return
-
{brief.purpose}{request.agent_id}
+
{brief.purpose}{c.owner}: {request.goal_id ? `${request.goal_id} / ` : ""}{request.agent_id}

{request.read_status === "supplied" ? c.supplied : request.read_status === "unavailable" ? c.unavailable : c.pending} {c.decision}: {c[decision] ?? (request.decision === "unavailable" ? c.unavailable : c.unknown)} - {conclusion ? {conclusion.status === "delivered" ? c.delivered : c.result} : null} + {conclusion ? {conclusion.status === "delivered" ? c.delivered + : conclusion.status === "queued" ? c.result : c.unverified} : null}

+ {request.decision_reason ?

{c.reason}: {request.decision_reason}

: null}
{c.details}

{c.context}

{brief.context}

diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 6f1087b0a1..12cbc7ba0f 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -116,6 +116,24 @@ export const chatRecoveryScenario = { await page.setViewportSize({ width: 1512, height: 982 }); await collaboration.evaluate((node) => node.scrollIntoView({ block: "start" })); await page.screenshot({ path: resolve(outputDir, "collaboration-brief-desktop.png"), fullPage: false, animations: "disabled" }); + delegatedMessage.collaboration.goal_id = "community"; + delegatedMessage.collaboration.decision = "defer"; + delegatedMessage.collaboration.decision_reason = "先完成正在进行的交付;问卷尚未制作。"; + delegatedMessage.collaboration.returns = [{ phase: "conclusion", status: "delivered" }]; + await collaboration.getByText("接收方判断: 已暂缓", { exact: true }).waitFor({ state: "visible", timeout: 10000 }); + await collaboration.getByText("原因: 先完成正在进行的交付;问卷尚未制作。", { exact: true }).waitFor({ state: "visible" }); + await collaboration.getByText("回复已送达", { exact: true }).waitFor({ state: "visible" }); + await collaboration.getByText("接收方: community / worker", { exact: true }).waitFor({ state: "visible" }); + if ((await collaboration.innerText()).includes("结论已回传")) throw new Error("Deferred reply was presented as a completed conclusion"); + await collaboration.evaluate((node) => node.scrollIntoView({ block: "start" })); + await page.screenshot({ path: resolve(outputDir, "collaboration-deferred-desktop.png"), fullPage: false, animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + if (await collaboration.evaluate((node) => node.scrollWidth > node.clientWidth + 1)) throw new Error("Deferred explanation overflows on mobile"); + await page.screenshot({ path: resolve(outputDir, "collaboration-deferred-mobile.png"), fullPage: false, animations: "disabled" }); + await page.setViewportSize({ width: 1512, height: 982 }); + delegatedMessage.collaboration.returns = [{ phase: "conclusion", status: "explicit_unverified" }]; + await collaboration.getByText("回复送达尚未核验", { exact: true }).waitFor({ state: "visible", timeout: 10000 }); + if (api.turnRequests.length !== turnsBeforeReturn) throw new Error("Disposition readback started another model turn"); pass("collaboration-brief", "Original conversation preserves context, constraints, inputs and receiver decision without a new turn"); const returnText = "处理结论:已核验新约束并关联现有计划,无需再次追问。"; diff --git a/loopx/capabilities/manager_context/presentation.py b/loopx/capabilities/manager_context/presentation.py index b1d8d5b7cd..d6dbaa2255 100644 --- a/loopx/capabilities/manager_context/presentation.py +++ b/loopx/capabilities/manager_context/presentation.py @@ -4,11 +4,12 @@ from .tracking import _entry, _receipt from .roundtrip import reply_status from ...chat import redact_local_paths +from ...control_plane.collaboration import conversation_scope def project_collaboration(store, root, session_id, messages): session = store.load_session(session_id) - if not session or session.get("channel_id") != "manager": + if not session or not conversation_scope(session)["private_conversation"]: return messages result = [] for message in messages: @@ -57,6 +58,7 @@ def safe(value): "schema_version": "collaboration_request_readback_v0", "request_id": row["request_id"], "agent_id": row["agent_id"], + "goal_id": row["goal_id"], "brief": safe(row["brief"]), "read_status": "unavailable" if read_error @@ -66,6 +68,7 @@ def safe(value): "decision": "unavailable" if error else decision.get("decision", "pending"), + "decision_reason": safe(decision.get("reason", "")) if not error else "", "returns": reply_status(root, row), }, } diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py index fdbb1be71d..7a4e779946 100644 --- a/loopx/capabilities/manager_context/roundtrip.py +++ b/loopx/capabilities/manager_context/roundtrip.py @@ -354,7 +354,9 @@ def drain(root, registry, store, external_sender, *, now=None, cancelled=lambda: {"status": "superseded", "reason": "conclusion_ready"}, ) continue - prefix = "处理结论" if path.stem == "conclusion" else "处理进展" + # A conclusion can be a deferral or rejection. Transport completion + # is not completion of the delegated work. + prefix = "协作回复" if path.stem == "conclusion" else "协作进展" text = f"{prefix} · {row['agent_id']} · 委托 {row['request_id'][:8]}\n\n{reply['text']}" # Transcript writes are independently idempotent, including when # Lark is offline. Keep the original Turn and logical conversation. diff --git a/tests/test_manager_context_roundtrip.py b/tests/test_manager_context_roundtrip.py index 4f1f5de1a1..6abda05053 100644 --- a/tests/test_manager_context_roundtrip.py +++ b/tests/test_manager_context_roundtrip.py @@ -47,7 +47,7 @@ def flow(tmp_path): ) store = ChatSessionStore(tmp_path) - def create(external=False, project=False): + def create(external=False, project=False, brief=None): session = store.create_session( goal_id="research" if project else "loopx-manager", agent_id="codex", @@ -85,7 +85,8 @@ def create(external=False, project=False): source_id="lark:om_fixture_source", ) receipt = deliver( - tmp_path, registry, session=session, turn=turn, request=target + tmp_path, registry, session=session, turn=turn, + request={**target, **({"brief": brief} if brief else {})} ) store.update_turn( session["session_id"], @@ -865,3 +866,52 @@ def verify(self, route, session, turn, text, attempt): assert again["status"] == "explicit_unverified" assert transport.send_calls == 1 assert transport.verify_calls == 0 + + +@pytest.mark.parametrize("project", [False, True], ids=["steward", "goal-chat"]) +@pytest.mark.parametrize("decision", ["defer", "reject", "adopt", "no_change"]) +def test_reply_delivery_does_not_replace_receiver_disposition(flow, project, decision): + root, registry, store, create = flow + brief = dict( + schema_version="collaboration_brief_v0", purpose="Draft a community survey", + context="Prepare a reviewable draft before publication.", + constraints=["Do not publish"], inputs=[], + acceptance=["A readable survey with concrete questions"], + return_requirement="Return the draft or explain why it is deferred", + ) + session, turn, receipt = create(project=project, brief=brief) + rid = receipt["request_id"] + reason = "Recipient assessment; private evidence at /Users/example/private/notes.md" + acknowledge(root, "research", "worker", rid, decision, reason) + report(root, "research", "worker", rid, "conclusion", "The assessment is available.") + drain(root, registry, store, lambda *_: pytest.fail("private return sent externally")) + snapshot = project_chat_session_snapshot(root, ChatSessionStore(root), session["session_id"]) + collaboration = next(row["collaboration"] for row in snapshot["messages"] if row.get("collaboration")) + assert collaboration["decision"] == decision + assert collaboration["goal_id"] == "research" + assert "Recipient assessment" in collaboration["decision_reason"] + assert "/Users/example" not in collaboration["decision_reason"] + assert collaboration["returns"][0]["status"] == "delivered" + reply = next(row for row in snapshot["messages"] if row.get("origin") == "manager_followup") + assert reply["text"].startswith("协作回复 · worker") + assert "处理结论" not in reply["text"] + assert "Recipient assessment" not in reply["text"] + assert len(list((store.root / "sessions" / session["session_id"] / "turns").glob("*.json"))) == 1 + + +def test_external_reply_never_exposes_private_receiver_reason(flow): + root, registry, store, create = flow + session, _, receipt = create(external=True) + acknowledge(root, "research", "worker", receipt["request_id"], "defer", "Private receiver rationale") + report(root, "research", "worker", receipt["request_id"], "conclusion", "This request is deferred.") + sent = [] + def sender(_route, _session, _turn, payload): + sent.append(payload) + return {"ok": True, "reply_verified": True, "verification_performed": True} + drain(root, registry, store, sender) + snapshot = project_chat_session_snapshot(root, store, session["session_id"]) + assert "Private receiver rationale" not in json.dumps(snapshot) + assert len(sent) == 1 + assert sent[0].startswith("协作回复 · worker") + assert "Private receiver rationale" not in json.dumps(sent) + assert all(not row.get("collaboration") for row in snapshot["messages"]) From 0a7e979f89eb258fa36fa150b00d9e0c22dcb025 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:17:32 +0800 Subject: [PATCH 2/2] docs(product): focus App acceptance on one complete delegated journey Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../app-conversation-and-async-inbox-v0.md | 33 +++++++++++++++++++ ...p-conversation-and-async-inbox-v0.zh-CN.md | 22 +++++++++++++ .../rfcs/loopx-overall-roadmap-v0.md | 7 +++- .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 6 +++- .../use-cases/steward/golden-queries.md | 9 +++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 4062b41be4..83e0521147 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -120,6 +120,39 @@ existing creation/connect, conversation reliability, affinity handoff and small-team Todos; do not create duplicate planning queues. Packaging/first-use checks run with each usable phase, not at the end of an architectural rewrite. +### Nearest user-visible exit: one request, controllable work, returned result + +Qualify one concrete G0/R3 journey before expanding the feature inventory. The +user says **“Prepare a community survey for LoopX; bring me a draft.”** The App +finds the qualified existing owner, retains the request, shows its actual +disposition, and returns a readable Markdown draft with sources. The correction +**“Chinese first; do not publish.”** must reach and be adopted by the actual +receiver. Publication is outside this draft-only pilot. + +Freeze GQ02/GQ04/GQ08/GQ09 with these observable exits: + +- No manual Agent-id lookup, old-session link, repeated context, reminder or + result relay. Recipient identity and task purpose are visible. +- Accepted, deferred, rejected, executing and returned facts remain distinct. + A delivered deferral is not task completion. Explain delay beside the request; + private receiver reasoning never enters an external audience automatically. +- One authorized driver performs the work. Correction and scoped-stop variants + require receiver/runtime readback, not only a transport ACK. +- The draft opens in the original App conversation after reload or session + replacement. Record source, packaged UI and real native execution separately + as passed, failed, blocked or not run. + +Then qualify existing G1 with **“Get a small team to check the cash-flow numbers +and resolve the disagreement.”** Two or three real workers consume versioned +inputs, independently challenge a period/unit error, adopt the revision and +return a checked synthesis. A second cycle changes the consumed input basis. +This is GQ05/GQ11–13, not a new milestone or queue. + +Keep WIP on the first journey and demonstrated blockers. Reuse acceptance +recovery, GoalRef and late-return changes. Shared TS refactors accompany the +affected transaction; full migration, Lark visual parity, scale and promotional +film do not block this pilot. Component PR merges do not certify the journey. + ## TS and generic async inbox: migrate with the user path ### Semantic boundary diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index fe98772221..a11116692d 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -97,6 +97,28 @@ LoopX 还必须核验综合结果返回原请求。这些是文档陈述,非 继续既有创建、连接、会话可靠性、affinity handoff 与小队 Todos,不创建重复计划队列。 每个可用阶段都运行打包和首次使用检查,不等到架构重写结束。 +### 最近的可感知交付:一句话交办,过程可控,结果回来 + +先验收一条 G0/R3 真实路径,再扩充功能清单。用户说:**“给 LoopX 做份社区问卷,先给我草稿。”** +App 找到合格的已有负责人,保留请求,展示对方真实判断,在原对话返回带来源的可读 Markdown 草稿。 +补充 **“先做中文,别发布。”** 后,实际接收方必须采用纠偏;发布不属于本草稿试点。 + +以 GQ02/GQ04/GQ08/GQ09 冻结验收: + +- 用户不提供 Agent ID、旧会话链接,不重复背景,不催办、不搬运结果;接收方和任务目的清晰。 +- 接收、延期、拒绝、执行、回传保持区分;延期回复送达不等于任务完成。 + 延期原因在请求旁可见,接收方的私有判断不得自动泄露到外部受众。 +- 一个获准驱动实际干活;纠偏和定向停止分别有接收方或运行时读回,传输 ACK 不够。 +- 原对话可打开草稿,刷新或更换会话后仍在;源码、打包前端、真实原生执行分别记录通过、失败、阻塞或未运行。 + +随后验收已有 G1:**“组个小队,核对现金流,把分歧查清楚。”** +2–3 个真实 worker 消费版本化材料,独立指出期间或单位错误,采用修订后回传综合结论; +第二轮修改输入,必须改变实际消费依据。对应 GQ05/GQ11–13,不新增里程碑或任务队列。 + +在制工作集中于第一条路径及其实际阻碍,复用请求恢复、GoalRef 和延迟回传工作。 +TS 重构随受影响事务推进;全量迁移、Lark 视觉对齐、规模化与宣传片不阻塞试点。 +组件 PR 合并不代表试点已经通过。 + ## TS 与通用异步 inbox:随用户路径迁移 ### 语义边界 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index fe372f47db..53220ffe55 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -36,7 +36,12 @@ embedding a transcript or sending an inbox ACK does not complete that transition [App conversations and asynchronous inbox](app-conversation-and-async-inbox-v0.md) refines R1–R3 without adding a milestone: repair ordinary input and durable entry; qualify connect/continue, truthful activity, stop/recovery and readable -results; then complete G1's two real collaboration cycles. Prioritize these +results. The nearest observable exit is one natural App request through an +existing qualified owner to a reviewable draft, with adopted correction and +recovery, without manual owner lookup, reminders or result relay. A delivered +deferral stays deferred. Then complete G1's two real collaboration cycles. +Use the [concrete pilot and exits](app-conversation-and-async-inbox-v0.md#nearest-user-visible-exit-one-request-controllable-work-returned-result). +Prioritize these before cross-channel visual parity or scale. Lark's reusable inbox lifecycle should converge into the existing typed coordination owner, while provider authentication/addressing/reactions remain in its extension. Product-facing TS diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 8fc2e22053..19aee042ab 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -39,7 +39,11 @@ managed 与 attached 的工作对话都应能持续在 LoopX 中进行:沿用 [App 对话与通用异步 inbox 设计](app-conversation-and-async-inbox-v0.md) 细化既有 R1–R3:先修普通输入与持久受理,再验证连接/继续、可信状态、 -停止/恢复和可读结果,随后完成 G1 两轮真实协作。它们优先于各渠道外观一致或 +停止/恢复和可读结果。最近的可感知交付是一句 App 请求,经合格的已有负责人, +带着实际采用的纠偏与恢复能力,把可审阅草稿返回原对话;用户不用找人、催办或搬结果。 +延期回复送达仍是延期。随后完成 G1 两轮真实协作,按 +[具体试点与退出条件](app-conversation-and-async-inbox-v0.zh-CN.md) 验收。 +它们优先于各渠道外观一致或 扩大规模。Lark 中可复用的 inbox 生命周期逐步收敛到现有 TS coordination owner, 认证、寻址、反应和消息限制保留在扩展。产品 TS 重构按完整用户路径推进, 不能变成逐字段 RPC 增长或全量重写前置条件。 diff --git a/docs/product/use-cases/steward/golden-queries.md b/docs/product/use-cases/steward/golden-queries.md index d23f4c7586..a61b7f5342 100644 --- a/docs/product/use-cases/steward/golden-queries.md +++ b/docs/product/use-cases/steward/golden-queries.md @@ -122,6 +122,15 @@ cause remains unknown. GQ04 permits a reasoned “do not change”; the desired answer is not predetermined. GQ06 does not authorize public posting. GQ08 is two separate variants: correcting scope and stopping work must not be conflated. +The nearest App pilot composes GQ02/GQ04/GQ08/GQ09: **“给 LoopX 做份社区问卷,先给我草稿。”** +/ **“Prepare a community survey for LoopX; bring me a draft.”** Follow with +**“先做中文,别发布。”** / **“Chinese first; do not publish.”** Freeze public reference +materials, one qualified existing owner and its prior context. Evaluate a real +readable draft and adopted correction in the original conversation; no posting, +manual Agent-id lookup, copied context, result relay or reminder. In the deferral +variant, explain the actual delay and retain that status after reply delivery. +This composes existing cases; it is not a fixed phrase to recognize or a pass. + ## Freeze a reproducible setup before running Use disposable Goals, isolated workspaces and a test conversation. Do not modify