From 94668742e1000c6d598b2c030f68b7e6420ad8a4 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:57:42 +0800 Subject: [PATCH 1/4] feat(chat): admit isolated project conversations in selected group topics Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 8 +- .../private-conversation-panel.tsx | 62 ++++- .../native_chat/conversation_bindings.py | 22 +- .../native_chat/project_context.py | 2 +- loopx/chat_lark_api.py | 6 +- loopx/chat_runtime.py | 6 +- .../collaboration/conversation_binding.ts | 46 +++- .../collaboration/conversation_scope.ts | 16 +- .../extensions/lark/conversation_identity.py | 7 + .../lark/private_conversation_api.py | 20 +- .../extensions/lark/private_conversations.py | 41 ++- .../project_registry_io_manifest_v1.json | 6 +- .../conversation_binding.test.ts | 43 +++ tests/test_lark_private_conversations.py | 258 +++++++++++++++++- 14 files changed, 497 insertions(+), 46 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 47253a00be..53ab43680f 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2181,7 +2181,7 @@ export type LarkPermissionGuidance = { const larkGroupChatsSchema = z.object({ ok: z.literal(true), - chats: z.array(z.object({ chat_id: z.string(), chat_name: z.string() })), + chats: z.array(z.object({ chat_id: z.string(), chat_name: z.string(), selected: z.boolean().default(false) })), }); export async function fetchLarkGroupChats(appRef: string, query?: string) { @@ -2402,6 +2402,7 @@ const privateConversationSchema = z.object({ project_ref: z.string(), project_title: z.string(), context_available: z.boolean(), executor_endpoint_id: z.string(), grant: z.enum(["workspace_read", "workspace_write", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(), goal_scope: z.enum(["all_registered", "selected"]).nullable().optional(), + audience: z.enum(["owner", "group"]).default("owner"), group_count: z.number().int().default(0), pending_count: z.number().int(), recovery_count: z.number().int(), agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]), }); @@ -2411,11 +2412,12 @@ export type PrivateConversation = z.infer; export async function fetchPrivateConversations() { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations")); } -export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write") { +export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write", groupIds?: string[]) { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor, context_kind: contextKind, project_grant: projectGrant, - ...(contextKind === "steward" ? {goal_scope: "all_registered"} : {})}), + ...(contextKind === "steward" ? {goal_scope: "all_registered"} : {}), + ...(groupIds !== undefined ? {audience: "group", group_ids: groupIds} : {})}), })); } export async function disconnectPrivateConversation(bindingId: string, revision: number) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx index 8c0e66ac2e..ef1424b8f8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx @@ -1,6 +1,6 @@ import {useEffect, useState} from "react"; import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations, - changePrivateAgentTarget, fetchChatProjects, fetchChatCapabilities, fetchLarkApps, type PrivateConversation, + changePrivateAgentTarget, fetchChatProjects, fetchChatCapabilities, fetchLarkApps, fetchLarkGroupChats, type PrivateConversation, type ChatProject, type LarkApp} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import "./private-conversation.css"; @@ -17,6 +17,12 @@ export function PrivateConversationPanel() { const [project, setProject] = useState(""); const [executor, setExecutor] = useState(""); const [role, setRole] = useState<"project" | "steward">("project"); + const [audience, setAudience] = useState<"owner" | "group">("owner"); + const [groups, setGroups] = useState>>([]); + const [groupIds, setGroupIds] = useState([]); + const [groupError, setGroupError] = useState(""); + const [loadingGroups, setLoadingGroups] = useState(false); + const [groupRefresh, setGroupRefresh] = useState(0); const [projectGrant, setProjectGrant] = useState<"workspace_read" | "workspace_write">("workspace_write"); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -43,6 +49,19 @@ export function PrivateConversationPanel() { return () => {active = false; clearInterval(timer);}; }, []); + useEffect(() => { + let active = true; + setGroups([]); setGroupIds([]); setGroupError(""); + setLoadingGroups(false); + if (!app || audience !== "group") return; + setLoadingGroups(true); + fetchLarkGroupChats(app).then(chats => { + if (active) {setGroups(chats); setGroupIds(chats.filter(chat => chat.selected).map(chat => chat.chat_id));} + }).catch(error => {if (active) setGroupError(String(error));}) + .finally(() => {if (active) setLoadingGroups(false);}); + return () => {active = false;}; + }, [app, audience, groupRefresh]); + const effectiveProjectGrant = executor === "codex" && projects.find(item => item.project_ref === project)?.grant === "workspace_write" ? projectGrant : "workspace_read"; @@ -62,39 +81,58 @@ export function PrivateConversationPanel() { setApp(appRef); const saved = rows.find(row => row.app_ref === appRef); if (saved) { + setAudience(saved.audience); setProject(saved.project_ref); setExecutor(saved.executor_endpoint_id); setRole(saved.context_kind); setProjectGrant(saved.grant === "workspace_write" ? "workspace_write" : "workspace_read"); - } else setProjectGrant("workspace_write"); + } else {setProjectGrant("workspace_write"); setAudience("owner");} } - return
-

{zh ? "本人私聊 · 项目助手与管家" : "Owner private Chat · Project assistant and steward"}

+ return
+

{zh ? "飞书 · 项目助手与管家" : "Lark · Project assistants and steward"}

{zh ? "每个 App 单独核验本人。项目助手默认支持工作区读写,按项目规则与 skills 执行编辑;可选只读。普通聊天不创建隐藏 Goal。本人管家默认读取本机已注册的 Goal 与 Agent;没有注册工作时显示空态。旧连接保留原范围,需明确升级授权。交办不提升执行或发布权限。" : "Verify the owner independently for each App. Project Chat defaults to workspace writes under project rules and skills; read-only remains available without hidden Goals. A personal steward defaults to the registered local Goals and Agents, including an honest empty state. Existing connections retain their scope until explicitly upgraded. Handoff does not elevate execution or publishing permissions."}

{rows.map(row =>
{row.app_ref} · {row.context_available ? row.project_title : (zh ? "工作区不可用" : "Workspace unavailable")}

{row.context_kind === "steward" ? (row.goal_scope === "all_registered" ? (zh ? `LoopX 管家 · 全部已注册工作 · ${row.goal_count} 个 Goal` : `LoopX steward · All registered work · ${row.goal_count} Goals`) : (zh ? `LoopX 管家 · 已选范围 · ${row.goal_count} 个 Goal` : `LoopX steward · Selected scope · ${row.goal_count} Goals`)) : (row.grant === "workspace_write" ? (zh ? "普通项目助手 · 已授权工作区读写" : "Project assistant · Workspace writes authorized") : (zh ? "普通项目助手 · 只读对话" : "Project assistant · Read-only Chat"))}

+ {row.audience === "group" ?

{zh ? `群话题助手 · 已授权 ${row.group_count} 个群 · 独立工作区隔离` : `Group topic assistant · ${row.group_count} authorized groups · Isolated workspace`}

: null}

{row.executor_endpoint_id} · {zh ? "监听状态" : "Listener"}: {listenerLabel(row.listener_status)}

{zh ? `待处理或回复:${row.pending_count}` : `Pending execution or reply: ${row.pending_count}`}

{row.recovery_count > 0 ?

{zh ? "存在尚未确认的发送回执。服务会读取原回执恢复;不要重新发送同一任务。检查 App 登录、权限和原会话后刷新状态。" : "A send receipt is unconfirmed. The service reads the original receipt to recover; avoid resending the same task. Check this App login, permissions and original Session, then refresh status."}

: null} {!row.context_available ?

{zh ? "工作区授权已失效;请恢复原工作区或重新选择。旧会话不会移到其它工作区。" : "The workspace grant is unavailable. Restore the original workspace or select a new one; the old Session will not move."}

: null} - {row.context_kind === "project" ? : null} + {row.context_kind === "project" && row.audience !== "group" ? : null} {row.context_kind === "steward" && row.goal_scope !== "all_registered" ? : null}
)} - {rows.length === 0 ?

{zh ? "尚未连接本人私聊。" : "No owner private Chat connected."}

: null} - - + {audience === "group" ?
+ {zh ? "允许使用的群(最多 16 个)" : "Authorized groups (up to 16)"} + {loadingGroups ?

{zh ? "正在读取此 App 的群" : "Reading this App's groups"}

: groups.map(chat => )} + {!loadingGroups && !groups.length && !groupError ?

{zh ? "此 App 尚无可见群。先将 Bot 加入调试群,再刷新。" : "This App has no visible groups. Add the Bot to a trial group, then refresh."}

: null} + {groupError ?

{groupError}

: null} + +

{zh ? "新话题需 @此 Bot;回复留在原话题。仅使用选定工作区,不能继承个人管家、全局工具或已有 Agent 会话。需要在独立执行环境登录;连接成功不代表已通过公开群准出。" : "Mention this Bot to start; replies stay in the original topic. Only the selected workspace is available, without personal steward, global tools or existing Agent Sessions. Sign in to the independent execution environment. Connection is not public release qualification."}

+
: null} + - {role === "project" ? : null} {role === "project" && effectiveProjectGrant === "workspace_write" ?

{zh ? "此 App 可按你的指令编辑选定工作区;不提高已直连 Agent 的原宿主权限。更改权限会建立新绑定和新会话,旧会话不会自动提升权限,已有 Agent 直连需重新授权。" : "This App may edit the selected workspace on your instruction; attached Agents retain their original host permissions. Changing access creates a new binding and Session. Existing Chat does not gain access and Agent targets require new authorization."}

: null} -
+

{zh ? "发送文字、图片或图文消息开始;后续消息进入原会话队列。/status 查看工作区、角色与持久排队状态,/help 查看用法与解绑入口,/stop 停止当前聊天执行,/new 开启新会话。文件、音视频、附在控制命令或已选择 Agent 上的图片会明确提示暂不支持。" : "Send text, images or image/text posts to begin; follow-ups queue in the same Session. /status shows the workspace, role and durable queue, /help explains commands and where to unbind, /stop stops the current Chat Turn, /new starts a new conversation. Files, audio/video, and images sent with control commands or to a selected attached Agent receive an explicit unsupported response."}

-

{zh ? "管家新委托:/delegate --tokens N 具体目标。先读预览,再用原私聊的完整 /confirm 命令确认;15 分钟过期。原生执行保持只读,总 token 上限可能被运行中的请求超过;没有默认定时调度。回执提供 /stop-commission 停止和 /resume-commission 恢复命令;恢复保留原线程及累计用量。" : "Steward commission: /delegate --tokens N objective. Read the preview, then use its full /confirm command in the original private Chat within 15 minutes. Native execution remains read-only; in-flight requests can exceed the total token allowance. No default schedule. Receipts provide /stop-commission and /resume-commission commands; recovery retains the original thread and cumulative usage."}

+ {audience === "owner" ?

{zh ? "管家新委托:/delegate --tokens N 具体目标。先读预览,再用原私聊的完整 /confirm 命令确认;15 分钟过期。原生执行保持只读,总 token 上限可能被运行中的请求超过;没有默认定时调度。回执提供 /stop-commission 停止和 /resume-commission 恢复命令;恢复保留原线程及累计用量。" : "Steward commission: /delegate --tokens N objective. Read the preview, then use its full /confirm command in the original private Chat within 15 minutes. Native execution remains read-only; in-flight requests can exceed the total token allowance. No default schedule. Receipts provide /stop-commission and /resume-commission commands; recovery retains the original thread and cumulative usage."}

: null} {error ?

{error}

: null}
; } diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index 98662778e1..88e28e9a13 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -43,7 +43,9 @@ def _core(operation: str, params: dict[str, Any]) -> dict[str, Any]: def configure(self, *, transport_ref: str, project_ref: str, executor_endpoint_id: str, context_kind: str = "project", - project_grant: str | None = None, goal_scope: str | None = None) -> dict[str, Any]: + project_grant: str | None = None, goal_scope: str | None = None, + audience: str | None = None, group_refs: list[str] | None = None, + available_group_refs: list[str] | None = None) -> dict[str, Any]: if goal_scope is not None and (context_kind != "steward" or goal_scope not in {"selected", "all_registered"}): raise ValueError("select a supported steward Goal scope") if project_grant is None: @@ -59,11 +61,13 @@ def configure(self, *, transport_ref: str, project_ref: str, "executor_endpoint_id": executor_endpoint_id, "grant": project_grant if context_kind == "project" else "portfolio_read", "enabled": True, **({"goal_ids": [], "goal_scope": goal_scope or "all_registered"} if context_kind == "steward" else {}), + **({"audience": audience} if audience is not None else {}), + **({"group_refs": group_refs} if group_refs is not None else {}), } with exclusive_file_lock(self.path, operation="configure_chat_conversation_binding"): current = self.read() previous = next((row for row in current["bindings"] if row["transport_ref"] == transport_ref), None) - if previous and all(previous.get(key) == candidate.get(key) for key in ["context_kind", "project_ref", "executor_endpoint_id", "provider_ref", "operator_ref", "grant"]): + if previous and all(previous.get(key) == candidate.get(key) for key in ["context_kind", "project_ref", "executor_endpoint_id", "provider_ref", "operator_ref", "grant", "audience", "group_refs"]): if previous.get("agent_targets"): candidate["agent_targets"] = previous["agent_targets"] if (context_kind == "steward" and previous and all(previous.get(key) == candidate.get(key) @@ -79,6 +83,7 @@ def configure(self, *, transport_ref: str, project_ref: str, result = self._core("collaboration.conversation.binding", { "current": current, "expected_revision": current.get("revision"), "operation": "configure", "binding": candidate, "observation": observation, "available_projects": self.projects.available(), + **({"available_group_refs": available_group_refs} if audience == "group" else {}), }) proposed = next(row for row in result["state"]["bindings"] if row["transport_ref"] == transport_ref) channels = self.delivery_channels(proposed) if goal_scope is not None else [] @@ -111,7 +116,9 @@ def disconnect(self, binding_id: str, *, expected_revision: int) -> dict[str, An return dict(result["state"]) def resolve(self, *, binding_id: str, source_ref: str, sender_ref: str, - private_human_message: bool, session_context: dict[str, Any] | None = None) -> dict[str, Any]: + private_human_message: bool, session_context: dict[str, Any] | None = None, + group_ref: str | None = None, topic_ref: str | None = None, + group_human_message: bool = False) -> dict[str, Any]: current = self.read() row = next((item for item in current.get("bindings", []) if item.get("binding_id") == binding_id), None) if row is None: @@ -125,6 +132,8 @@ def resolve(self, *, binding_id: str, source_ref: str, sender_ref: str, "available_projects": self.projects.available(), **registry_scope, **({"session_context": session_context} if session_context is not None else {}), + **({"group_ref": group_ref, "topic_ref": topic_ref, + "group_human_message": group_human_message} if group_ref is not None or topic_ref is not None or group_human_message else {}), }) def goal_scope_ids(self, binding: dict[str, Any]) -> list[str]: @@ -160,6 +169,11 @@ def ensure_delivery_scope(self, selected: dict[str, Any], *, if_absent: bool = T sender_id=binding["operator_ref"], execute=execute, if_absent=if_absent) def session_context(self, saved: dict[str, Any]) -> dict[str, Any]: + if saved.get("audience") == "bound_group": + return self.resolve(binding_id=saved["binding_id"], source_ref=saved["source_ref"], + sender_ref=saved["operator_ref"], private_human_message=False, + group_ref=saved["group_ref"], topic_ref=saved["topic_ref"], group_human_message=True, + session_context=saved) return self.resolve(binding_id=saved["binding_id"], source_ref=saved["source_ref"], sender_ref=saved["operator_ref"], private_human_message=True, session_context=saved) @@ -221,7 +235,7 @@ def agent_observation(self, session_id: str) -> dict[str, Any]: def agent_candidates(self, binding_id: str) -> list[dict[str, Any]]: row = next(item for item in self.read()["bindings"] if item["binding_id"] == binding_id) - if row["context_kind"] != "project" or self.controller is None: + if row["context_kind"] != "project" or row.get("audience") == "group" or self.controller is None: return [] project = next((item for item in self.projects.available() if item["project_ref"] == row["project_ref"]), None) if project is None: diff --git a/loopx/capabilities/native_chat/project_context.py b/loopx/capabilities/native_chat/project_context.py index 9aeee52746..fa44f78f61 100644 --- a/loopx/capabilities/native_chat/project_context.py +++ b/loopx/capabilities/native_chat/project_context.py @@ -83,7 +83,7 @@ def session_context(self, session: dict[str, Any]) -> dict[str, Any]: saved = session.get("project_context") if not isinstance(saved, dict) or session.get("goal_id") is not None: raise ValueError("invalid ordinary project Session") - if saved.get("audience") == "bound_owner": + if saved.get("audience") in {"bound_owner", "bound_group"}: if self.conversation_bindings is None: raise ValueError("bound project conversation authority is unavailable") selected = self.conversation_bindings.session_context(saved) diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 15b3834263..d6f341c7b6 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -521,11 +521,15 @@ def _lark_chats(self) -> None: error_code=exc.error_code, ) return + from .extensions.lark.conversation_identity import identity_ref + bindings = self.server.runtime_controller.project_contexts.conversation_bindings + binding = next((row for row in bindings.read()["bindings"] if row["transport_ref"] == app_ref), None) if bindings else None self._send_json( { "ok": True, "schema_version": "loopx_lark_group_chats_v0", - "chats": chats, + "chats": [{**chat, "selected": identity_ref(binding["provider_ref"], chat["chat_id"]) in binding.get("group_refs", [])} + for chat in chats] if binding else chats, } ) diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index afdb7160c0..014bb4e692 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -1011,7 +1011,7 @@ def submit_turn( if session.get("steward_context") is not None: raise ValueError("bound steward Chat requires its external source admission") if session.get("project_context") is not None: - if session["project_context"].get("audience") == "bound_owner": + if session["project_context"].get("audience") in {"bound_owner", "bound_group"}: raise ValueError("bound project Chat requires its external source admission") context = self.project_contexts.session_context(session) project_context_policy.validate_project_executor_scope(str(session["agent_id"]), session["project_context"], self.capabilities) @@ -1143,7 +1143,7 @@ def steer_active_turn( if session.get("steward_context") is not None: raise ValueError("bound steward Chat requires its external source admission") if session.get("project_context") is not None: - if session["project_context"].get("audience") == "bound_owner": + if session["project_context"].get("audience") in {"bound_owner", "bound_group"}: raise ValueError("bound project steering requires its external source admission") self.project_contexts.session_context(session) if session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: @@ -1658,6 +1658,8 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: event_buffer.close() return if response.get("context_handoff") is not None: + if scope["kind"] not in {"owner_goal", "owner_portfolio", "external_audience"}: + raise ValueError("This workspace conversation cannot hand off to a Goal or Agent.") response = apply_context_handoff( self, scope, session, self.store.load_turn(session_id, turn_id) or {}, response, context, diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index c4df136d7d..3219572438 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -86,6 +86,18 @@ function validateAgentTargetObservation(target: JsonObject, value: unknown, work function binding(value: unknown): JsonObject { const row = requireJsonObject(value, "conversation binding"); + const group = row.audience === "group"; + if (row.audience !== undefined && row.audience !== "group") { + throw new EffectRuntimeRequestError("unsupported conversation audience"); + } + if (group && (row.context_kind !== "project" || row.executor_endpoint_id !== "codex")) { + throw new EffectRuntimeRequestError("group Chat requires a native project executor"); + } + const groups = row.group_refs; + if (group ? !Array.isArray(groups) || groups.length === 0 || groups.length > 16 + : groups !== undefined) throw new EffectRuntimeRequestError("invalid group audience grant"); + const groupRefs = group ? [...new Set((groups as unknown[]).map(value => ref(value, "group reference")))].sort() : []; + if (group && groupRefs.length !== (groups as unknown[]).length) throw new EffectRuntimeRequestError("duplicate group audience grant"); if (row.goal_scope !== undefined && (row.context_kind !== "steward" || !["all_registered", "selected"].includes(String(row.goal_scope)))) { throw new EffectRuntimeRequestError("unsupported steward Goal scope"); @@ -97,7 +109,7 @@ function binding(value: unknown): JsonObject { } const targets = row.agent_targets === undefined ? [] : row.agent_targets; if (!Array.isArray(targets) || targets.length > 16 - || (row.context_kind !== "project" && targets.length !== 0)) { + || ((row.context_kind !== "project" || group) && targets.length !== 0)) { throw new EffectRuntimeRequestError("invalid Agent target grant set"); } const agents = targets.map(agentTarget); @@ -114,6 +126,7 @@ function binding(value: unknown): JsonObject { context_kind: row.context_kind, project_ref: ref(row.project_ref, "workspace reference"), executor_endpoint_id: token(row.executor_endpoint_id, "executor endpoint"), grant: row.grant, enabled: true, + ...(group ? {audience: "group", group_refs: groupRefs} : {}), ...(row.context_kind === "steward" ? {goal_ids: normalizeStewardGoalScope(row.goal_ids), ...(row.goal_scope !== undefined ? {goal_scope: row.goal_scope} : {})} : {}), ...(agents.length ? {agent_targets: agents} : {}), @@ -161,6 +174,12 @@ export function planConversationBinding(params: JsonObject): JsonObject { if (params.operation === "configure") { const candidate = binding(params.binding); observed(candidate, params.observation); + if (candidate.audience === "group") { + if (!Array.isArray(params.available_group_refs) + || !(candidate.group_refs as string[]).every(group => (params.available_group_refs as unknown[]).includes(group))) { + throw new EffectRuntimeRequestError("selected groups are not independently verified under this App"); + } + } const project = Array.isArray(params.available_projects) ? params.available_projects.map(normalizeProjectContext).find(row => row.project_ref === candidate.project_ref) : undefined; if (!project || (candidate.grant === "workspace_write" && project.grant !== "workspace_write")) { @@ -179,7 +198,7 @@ export function planConversationBinding(params: JsonObject): JsonObject { } else if (params.operation === "grant_agent_target" || params.operation === "revoke_agent_target") { const id = ref(params.binding_id, "binding identity"); const previous = current.bindings.find(row => row.binding_id === id); - if (!previous || previous.context_kind !== "project") throw new EffectRuntimeRequestError("Agent selection requires a project binding"); + if (!previous || previous.context_kind !== "project" || previous.audience === "group") throw new EffectRuntimeRequestError("Agent selection requires a private project binding"); observed(previous, params.observation); const existing = previous.agent_targets as JsonObject[] | undefined ?? []; let targets: JsonObject[]; @@ -231,7 +250,18 @@ export function resolveBoundConversation(params: JsonObject): JsonObject { if (!row) throw new EffectRuntimeRequestError("conversation binding is no longer authorized"); observed(row, params.observation); const source = ref(params.source_ref, "source conversation identity"); - if (params.sender_ref !== row.operator_ref || params.private_human_message !== true) { + const group = row.audience === "group"; + let topic: JsonObject = {}; + if (group) { + const groupRef = ref(params.group_ref, "group reference"), topicRef = ref(params.topic_ref, "topic reference"); + ref(params.sender_ref, "group sender identity"); + if (params.group_human_message !== true || params.private_human_message !== false || topicRef !== source + || !(row.group_refs as string[]).includes(groupRef)) { + throw new EffectRuntimeRequestError("message audience is outside the binding grant"); + } + topic = {group_ref: groupRef, topic_ref: topicRef, filesystem_scope: "workspace_only"}; + } else if (params.sender_ref !== row.operator_ref || params.private_human_message !== true + || params.group_ref !== undefined || params.topic_ref !== undefined || params.group_human_message === true) { throw new EffectRuntimeRequestError("message audience is outside the binding grant"); } if (!Array.isArray(params.available_projects)) throw new EffectRuntimeRequestError("workspace grants unavailable"); @@ -242,9 +272,10 @@ export function resolveBoundConversation(params: JsonObject): JsonObject { } const goals = row.context_kind === "steward" && row.goal_scope === "all_registered" ? normalizeStewardGoalScope(params.available_goal_ids) : row.goal_ids; - const context = {...projects[0], audience: "bound_owner", binding_id: id, source_ref: source, + const rawContext = {...projects[0], ...topic, audience: group ? "bound_group" : "bound_owner", binding_id: id, source_ref: source, provider_ref: row.provider_ref, operator_ref: row.operator_ref, grant: row.grant, ...(row.context_kind === "steward" ? {kind: "bound_steward", grant: "portfolio_read", goal_ids: goals} : {})}; + const context = group ? normalizeProjectContext(rawContext) : rawContext; if (params.session_context !== undefined) { const saved = requireJsonObject(params.session_context, "bound Session context"); // New commissions may extend the same owner's scope. Workspace, role and @@ -288,6 +319,9 @@ export function planBoundConversationRequest(params: JsonObject): JsonObject { return {operation: "reply", session_id: session, turn_id: null, response_code: "unsupported_attachment"}; } if (["agents", "select_agent", "select_project"].includes(String(command))) { + if (requireJsonObject(params.binding, "conversation binding").audience === "group") { + return {operation: "reply", session_id: session, turn_id: null, response_code: "group_recipient_unavailable"}; + } return {operation: "select_recipient", session_id: null, turn_id: null}; } if (params.agent_target !== undefined && params.agent_target !== null) { @@ -364,7 +398,9 @@ function boundConversationStatus(params: JsonObject, current: JsonObject | null) } } return {schema_version: "loopx_chat_bound_status_v0", observed_at: instant, - context_kind: selected.context_kind, workspace_path: context.workspace_path, + context_kind: selected.context_kind, + workspace_path: selected.audience === "group" ? String(context.workspace_path).split(/[\\/]/).filter(Boolean).at(-1) : context.workspace_path, + ...(selected.audience === "group" ? {audience: "group"} : {}), executor_endpoint_id: recipient?.executor_endpoint_id ?? selected.executor_endpoint_id, grant: selected.grant, ...(recipient ? {recipient_agent_id: recipient.agent_id, recipient_goal_id: recipient.goal_id, recipient_mode: "attached_host"} : {}), authorized_commission_count: steward ? (selected.goal_ids as string[]).length : 0, diff --git a/loopx/control_plane/collaboration/conversation_scope.ts b/loopx/control_plane/collaboration/conversation_scope.ts index adeb24cb40..0655be2910 100644 --- a/loopx/control_plane/collaboration/conversation_scope.ts +++ b/loopx/control_plane/collaboration/conversation_scope.ts @@ -6,7 +6,7 @@ export function normalizeProjectContext(value: unknown): Record const ref = context.project_ref, workspace = context.workspace_path; if (typeof ref !== "string" || !/^[a-f0-9]{24}$/.test(ref) || typeof workspace !== "string" || !workspace || context.kind !== "project_workspace" - || !["local_owner", "bound_owner"].includes(String(context.audience)) + || !["local_owner", "bound_owner", "bound_group"].includes(String(context.audience)) || !["workspace_read", "workspace_write"].includes(String(context.grant)) || (workspace[0] !== "/" && !/^[A-Za-z]:[\\/]/.test(workspace))) { throw new Error("invalid project conversation context"); @@ -17,13 +17,21 @@ export function normalizeProjectContext(value: unknown): Record if (context.filesystem_scope !== "workspace_only") throw new Error("invalid project filesystem scope"); normalized.filesystem_scope = "workspace_only"; } - if (context.audience === "bound_owner") { + if (context.audience === "bound_owner" || context.audience === "bound_group") { for (const field of ["binding_id", "source_ref", "provider_ref", "operator_ref"]) { const value = context[field]; if (typeof value !== "string" || !/^[a-f0-9]{24}$/.test(value)) throw new Error("incomplete bound project identity"); normalized[field] = value; } } + if (context.audience === "bound_group") { + if (context.filesystem_scope !== "workspace_only") throw new Error("group Chat requires workspace-only isolation"); + for (const field of ["group_ref", "topic_ref"]) { + const value = context[field]; + if (typeof value !== "string" || !/^[a-f0-9]{24}$/.test(value)) throw new Error("incomplete group topic identity"); + normalized[field] = value; + } + } return normalized; } @@ -34,7 +42,7 @@ export function projectConversationIdentity(input: Record): Rec // Share authentication within this authorized workspace/owner binding, // never with another App or local-owner context. Source topics retain // separate threads without requiring a new login for every message. - host_store_key: [context.project_ref, ...(context.audience === "bound_owner" + host_store_key: [context.project_ref, ...(context.audience !== "local_owner" ? [context.binding_id, context.provider_ref, context.operator_ref] : ["local"])].join("."), permissions_profile: `loopx_workspace_only_${writable ? "write" : "read"}`, host_config: { @@ -113,7 +121,7 @@ export function resolveConversationScope(input: Record): Conver && channel === `project.${context.project_ref}`) { return {kind: "project_workspace", goal_ids: [], private_conversation: true}; } - if (context.audience === "bound_owner" && (input.origin === undefined || input.origin === "lark" || input.origin === "external") + if (context.audience !== "local_owner" && (input.origin === undefined || input.origin === "lark" || input.origin === "external") && channel === `project.external.${context.binding_id}.${context.source_ref}`) { return {kind: "project_workspace", goal_ids: [], private_conversation: false}; } diff --git a/loopx/extensions/lark/conversation_identity.py b/loopx/extensions/lark/conversation_identity.py index 71db018cf4..916066ad19 100644 --- a/loopx/extensions/lark/conversation_identity.py +++ b/loopx/extensions/lark/conversation_identity.py @@ -57,5 +57,12 @@ def lark_private_source(*, provider_ref: str, event: Mapping[str, Any]) -> dict[ if not (re.fullmatch(r"[a-f0-9]{24}", provider_ref) and LARK_CHAT_ID_PATTERN.fullmatch(chat) and LARK_MESSAGE_ID_PATTERN.fullmatch(message) and OPEN_ID_PATTERN.fullmatch(sender)): raise ValueError("incomplete private-message provenance") + if event.get("chat_type") == "group": + root = str(event.get("root_id") or message) + if not LARK_MESSAGE_ID_PATTERN.fullmatch(root) or (event.get("parent_id") and not event.get("root_id")): + raise ValueError("group topic root is unavailable") + return {"source_ref": identity_ref(provider_ref, chat, root), "sender_ref": identity_ref(provider_ref, sender), + "private_human_message": False, "group_human_message": event.get("sender_type") == "user", + "group_ref": identity_ref(provider_ref, chat), "topic_ref": identity_ref(provider_ref, chat, root)} return {"source_ref": identity_ref(provider_ref, chat, sender), "sender_ref": identity_ref(provider_ref, sender), "private_human_message": event.get("chat_type") == "p2p" and event.get("sender_type") == "user"} diff --git a/loopx/extensions/lark/private_conversation_api.py b/loopx/extensions/lark/private_conversation_api.py index 58c04ba9f1..03cdd3ac6a 100644 --- a/loopx/extensions/lark/private_conversation_api.py +++ b/loopx/extensions/lark/private_conversation_api.py @@ -20,6 +20,7 @@ def _private_conversations(self) -> None: {"binding_id": row["binding_id"], "app_ref": row["transport_ref"], "context_kind": row["context_kind"], "project_ref": row["project_ref"], "context_available": row["project_ref"] in titles, "project_title": titles.get(row["project_ref"], "Unavailable workspace"), "executor_endpoint_id": row["executor_endpoint_id"], "grant": row["grant"], + "audience": row.get("audience", "owner"), "group_count": len(row.get("group_refs", [])), "goal_count": len(bindings.goal_scope_ids(row)), "goal_scope": row.get("goal_scope", "selected") if row["context_kind"] == "steward" else None, "agent_candidates": [{key: item.get(key) for key in ["session_id", "goal_id", "agent_id", "executor_endpoint_id"]} @@ -34,13 +35,27 @@ def _private_conversation_connect(self) -> None: from ...chat_lark_api import build_lark_goal_topic_runtime_snapshot try: body = self._read_json() - if set(body) - {"context_kind", "project_grant", "goal_scope"} != {"app_ref", "project_ref", "executor_endpoint_id"}: + if set(body) - {"context_kind", "project_grant", "goal_scope", "audience", "group_ids"} != {"app_ref", "project_ref", "executor_endpoint_id"}: raise ValueError("select an App, authorized workspace and executor") profile = str(body["app_ref"]) existing = _active_profile_configs(build_lark_goal_topic_runtime_snapshot( registry_path=self.server.registry_path, runtime_root_override=self.server.runtime_root_override)) from .conversation_identity import identity_ref observed = self.server.runtime_controller.project_contexts.conversation_bindings.observe(profile) + group_refs, available_group_refs = None, None + if body.get("audience") == "group": + from .goal_topic_connections import list_lark_group_chats, LarkGroupChatLookupError + requested = body.get("group_ids") + if not isinstance(requested, list) or not requested or len(requested) > 16 or any(not isinstance(item, str) for item in requested): + raise ValueError("select one to sixteen groups visible to this App") + try: + chats = list_lark_group_chats(app_ref=profile, runner=self._lark_runner(), cli_bin=self._require_lark_cli()) + except LarkGroupChatLookupError as exc: + raise ValueError("the App's group membership could not be verified") from exc + available_group_refs = [identity_ref(observed["provider_ref"], item["chat_id"]) for item in chats] + group_refs = [identity_ref(observed["provider_ref"], chat) for chat in requested] + elif "group_ids" in body or "audience" in body: + raise ValueError("unsupported conversation audience") from ...chat_lark_api import _app_identity_for_private_guard group_apps = [str(config.get("bot_app_id") or _app_identity_for_private_guard( other_profile, self._lark_runner(), self._require_lark_cli())) @@ -54,7 +69,8 @@ def _private_conversation_connect(self) -> None: transport_ref=profile, project_ref=str(body["project_ref"]), executor_endpoint_id=endpoint, context_kind=str(body.get("context_kind", "project")), project_grant=str(body["project_grant"]) if "project_grant" in body else None, - goal_scope=str(body["goal_scope"]) if "goal_scope" in body else None) + goal_scope=str(body["goal_scope"]) if "goal_scope" in body else None, + audience=body.get("audience"), group_refs=group_refs, available_group_refs=available_group_refs) self.server.lark_goal_topic_runtime.refresh() except (ValueError, OSError, KeyError) as exc: self._send_error(str(exc), status=400) diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index ec6bb0529d..3bab04d9b8 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -109,6 +109,28 @@ def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] | if (result.get("returncode") == 0 and message is not None and message.get("chat_id") == event["chat_id"] and isinstance(sender, Mapping) and sender.get("id") == event["sender_id"] and sender.get("sender_type") == "user"): + if selected["binding"].get("audience") == "group": + # Provider readback, never event text, fixes the topic root. + root = str(message.get("root_id") or message["message_id"]) + if (root != str(event.get("root_id") or event["message_id"]) + or (message.get("parent_id") and not message.get("root_id")) + or any(message.get(key, "") != event.get(key, "") for key in ["parent_id", "thread_id"])): + return None + if root == event["message_id"]: + original = message + else: + response = call(self.runner, lark_args(cli_bin=self.cli_bin, profile=record["profile"], + tail=["im", "+messages-mget", "--message-ids", root, + "--as", "bot", "--no-reactions", "--format", "json"])) + original = _message(json_payload(response), root) if response.get("returncode") == 0 else None + from .event_inbox import lark_event_mentions_bot + observation = self.bindings.observe(record["profile"]) + if (not original or original.get("chat_id") != event["chat_id"] + or original.get("sender", {}).get("sender_type") != "user" + or original.get("parent_id") or original.get("root_id") not in (None, "", root) + or not lark_event_mentions_bot(original, bot_display_name=observation["bot_display_name"], + allow_text_fallback=False)): + return None return message except (KeyError, ValueError, OSError): pass @@ -126,7 +148,7 @@ def _inbox(self, record: dict[str, Any]) -> Path: "inbox_dir": f".loopx/inbox/private-chat/{scope}", "capture_scope": "configured_chat_all", "reply": {"enabled": True, "sender_profile": record["profile"], "sender_identity": "bot", "bot_display_name": observation["bot_display_name"], "chat_id": record["event"]["chat_id"], - "placement_policy": "source_context", "received_reaction_emoji": "Get" if self.reaction_feedback else "", + "placement_policy": "source_thread" if record["source"].get("group_ref") else "source_context", "received_reaction_emoji": "Get" if self.reaction_feedback else "", "received_reaction_policy": "retain", "processing_reaction_emoji": "OnIt" if self.reaction_feedback else ""}, }) event = record["event"] @@ -154,18 +176,21 @@ def admit(self, profile: str, event: dict[str, Any]) -> dict[str, Any]: # event_id can change on redelivery; canonical message content # and audience cannot change underneath the stable message id. if any(record["event"].get(key) != event.get(key) for key in - ["message_id", "sender_id", "chat_id", "content", "message_type"]): + ["message_id", "sender_id", "chat_id", "content", "message_type", "chat_type", "sender_type", "root_id", "parent_id", "thread_id"]): return {"status": "source_conflict"} else: record = {"schema_version": "lark_private_chat_delivery_v0", "request_ref": request, "profile": profile, "binding_id": binding["binding_id"], "source": source, "event": event, "deliveries": {}, "status": "captured"} - _atomic_write_json(path, record) + if binding.get("audience") != "group": + _atomic_write_json(path, record) # Reuse this admission preflight only; Core rechecks under its # source fence, and outbound writes always perform a fresh check. source_message = self._source_message(record, selected=selected) if source_message is None: return {"status": "source_verification_failed"} + if binding.get("audience") == "group": + _atomic_write_json(path, record) message_type = str(event.get("message_type") or "") text = "" attachments: list[dict[str, Any]] = [] @@ -229,7 +254,7 @@ def admit(self, profile: str, event: dict[str, Any]) -> dict[str, Any]: admitted = self.core.admit(binding_id=binding["binding_id"], source=source, request_ref=request, message=text, command=command, attachments=attachments) except ValueError: - record.update(status="rejected", response="操作或原授权不可用;Agent 请先用 /agents 查看确切命令,/project 返回项目对话。新委托请使用 /delegate --tokens N 具体目标,确认或取消请使用原预览中的完整命令。") + record.update(status="rejected", response=("群助手仅处理当前项目和话题,不能选择个人 Agent 会话;/status 查看状态,/help 查看用法。" if binding.get("audience") == "group" else "操作或原授权不可用;Agent 请先用 /agents 查看确切命令,/project 返回项目对话。新委托请使用 /delegate --tokens N 具体目标,确认或取消请使用原预览中的完整命令。")) _atomic_write_json(path, record) return {"status": "command_rejected"} except RuntimeError as exc: @@ -628,7 +653,8 @@ def inbox() -> Path: def _command_text(code: str) -> str: - return {"unsupported_attachment": "这条消息未提交执行。普通项目或管家对话支持文字与图片;文件、音视频及所选 Agent 的原宿主暂不支持图片。请将文字与图片单独发送,或用 /project 返回项目对话。", + return {"group_recipient_unavailable": "群助手仅处理当前项目和话题,不能选择个人 Agent 会话;/status 查看状态,/help 查看用法。", + "unsupported_attachment": "这条消息未提交执行。普通项目或管家对话支持文字与图片;文件、音视频及所选 Agent 的原宿主暂不支持图片。请将文字与图片单独发送,或用 /project 返回项目对话。", "no_session": "尚无会话;发送文字即可开始。", "active_session": "正在执行;后续文字会进入同一会话队列。", "ready_session": "会话已就绪,可继续发送文字。", "new_session": "已关闭此前会话;下一条文字将开启新会话。", "attached_control_unavailable": "原 Agent 宿主尚不支持此处的实时停止或新建会话;原执行没有被停止或替换。请在原宿主处理,/project 返回普通项目对话。", @@ -640,6 +666,11 @@ def _status_text(snapshot: dict[str, Any], *, help_requested: bool) -> str: steward = snapshot["context_kind"] == "steward" attached = bool(snapshot.get("recipient_agent_id")) text = render_conversation_status(snapshot) + if snapshot.get("audience") == "group": + text += "\n\n/status 当前话题状态 · /stop 停止本话题执行 · /new 本话题新会话 · /help 用法" + if help_requested: + text += "\n@此 Bot 开始新话题,后续消息在原话题继续;文字与图片沿用同一会话。仅授权当前项目工作区,不继承个人管家或 Agent 会话。" + return text text += ("\n\n/agents 授权 Agent · /project 返回项目 · /help 用法" if attached else "\n\n/stop 停止当前聊天 · /new 新会话 · /help 用法") if help_requested: diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 4cea06357d..a0ee33f6f3 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -287,7 +287,7 @@ }, { "site": "loopx/capabilities/native_chat/conversation_bindings.py::.ChatConversationBindings.agent_observation::codec_read:load_registry#1", - "line": 209, + "line": 223, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -295,7 +295,7 @@ }, { "site": "loopx/capabilities/native_chat/conversation_bindings.py::.ChatConversationBindings.goal_scope_ids::codec_read:load_registry#1", - "line": 136, + "line": 145, "column": 40, "kind": "codec_read", "api": "load_registry", @@ -455,7 +455,7 @@ }, { "site": "loopx/chat_lark_api.py::.LarkChatRequestMixin._lark_connections::codec_read:load_registry#1", - "line": 536, + "line": 540, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index fa80a2f279..b0a19f3e26 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -16,6 +16,49 @@ const current = {schema_version: "loopx_chat_conversation_bindings_v0", revision const request = {current, expected_revision: 0, operation: "configure", binding: row, observation, available_projects: [project]}; +test("group topics require an observed App membership and cannot inherit private recipients or portfolio", () => { + const group = "1".repeat(24), topic = "2".repeat(24); + const configure = {...request, binding: {...row, audience: "group", group_refs: [group]}, + available_group_refs: [group]}; + const next = planConversationBinding(configure).state; + const use = {current: next, binding_id: row.binding_id, source_ref: topic, topic_ref: topic, + group_ref: group, sender_ref: "3".repeat(24), private_human_message: false, + group_human_message: true, observation, available_projects: [project]}; + const resolved = resolveBoundConversation(use); + const context = resolved.context as Record; + assert.equal(context.audience, "bound_group"); + assert.equal(context.filesystem_scope, "workspace_only"); + assert.equal(resolved.owner_manager_audience, false); + assert.deepEqual(resolveBoundConversation({...use, session_context: context}).context, context); + assert.deepEqual(resolveConversationScope({goal_id: null, channel_id: resolved.channel_id, + project_context: context, origin: "lark"}), + {kind: "project_workspace", goal_ids: [], private_conversation: false}); + const host = projectConversationIdentity({context}).host_config as Record; + assert.deepEqual(host.skills, {include_instructions: false}); + assert.equal(host.project_doc_max_bytes, 0); + assert.equal(host.allow_login_shell, false); + for (const denied of [{group_ref: "4".repeat(24)}, {topic_ref: "4".repeat(24)}, + {private_human_message: true}, {group_human_message: false}, + {observation: {...observation, verified: false}}, {current}]) { + assert.throws(() => resolveBoundConversation({...use, ...denied})); + } + for (const denied of [{available_group_refs: []}, {available_group_refs: undefined}, + {binding: {...configure.binding, context_kind: "steward", grant: "portfolio_read", goal_ids: []}}, + {binding: {...configure.binding, executor_endpoint_id: "claude-code"}}, + {binding: {...configure.binding, group_refs: [group, group]}}]) { + assert.throws(() => planConversationBinding({...configure, ...denied})); + } + for (const command of ["agents", "select_agent", "select_project"]) { + assert.equal(planBoundConversationRequest({binding: configure.binding, + request: {request_ref: topic, command}, current_session: null}).response_code, "group_recipient_unavailable"); + } + const changed = planConversationBinding({...configure, current: next, expected_revision: 1, + binding: {...configure.binding, binding_id: "5".repeat(24), group_refs: [group, "4".repeat(24)]}, + available_group_refs: [group, "4".repeat(24)]}).state; + assert.throws(() => resolveBoundConversation({...use, current: changed}), /no longer authorized/); + assert.equal(project.audience, "local_owner"); +}); + test("only a current verified private steward binding proves the machine owner audience", () => { const steward = {...row, context_kind: "steward", grant: "portfolio_read", goal_ids: []}; const next = planConversationBinding({...request, binding: steward}).state; diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index f3db9aed4e..991f269203 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -1,13 +1,16 @@ """Public synthetic provider/native Core journeys, never private credentials.""" import json import time +import http.client +import threading +from types import SimpleNamespace import pytest from test_chat_ordinary_project import ordinary # noqa: F401 from loopx.capabilities.native_chat.conversation_bindings import ChatConversationBindings from loopx.capabilities.native_chat.external_conversations import ChatExternalConversations -from loopx.extensions.lark.conversation_identity import observe_lark_conversation_identity +from loopx.extensions.lark.conversation_identity import identity_ref, lark_private_source, observe_lark_conversation_identity from loopx.extensions.lark.private_conversations import LarkPrivateConversations from loopx.extensions.lark.goal_topic_runtime import poll_lark_goal_topic_profile_once @@ -17,6 +20,7 @@ def __init__(self): self.messages = {} self.calls = [] self.writes = [] + self.topic_writes = [] self.verify_replies = True self.reactions = {} self.reaction_creates = [] @@ -34,6 +38,18 @@ def event(self, profile, name, text, kind="text"): "sender": {"id": event["sender_id"], "sender_type": "user"}, "msg_type": kind, "content": text} return event + def topic(self, name, text, *, root=None, chat="oc_community", sender="ou_member", addressed=True): + event = self.event("notes-app", name, text) + event.update(chat_id=chat, chat_type="group", sender_id=sender) + if root: + event.update(root_id=root, parent_id=root, thread_id=f"omt_{root}") + self.messages[event["message_id"]].update(chat_id=chat, + sender={"id": sender, "sender_type": "user"}, + **{key: event[key] for key in ["root_id", "parent_id", "thread_id"] if key in event}) + if not root and addressed: + self.messages[event["message_id"]]["mentions"] = [{"name": "notes-app", "id": {"open_id": "ou_bot"}}] + return event + def __call__(self, args, cwd=None, timeout=None): self.calls.append(list(args)) if args[1:] == ["profile", "list"]: @@ -45,6 +61,9 @@ def __call__(self, args, cwd=None, timeout=None): data = {"ok": True, "appId": f"cli_{profile.replace('-', '_')}", "identities": { "bot": {"available": True, "verified": True, "appName": profile}, "user": {"available": True, "verified": True, "openId": f"ou_{profile.replace('-', '_')}"}}} + elif "+chat-list" in args: + data = {"ok": True, "data": {"chats": [{"chat_id": chat, "name": title} + for chat, title in [("oc_community", "Community trial"), ("oc_second", "Second trial")]]}} elif "+messages-mget" in args: ref = args[args.index("--message-ids") + 1] message = self.messages[ref] @@ -54,13 +73,13 @@ def __call__(self, args, cwd=None, timeout=None): elif "+messages-edit" in args: ref = args[args.index("--message-id") + 1] content = args[args.index("--content") + 1] - assert self.messages[ref]["chat_id"] == f"oc_{profile.replace('-', '_')}" + assert self.messages[ref]["sender"]["id"] == self.profile_apps[profile] if "--dry-run" in args: data = {"ok": True, "api": [{"body": {"content": content, "msg_type": "post"}}]} else: self.messages[ref]["body"] = {"content": content} data = {"ok": True} - elif "+messages-send" in args: + elif "+messages-send" in args or "+messages-reply" in args: kind = "post" if "--content" in args else "text" content = args[args.index("--content") + 1] if kind == "post" else json.dumps({"text": args[args.index("--text") + 1]}) text = json.loads(content)["zh_cn"]["content"][0][0]["text"] if kind == "post" else json.loads(content)["text"] @@ -69,9 +88,15 @@ def __call__(self, args, cwd=None, timeout=None): else: ref = f"om_out_{len(self.writes)}" self.writes.append((profile, text)) + target = self.messages[args[args.index("--message-id") + 1]] if "+messages-reply" in args else None self.messages[ref] = {"message_id": ref, "msg_type": kind, "body": {"content": content}, - "chat_id": args[args.index("--chat-id") + 1], + "chat_id": target["chat_id"] if target else args[args.index("--chat-id") + 1], "sender": {"id": self.profile_apps[profile], "sender_type": "app"}} + if target: + assert "--reply-in-thread" in args + root = target.get("root_id") or target["message_id"] + self.messages[ref].update(root_id=root, parent_id=target["message_id"], thread_id=f"omt_{root}") + self.topic_writes.append((target["chat_id"], root, text)) data = {"ok": True, "data": {"message_id": ref}} elif "reactions" in args: ref = args[args.index("--message-id") + 1] @@ -110,6 +135,231 @@ def connect(fixture): runner=provider, cli_bin="lark-cli") +def connect_group(fixture): + store, runtime, provider, transport = connect(fixture) + runtime.codex_home = fixture[-1].parent / "isolated-native-account" + runtime.codex_home.mkdir() + fake = fixture[-2] + fake.write_text(fake.read_text().replace('"thread": {"id": "durable-thread"},', + '"thread": {"id": "durable-thread"}, ' + '"activePermissionProfile": {"id": request["params"].get("permissions")}, ' + '"runtimeWorkspaceRoots": [request["params"]["cwd"]],')) + binding = transport.bindings.read()["bindings"][0] + refs = [identity_ref(binding["provider_ref"], chat) for chat in ["oc_community", "oc_second"]] + transport.bindings.configure(transport_ref="notes-app", project_ref=binding["project_ref"], + executor_endpoint_id="codex", audience="group", group_refs=refs, available_group_refs=refs) + return store, runtime, provider, transport + + +def finish_group_turn(runtime, transport, *, message): + row = next(row for row in transport.core.pending() if row["message"] == message) + assert runtime.wait_for_turn(session_id=row["session_id"], turn_id=row["turn_id"], timeout_sec=10)["status"] == "completed" + transport.reconcile() + return row + + +def test_group_topics_share_members_but_isolate_topics_apps_and_native_host(ordinary): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + root = provider.topic("community-root", "Explain this public project") + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + first = finish_group_turn(runtime, transport, message=root["content"]) + continuation = provider.topic("community-follow", "Compare its configuration", root=root["message_id"], sender="ou_other_member") + assert transport.admit("notes-app", continuation)["status"] == "durably_accepted" + second = finish_group_turn(runtime, transport, message=continuation["content"]) + assert second["session_id"] == first["session_id"] + assert transport.admit("notes-app", {**continuation, "event_id": "redelivered"})["status"] == "durably_accepted" + for name, chat in [("different-topic", "oc_community"), ("different-group", "oc_second")]: + event = provider.topic(name, name, chat=chat) + assert transport.admit("notes-app", event)["status"] == "durably_accepted" + row = finish_group_turn(runtime, transport, message=name) + assert row["session_id"] != first["session_id"] + assert len(store.list_sessions()) == 3 + context = store.load_session(first["session_id"])["project_context"] + assert context["audience"] == "bound_group" and context["filesystem_scope"] == "workspace_only" + assert all(row["goal_id"] is None for row in store.list_sessions()) + assert provider.topic_writes and all(chat in {"oc_community", "oc_second"} for chat, _, _ in provider.topic_writes) + assert any(root_id == root["message_id"] and text == "Runtime response." for _, root_id, text in provider.topic_writes) + requests = [json.loads(line) for line in ordinary[4].read_text().splitlines()] + starts = [r["params"] for r in requests if r["method"] == "thread/start"] + assert len(starts) == 3 and all(r["permissions"] == "loopx_workspace_only_read" for r in starts) + assert all(not r.get("dynamicTools") for r in starts) + assert "Fresh Core evidence" not in ordinary[4].read_text() + # Original private project still uses the default host and grant. + assert runtime.project_contexts.available()[0].get("filesystem_scope") is None + assert transport._binding("steward-app").get("audience") is None + runtime.close() + from loopx.chat_runtime import ChatRuntimeController + restarted = ChatRuntimeController(store=store, codex_bin=str(ordinary[-2]), project_contexts=runtime.project_contexts) + restarted.codex_home = runtime.codex_home + try: + reopened, resumed = restarted.open_session(goal_id=None, agent_id="codex", work_dir=ordinary[-1], + objective="continue", conversation_binding_id=context["binding_id"], + source_context=lark_private_source(provider_ref=context["provider_ref"], event=root), + mode="resume_latest") + assert resumed and reopened["session_id"] == first["session_id"] + finally: + restarted.close() + finally: + runtime.close() + + +def test_group_model_handoff_cannot_read_or_dispatch_to_a_private_goal(ordinary, monkeypatch): # noqa: F811 + from loopx.chat_runtime import CodexAppServerAdapter + store, runtime, provider, transport = connect_group(ordinary) + original = CodexAppServerAdapter.start_turn + + def untrusted_response(adapter, *args, **kwargs): + return {**original(adapter, *args, **kwargs), "context_handoff": { + "goal_id": "private-work", "agent_id": "private-agent", "brief": "read private state"}} + + monkeypatch.setattr(CodexAppServerAdapter, "start_turn", untrusted_response) + monkeypatch.setattr("loopx.chat_runtime.apply_context_handoff", + lambda *_, **__: pytest.fail("workspace Chat must reject before any private Goal read or dispatch")) + try: + root = provider.topic("untrusted-handoff", "Explain this public project") + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + row = transport.core.pending()[0] + turn = runtime.wait_for_turn(session_id=row["session_id"], turn_id=row["turn_id"], timeout_sec=10) + assert turn["status"] == "failed" + assert "cannot hand off" in json.dumps(turn) + assert not (store.root.parent / "manager-context").exists() + finally: + runtime.close() + + +def test_group_progress_queue_and_stop_remain_in_the_original_topic(ordinary): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + roots = [provider.topic(f"running-{i}", "wait for interrupt") for i in range(2)] + for root in roots: + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + rows = [next(row for row in transport.core.pending() if row["source"] == lark_private_source( + provider_ref=transport._binding("notes-app")["provider_ref"], event=root)) for root in roots] + for row in rows: + deadline = time.monotonic() + 10 + while store.load_turn(row["session_id"], row["turn_id"])["status"] != "running": + assert time.monotonic() < deadline + time.sleep(.01) + queued = provider.topic("queued-in-first-topic", "Continue this public check", root=roots[0]["message_id"]) + assert transport.admit("notes-app", queued)["status"] == "durably_accepted" + assert len(store.queued_turns(rows[0]["session_id"])) == 1 + store.append_event(rows[0]["session_id"], rows[0]["turn_id"], kind="answer.delta", payload={"text": "Public progress"}) + store.append_event(rows[0]["session_id"], rows[0]["turn_id"], kind="agent.phase", payload={"method": "item/reasoning/textDelta", "label": "private reasoning"}) + transport.reconcile() + assert any(root == roots[0]["message_id"] and "Public progress" in text for _, root, text in provider.topic_writes) + assert all("private reasoning" not in text for _, _, text in provider.topic_writes) + stop = provider.topic("stop-first-topic", "/stop", root=roots[0]["message_id"]) + assert transport.admit("notes-app", stop)["status"] == "command_recorded" + assert runtime.wait_for_turn(session_id=rows[0]["session_id"], turn_id=rows[0]["turn_id"], timeout_sec=10)["status"] == "interrupted" + assert store.load_turn(rows[1]["session_id"], rows[1]["turn_id"])["status"] == "running" + finish_group_turn(runtime, transport, message=queued["content"]) + assert any("+messages-edit" in call for call in provider.calls) + assert all(root in {row["message_id"] for row in roots} for _, root, _ in provider.topic_writes) + finally: + runtime.close() + + +def test_group_provenance_and_revocation_prevent_private_or_wrong_topic_delivery(ordinary): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + for event in [provider.event("notes-app", "private", "private query"), + provider.topic("foreign", "foreign", chat="oc_not_selected"), + provider.topic("unaddressed", "ordinary community conversation", addressed=False)]: + assert transport.admit("notes-app", event)["status"] in {"audience_rejected", "source_verification_failed"} + root = provider.topic("root", "topic one") + forged = provider.topic("forged", "wrong root", root=root["message_id"]) + provider.messages[forged["message_id"]]["root_id"] = "om_other_root" + assert transport.admit("notes-app", forged)["status"] == "source_verification_failed" + assert not store.list_sessions() and not list(transport.root.glob("*.json")) + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + row = next(row for row in transport.core.pending() if row["message"] == root["content"]) + assert runtime.wait_for_turn(session_id=row["session_id"], turn_id=row["turn_id"], timeout_sec=10)["status"] == "completed" + binding = transport._binding("notes-app") + transport.bindings.disconnect(binding["binding_id"], expected_revision=transport.bindings.read()["revision"]) + transport.reconcile() + assert provider.writes == [] # Even a completed result needs current source authority. + assert transport.admit("notes-app", root)["status"] == "audience_rejected" + finally: + runtime.close() + + +def test_group_status_and_recipient_commands_cannot_expose_private_sessions(ordinary): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + root = provider.topic("status-root", "/status") + assert transport.admit("notes-app", root)["status"] == "command_recorded" + help_event = provider.topic("help", "/help", root=root["message_id"]) + assert transport.admit("notes-app", help_event)["status"] == "command_recorded" + for name, command in [("agents", "/agents"), ("agent", "/agent secret"), ("project", "/project")]: + assert transport.admit("notes-app", provider.topic(name, command, root=root["message_id"]))["status"] == "command_recorded" + transport.reconcile() + assert not store.list_sessions() + replies = "\n".join(text for _, text in provider.writes) + assert str(ordinary[-1]) not in replies and "此 Bot" in replies + assert "不能选择个人 Agent" in replies + assert all(root_id == root["message_id"] for _, root_id, _ in provider.topic_writes) + finally: + runtime.close() + + +def test_http_group_setup_observes_membership_reads_back_selection_and_rejects_widening(ordinary, monkeypatch): # noqa: F811 + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.extensions.lark.cli_resolution import LarkCliResolution + import loopx.chat_lark_api as api + + store, runtime, provider, transport = connect(ordinary) + monkeypatch.setattr(api, "build_lark_goal_topic_runtime_snapshot", lambda **_: {"goals": []}) + monkeypatch.setattr(ChatRequestHandler, "_lark_runner", lambda _: provider) + refreshes = [] + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.chat_store, server.runtime_controller = store, runtime + server.verbose = False + server.registry_path, server.runtime_root_override = runtime.registry_path, store.root.parent + server.lark_cli_resolution = LarkCliResolution(command="lark-cli", available=True, source="explicit", version="fixture", error_code=None) + server.lark_private_conversations = transport + server.lark_goal_topic_runtime = SimpleNamespace(refresh=lambda: refreshes.append(True), health_snapshot=lambda: {}, close=lambda: None) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(path, body=None): + connection = http.client.HTTPConnection(*server.server_address, timeout=15) + try: + connection.request("GET" if body is None else "POST", path, + None if body is None else json.dumps(body), headers={"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + path = "/api/chat/lark/private-conversations" + body = {"app_ref": "notes-app", "project_ref": runtime.project_contexts.available()[0]["project_ref"], + "executor_endpoint_id": "codex", "audience": "group", "group_ids": ["oc_community"]} + try: + original = transport.bindings.read() + for bad in [{"group_ids": ["oc_not_selected"]}, {"group_ids": []}, + {"group_ids": ["oc_community", "oc_community"]}, {"context_kind": "steward"}, + {"executor_endpoint_id": "claude-code"}]: + assert request(path, {**body, **bad})[0] == 400 + assert transport.bindings.read() == original and not refreshes + status, response = request(path, body) + assert status == 200, response + saved = next(row for row in response["connections"] if row["app_ref"] == "notes-app") + assert saved["audience"] == "group" and saved["group_count"] == 1 + assert saved["agent_candidates"] == saved["agent_targets"] == [] + assert str(ordinary[-1]) not in json.dumps(response) + selected = request("/api/chat/lark/chats?app_ref=notes-app")[1]["chats"] + assert [(row["chat_id"], row["selected"]) for row in selected] == [("oc_community", True), ("oc_second", False)] + assert request(path, body)[1]["revision"] == response["revision"] + assert refreshes == [True, True] + assert not store.list_sessions() and not provider.writes + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + runtime.close() + + def test_native_private_admission_queue_other_app_stop_and_verified_delivery(ordinary): # noqa: F811 store, runtime, provider, transport = connect(ordinary) try: From be96a56518273bc27b2bc00a3764950431b1caad Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:01:41 +0800 Subject: [PATCH 2/4] docs(chat): specify group topic isolation and rollout acceptance Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../app-conversation-and-async-inbox-v0.md | 32 +++++++++++++++++++ ...p-conversation-and-async-inbox-v0.zh-CN.md | 24 ++++++++++++++ 2 files changed, 56 insertions(+) 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 44081a5fad..21a08bfa27 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -73,6 +73,38 @@ Synthetic product previews: [desktop](../../assets/personal-workspace/private-pr [narrow](../../assets/personal-workspace/private-project-conversations-narrow.png), [revoked workspace](../../assets/personal-workspace/private-project-workspace-revoked.png). +## Selected group topics: isolated ordinary project Chat + +Settings → Lark also connects a project App to explicitly selected groups. The +list comes from that App's provider-observed group membership; Core validates +the allowlist at configuration time. This is an audience option on the existing +binding, not a new Goal Channel, listener, queue or model runner. One App retains +one binding owner. Existing private bindings keep their audience and host grants. + +A human starts a topic by mentioning the Bot. Admission reads the exact provider +message and, for a continuation, its original root; event text cannot substitute +the root or mention. Members in that topic share its Session and FIFO; different +topics, groups and Apps have independent Session identities. Status, stop and new +target that topic. Admission feedback, streamed posts, terminal edits and receipt +recovery use the existing delivery path with `source_thread` placement. A revoked +binding or unreadable source prevents later result delivery. + +Group contexts always use `workspace_only` filesystem isolation, even when the +owner's private project uses `host_default`. Only the selected read/write workspace +grant is available. Personal portfolio, attached Agent selection, global skills, +MCP, shell profiles and inherited account environment are unavailable. The native +host must verify the exact permissions profile and workspace root; its independent +store needs separate login and is never seeded from personal credentials/history. +`/status` exposes the project title, not the host's absolute workspace path. + +Core, HTTP, provider-readback and native-protocol fixtures exercise these boundaries. +They are synthetic transport/model evidence, not a live community rollout. The +[community golden queries](../../product/use-cases/community/golden-queries.md) +still require an independently authenticated public workspace and actual pilot +group journeys before either developer group is enabled. Public-source reading +needs its separately qualified scoped tool; this change does not enable unrestricted +network or personal browser access. + ## Ordinary workspace writes: default and revocation checkpoint Ordinary project Chat defaults to `workspace_write` for host-declared roots. The 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 2dc2712c4a..e17b4baeac 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 @@ -53,6 +53,30 @@ extension,typed binding owner 继续保持 provider-neutral。 ![窄屏私聊设置](../../assets/personal-workspace/private-project-conversations-narrow.png) ![工作区撤权读回](../../assets/personal-workspace/private-project-workspace-revoked.png) +## 指定群话题:隔离的普通项目 Chat + +设置 → Lark 可将项目 App 接到明确选定的群。群列表来自当前 App 的 provider 成员 +观测,Core 在配置时核验白名单;这是现有 binding 的受众选项,不新增 Goal Channel、 +listener、队列或模型 runner。一个 App 仍只有一个 binding owner,已有私聊绑定保留 +原受众及宿主权限。 + +成员 @Bot 开始话题。受理时读取 provider 原消息,续聊还读取原话题根;事件文字不能 +替换根消息或提及证据。同一话题的成员共享 Session 与 FIFO,不同话题、群和 App +使用独立 Session。status、stop、new 只作用于当前话题。受理反馈、流式消息、最终 +编辑和回执恢复复用现有投递路径,使用 `source_thread` 落点。解绑或原消息不可读后, +即使执行完成,也不得继续发送结果。 + +群上下文强制使用 `workspace_only`,不受本人私聊 `host_default` 影响。只保留选定 +工作区的读写 grant,不提供个人 portfolio、直连 Agent 选择、全局 skills、MCP、 +shell profile 或继承的账号环境。原生宿主必须确认确切 permissions profile 和工作区 +根;独立存储需另行登录,不能复制个人凭据或历史。`/status` 只显示项目标题,不展示 +宿主绝对工作区路径。 + +Core、HTTP、provider 读回及原生协议 fixture 验证上述边界,属于合成传输/模型证据。 +[社区黄金查询](../../product/use-cases/community/golden-queries.md) 仍需独立登录的公开 +工作区和真实调试群旅程通过后,才能接入两个正式开发群。公开来源读取另需完成其 +限定范围工具的验收;本改动不启用无限制网络或个人浏览器。 + ## 普通工作区读写:默认值与撤权检查点 普通项目 Chat 对宿主声明的工作区默认使用 `workspace_write`。Core context owner From 40ac4fbf9781017f00cab59700e0e1a32b5cfb11 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:19:32 +0800 Subject: [PATCH 3/4] fix(chat): verify group mentions against native Bot identity Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../extensions/lark/conversation_identity.py | 8 +++- .../extensions/lark/private_conversations.py | 5 ++- tests/test_lark_private_conversations.py | 45 ++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/loopx/extensions/lark/conversation_identity.py b/loopx/extensions/lark/conversation_identity.py index 916066ad19..5d3a7ffc89 100644 --- a/loopx/extensions/lark/conversation_identity.py +++ b/loopx/extensions/lark/conversation_identity.py @@ -42,9 +42,15 @@ def observe_lark_conversation_identity(*, profile: str, runner: CommandRunner, owner_id = str(owner.get("openId") or "") if not OPEN_ID_PATTERN.fullmatch(owner_id): raise ValueError("the selected App has no verified owner identity") + bot_open_id = str(bot.get("openId") or "") + if not OPEN_ID_PATTERN.fullmatch(bot_open_id): + bot_open_id = "" provider_ref = identity_ref(app_id) return {"transport_ref": profile, "provider_ref": provider_ref, - "operator_ref": identity_ref(provider_ref, owner_id), "verified": True, "consumer_ref": hashlib.sha256(app_id.encode("utf-8")).hexdigest()[:32], "bot_display_name": str(bot.get("appName") or "")} + "operator_ref": identity_ref(provider_ref, owner_id), "verified": True, + "consumer_ref": hashlib.sha256(app_id.encode("utf-8")).hexdigest()[:32], + "bot_display_name": str(bot.get("appName") or ""), + "bot_app_id": app_id, "bot_open_id": bot_open_id} def lark_private_source(*, provider_ref: str, event: Mapping[str, Any]) -> dict[str, Any]: diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index 3bab04d9b8..afe7b99a77 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -117,7 +117,7 @@ def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] | or any(message.get(key, "") != event.get(key, "") for key in ["parent_id", "thread_id"])): return None if root == event["message_id"]: - original = message + original: Mapping[str, Any] | None = message else: response = call(self.runner, lark_args(cli_bin=self.cli_bin, profile=record["profile"], tail=["im", "+messages-mget", "--message-ids", root, @@ -128,7 +128,10 @@ def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] | if (not original or original.get("chat_id") != event["chat_id"] or original.get("sender", {}).get("sender_type") != "user" or original.get("parent_id") or original.get("root_id") not in (None, "", root) + or not isinstance(original.get("mentions"), list) or not lark_event_mentions_bot(original, bot_display_name=observation["bot_display_name"], + bot_app_id=observation["bot_app_id"], + bot_open_id=observation["bot_open_id"], allow_text_fallback=False)): return None return message diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 991f269203..56b0217df9 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -47,7 +47,7 @@ def topic(self, name, text, *, root=None, chat="oc_community", sender="ou_member sender={"id": sender, "sender_type": "user"}, **{key: event[key] for key in ["root_id", "parent_id", "thread_id"] if key in event}) if not root and addressed: - self.messages[event["message_id"]]["mentions"] = [{"name": "notes-app", "id": {"open_id": "ou_bot"}}] + self.messages[event["message_id"]]["mentions"] = [{"name": "notes-app", "id": {"open_id": "ou_notes_app_bot"}}] return event def __call__(self, args, cwd=None, timeout=None): @@ -59,7 +59,8 @@ def __call__(self, args, cwd=None, timeout=None): profile = args[args.index("--profile") + 1] if "auth" in args: data = {"ok": True, "appId": f"cli_{profile.replace('-', '_')}", "identities": { - "bot": {"available": True, "verified": True, "appName": profile}, + "bot": {"available": True, "verified": True, "appName": profile, + "openId": f"ou_{profile.replace('-', '_')}_bot"}, "user": {"available": True, "verified": True, "openId": f"ou_{profile.replace('-', '_')}"}}} elif "+chat-list" in args: data = {"ok": True, "data": {"chats": [{"chat_id": chat, "name": title} @@ -284,6 +285,46 @@ def test_group_provenance_and_revocation_prevent_private_or_wrong_topic_delivery runtime.close() +@pytest.mark.parametrize("mentions", [ + [{"name": "notes-app", "id": {"open_id": "ou_steward_app_bot"}}], + [{"name": "notes-app", "id": {"app_id": "cli_steward_app"}}], + [{"name": "notes-app"}], + None, +]) +def test_group_root_requires_this_apps_provider_mention_before_capture(ordinary, mentions): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + root = provider.topic("wrong-recipient", "Explain this project") + message = provider.messages[root["message_id"]] + message["mentioned"] = True + if mentions is None: + message.pop("mentions") + else: + message["mentions"] = mentions + assert transport.admit("notes-app", root)["status"] == "source_verification_failed" + assert not store.list_sessions() and not list(transport.root.glob("*.json")) + assert provider.writes == [] + finally: + runtime.close() + + +@pytest.mark.parametrize("identity", [{"open_id": "ou_notes_app_bot"}, {"app_id": "cli_notes_app"}]) +def test_group_provider_mention_survives_a_bot_rename_and_topic_followup(ordinary, identity): # noqa: F811 + _, runtime, provider, transport = connect_group(ordinary) + try: + root = provider.topic("renamed-bot", "Explain this project") + provider.messages[root["message_id"]]["mentions"] = [{"name": "Renamed project assistant", "id": identity}] + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + first = finish_group_turn(runtime, transport, message=root["content"]) + followup = provider.topic("renamed-followup", "Compare its configuration", root=root["message_id"]) + assert transport.admit("notes-app", followup)["status"] == "durably_accepted" + second = finish_group_turn(runtime, transport, message=followup["content"]) + assert second["session_id"] == first["session_id"] + assert all(topic == root["message_id"] for _, topic, _ in provider.topic_writes) + finally: + runtime.close() + + def test_group_status_and_recipient_commands_cannot_expose_private_sessions(ordinary): # noqa: F811 store, runtime, provider, transport = connect_group(ordinary) try: From 5601735a934db42f74632e9987469afa3bb23c11 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:49:05 +0800 Subject: [PATCH 4/4] test(chat): follow shared Lark settings region in steward scope smoke Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/personal-workspace-browser/private-steward-scope.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/personal-workspace-browser/private-steward-scope.mjs b/examples/personal-workspace-browser/private-steward-scope.mjs index 1f66e402e9..f4e50ca9a3 100644 --- a/examples/personal-workspace-browser/private-steward-scope.mjs +++ b/examples/personal-workspace-browser/private-steward-scope.mjs @@ -39,7 +39,7 @@ export const privateStewardScopeScenario = { await page.locator(".personal-settings-tabs").getByRole("button", {name: "Lark", exact: true}).click(); } await settings(); - const panel = page.getByRole("region", {name: "本人飞书私聊"}); + const panel = page.getByRole("region", {name: "飞书项目助手与管家"}); await panel.getByText("LoopX 管家 · 已选范围 · 1 个 Goal", {exact: true}).waitFor(); const upgrade = panel.getByRole("button", {name: "授权全部已注册工作", exact: true}); await upgrade.scrollIntoViewIfNeeded();