From 3bda6dacce91a3a2cd36c3c79defbe4e3f18cbfb Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:50:36 +0800 Subject: [PATCH 1/3] feat(chat): support steward group conversations without mentions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 4 ++ .../src/features/personal-workspace/i18n.tsx | 8 +++ .../personal-workspace/lark-settings-page.tsx | 7 ++- .../rfcs/loopx-overall-roadmap-v0.md | 7 +++ .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 5 ++ .../use-cases/steward/golden-queries.md | 14 +++++ examples/personal-workspace-browser-smoke.mjs | 4 +- .../personal-workspace-browser/fixture.mjs | 3 +- .../steward-group-trigger.mjs | 41 ++++++++++++++ loopx/chat_lark_api.py | 7 +++ loopx/control_plane/collaboration/__init__.py | 11 +++- .../collaboration/conversation_trigger.ts | 24 ++++++++ .../control_plane/effect_runtime_handlers.ts | 2 + .../extensions/lark/docs/lark-event-inbox.md | 20 +++++++ .../extensions/lark/goal_topic_connections.py | 14 +++++ loopx/extensions/lark/manager_routing.py | 21 +++++-- .../conversation_trigger.test.ts | 20 +++++++ .../test_lark_goal_topic_connections.py | 55 +++++++++++++++++++ .../test_lark_goal_topic_runtime.py | 2 +- tests/test_chat_lark_api_contract.py | 2 + 20 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 examples/personal-workspace-browser/steward-group-trigger.mjs create mode 100644 loopx/control_plane/collaboration/conversation_trigger.ts create mode 100644 tests/control_plane_ts/conversation_trigger.test.ts diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 002a15b05b..6ba7832d54 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1942,6 +1942,7 @@ export async function fetchLarkGroupChats(appRef: string, query?: string) { export type LarkGoalConnection = { conversation_kind?: "goal" | "manager"; + turn_trigger?: "addressed" | "human_messages"; agent_id: string | null; connection_id: string; app_label: string; @@ -1973,6 +1974,7 @@ const larkConnectionsSchema = z.object({ ok: z.literal(true), connections: z.array(z.object({ conversation_kind: z.enum(["goal", "manager"]).default("goal"), + turn_trigger: z.enum(["addressed", "human_messages"]).default("addressed"), agent_id: z.string().nullable().default(null), connection_id: z.string(), app_label: z.string(), @@ -2019,6 +2021,7 @@ export async function fetchLarkConnections() { export async function connectLarkGoalTopic(options: { conversationKind?: "goal" | "manager"; + turnTrigger?: "addressed" | "human_messages"; agentBindings?: Array<{ agentId: string; appRef: string }>; agentId?: string; appRef?: string; @@ -2046,6 +2049,7 @@ export async function connectLarkGoalTopic(options: { ...(options.appRef ? { app_ref: options.appRef } : {}), ...(options.connectionId ? { connection_id: options.connectionId } : {}), conversation_kind: options.conversationKind ?? "goal", + ...(options.turnTrigger ? { turn_trigger: options.turnTrigger } : {}), capture_scope: options.captureScope, chat_id: options.chatId, chat_name: options.chatName, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 266255795e..9640b93de7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -572,6 +572,10 @@ const en = { "lark.conversationKind": "Connection purpose", "lark.managerConversation": "Manager · live conversation", "lark.workerConversation": "Worker Agent · background tasks", + "lark.turnTrigger": "When to respond", + "lark.triggerAddressed": "Only when mentioned or replied to", + "lark.triggerHuman": "Respond to group members without @", + "lark.triggerDescription": "Everyone in this connected group can start a conversation. Bot messages and historical backfill stay as context. Requires the Lark app permission to receive all group messages; tool and delegation permissions stay unchanged.", "lark.managerConversationDescription": "The built-in manager responds as you chat and delegates long-running work to background Agents. Group and private conversations keep separate histories.", "lark.ingressLegacy": "Upgrade available", "lark.ingressLegacyDescription": "Save this connection to upgrade to the Agent inbox. Existing messages remain in the same Topic.", @@ -1725,6 +1729,10 @@ const zhCN: Record = { "lark.conversationKind": "连接用途", "lark.managerConversation": "管家 · 同步对话", "lark.workerConversation": "工作 Agent · 后台任务", + "lark.turnTrigger": "何时回应", + "lark.triggerAddressed": "仅 @ 或回复管家时", + "lark.triggerHuman": "群成员直接发消息,无需 @", + "lark.triggerDescription": "此群所有成员都可以发起对话;机器人消息和补拉历史只作背景。需要飞书应用具备接收群内所有消息的权限;工具权限和委派授权保持不变。", "lark.managerConversationDescription": "内置管家即时回应对话,长任务交给后台 Agent。群聊与私聊分别保存上下文。", "lark.ingressLegacy": "待升级", "lark.ingressLegacyDescription": "保存连接即可升级为 Agent 异步收件箱,已有消息保留在原 Topic 中。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx index 12f1c1bcef..a461ffb1d3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx @@ -201,6 +201,7 @@ export function LarkSettingsPage({ const [chatId, setChatId] = useState(""); const [chatLoading, setChatLoading] = useState(false); const [chatLoadError, setChatLoadError] = useState(null); + const [turnTrigger, setTurnTrigger] = useState<"addressed" | "human_messages">("addressed"); const [captureScope, setCaptureScope] = useState("addressed_only"); const [conversationKind, setConversationKind] = useState<"goal" | "manager">(focusGoalConnection && initialGoalId ? "goal" : "manager"); const [ingressMode, setIngressMode] = useState("async_inbox"); @@ -375,6 +376,7 @@ export function LarkSettingsPage({ setConnectAllAgents(false); setAgentAppRefs({}); setCaptureScope("addressed_only"); + setTurnTrigger("addressed"); setIngressMode("async_inbox"); setReplyMode("topic_reply"); setChatQuery(""); @@ -394,6 +396,7 @@ export function LarkSettingsPage({ setConnectAllAgents(false); setAgentAppRefs({}); setCaptureScope(connection.capture_scope); + setTurnTrigger(connection.turn_trigger ?? "addressed"); setIngressMode(connection.ingress_mode === "direct_session" ? "async_inbox" : connection.ingress_mode); setReplyMode(connection.reply_mode); setChatQuery(connection.chat_name); @@ -457,6 +460,7 @@ export function LarkSettingsPage({ chatName: selectedChat!.chat_name, }), conversationKind, + ...(conversationKind === "manager" ? { turnTrigger } : {}), captureScope: conversationKind === "manager" ? "addressed_only" as const : captureScope, goalId, incomingMode: captureScope === "configured_chat_all" ? "all" as const : "mentions" as const, @@ -554,7 +558,7 @@ export function LarkSettingsPage({ ) : null} {connection.goal_title}# {connection.topic_name} - {connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")} + {connection.conversation_kind === "manager" ? t(connection.turn_trigger === "human_messages" ? "lark.triggerHuman" : "lark.triggerAddressed") : connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")} {connection.conversation_kind === "manager" ? t("lark.managerConversation") : ingressPresentation(connection.ingress_mode, t).label}{connection.conversation_kind === "manager" ? t("lark.managerConversationDescription") : connection.agent_id ?? ingressPresentation(connection.ingress_mode, t).detail} @@ -593,6 +597,7 @@ export function LarkSettingsPage({ } + {conversationKind === "manager" ? : null} {conversationKind === "goal" ? <>
{t("lark.agentIngress")}
{(["live_steering", "session_queue", "async_inbox"] as const).map((mode) => { const presentation = ingressPresentation(mode, t); return ; })}
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index fa81e287bc..a05a0a5ba2 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -620,6 +620,13 @@ callback; neither may appear as work completed or a second user-visible reply. This uses the R3 collaboration owner and current managed Turn wake instead of a separate steward scheduler. +R3 entry qualification also covers ordinary human messages in an explicitly +configured steward group without a mention. The shared TS admission rule owns +trigger semantics; Lark supplies provider identity and the existing inbox owns +deduplication and return. App settings select and read back the trigger per +connection. External host-tool permission and sender-bound delegation remain +separate gaps; receiving a request does not establish execution authority. + ### R4: Shared Goal Alignment and Evolution - **Owner:** alignment RFC Stage 3–5 and TS Goal/work-graph owners. 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 9a5af93e0d..4fcf39177f 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -468,6 +468,11 @@ R3 还需验证 handoff RFC 的分层 A6/A8/A13 扩展:普通 managed worker fixture 在未决工具、取消、迟到结果下区分 inbox 收件、后续 queue 工作和已采用 steer;传输成功不关闭请求。复用现有 R2/R3 后继,不另开平行团队编排项目。 +R3 入口验收也覆盖显式配置的管家群内无需 @ 的普通用户消息。触发语义由共享 +TS 对话准入规则负责;Lark 提供消息身份,现有收件箱负责去重和回传。App 设置 +按连接选择并回读触发方式。外部渠道的工具权限、发信人绑定的委派授权仍需单独 +验收;收到请求不代表已经具备执行授权。 + ### R4:共享目标对齐与演化 - **Owner:** alignment RFC Stage 3–5;TS Goal/work-graph owner。 diff --git a/docs/product/use-cases/steward/golden-queries.md b/docs/product/use-cases/steward/golden-queries.md index a61b7f5342..49999b9b2c 100644 --- a/docs/product/use-cases/steward/golden-queries.md +++ b/docs/product/use-cases/steward/golden-queries.md @@ -57,6 +57,20 @@ must finish before work on the next layer can start. GQ17 repeats the ordinary parallel-work intent at larger fixture sizes; basic parallel work is already P0 in GQ11, and the steward should not overstaff a small task. +### Direct group conversation / 群里直接说话 + +For the Lark variant of P0 reliable entry, configure the steward connection to +respond without mentions. Send “帮我总结一下当前进展。” / “Summarize the current +progress” as an ordinary new human message, then a concise correction. Require +one retained request and one checked answer through the existing conversation; +replay the same provider message and prove there is no second execution/reply. +Disable the option and verify that another unaddressed message remains context +while a mention or verified bot reply still works. Bots, missing sender evidence, +historical backfill (even with an old mention), an unrelated group and a worker's +own Topic are negative cases. This admission probe does not qualify autonomous +execution: the external read-only profile and recipient grants must be evaluated +separately. Passing transport fixtures is not evidence of a deployed group run. + ### App-first execution profiles and ordinary questions Qualify the installed App first; Lark is independently scored, not required to diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index dc9565071b..c17ca1b8d5 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -39,7 +39,9 @@ import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.m import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs"; import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs"; -const scenarioCatalog = [conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; +import {stewardGroupTriggerScenario} from "./personal-workspace-browser/steward-group-trigger.mjs"; + +const scenarioCatalog = [stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 2aac6aecc3..67e9e2cabe 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1326,7 +1326,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const existing = runtime.larkConnections.find((item) => item.connection_id === body.connection_id && item.goal_id === body.goal_id); if (!existing || body.app_ref || body.chat_id || body.agent_bindings) throw new Error("Editing must select the stored connection without replacing its identity"); if (body.execute) { - Object.assign(existing, { agent_id: body.agent_id, ingress_mode: body.ingress_mode, capture_scope: body.capture_scope }); + Object.assign(existing, { turn_trigger: body.turn_trigger ?? existing.turn_trigger, agent_id: body.agent_id, ingress_mode: body.ingress_mode, capture_scope: body.capture_scope }); state.larkWrites.push({ ...body }); } await route.fulfill({ contentType: "application/json", json: { ok: true, status: body.execute ? "connected" : "preview_ready" }, status: 200 }); @@ -1344,6 +1344,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true runtime.larkConnections.push({ agent_id: body.conversation_kind === "manager" ? "loopx-manager" : binding.agent_id ?? null, conversation_kind: body.conversation_kind ?? "goal", + turn_trigger: body.turn_trigger ?? "addressed", connection_id: connectionId, app_label: binding.app_ref === "mew-research" ? "LoopX Research" : "LoopX Mew", app_ref: binding.app_ref, chat_name: body.chat_name, enabled: true, capture_scope: body.capture_scope, diff --git a/examples/personal-workspace-browser/steward-group-trigger.mjs b/examples/personal-workspace-browser/steward-group-trigger.mjs new file mode 100644 index 0000000000..c7b8b12ced --- /dev/null +++ b/examples/personal-workspace-browser/steward-group-trigger.mjs @@ -0,0 +1,41 @@ +import {resolve} from "node:path"; +import {outputDir} from "./fixture.mjs"; +import {openWorkspacePage} from "./scenario-context.mjs"; + +export const stewardGroupTriggerScenario = { + id: "steward-group-trigger", + async run({browser, collectCoverage, url}) { + const context = await openWorkspacePage(browser, url, {collectCoverage}); + const {page, api} = context; + try { + await page.getByRole("button", {name: "设置", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "Lark", exact: true}).click(); + await page.getByRole("button", {name: "连接 Lark App", exact: true}).click(); + const dialog = page.getByRole("dialog", {name: "连接 Lark App"}); + await dialog.getByRole("option", {name: "Product group"}).waitFor({state: "attached"}); + await dialog.getByLabel("群聊").selectOption({label: "Product group"}); + const trigger = dialog.getByLabel("何时回应"); + if (await trigger.inputValue() !== "addressed") throw new Error("Legacy mention default changed"); + await trigger.selectOption("human_messages"); + await page.screenshot({path: resolve(outputDir, "steward-group-trigger.png"), animations: "disabled"}); + await dialog.getByRole("button", {name: "连接", exact: true}).click(); + await dialog.waitFor({state: "hidden"}); + const row = page.locator(".personal-lark-table-row", {hasText: "Product group"}); + await row.getByText("群成员直接发消息,无需 @", {exact: true}).waitFor(); + if (api.larkWrites.length !== 1 || api.larkWrites[0].turn_trigger !== "human_messages") throw new Error("Trigger not saved"); + await row.getByRole("button", {name: /配置/}).click(); + const editor = page.getByRole("dialog"); + if (await editor.getByLabel("何时回应").inputValue() !== "human_messages") throw new Error("Trigger not read back"); + await page.setViewportSize({width: 390, height: 844}); + await editor.getByLabel("何时回应").scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "steward-group-trigger-mobile.png"), animations: "disabled"}); + await editor.getByLabel("何时回应").selectOption("addressed"); + await editor.getByRole("button", {name: "保存连接", exact: true}).click(); + await editor.waitFor({state: "hidden"}); + await row.getByText("仅 @ 或回复管家时", {exact: true}).waitFor(); + if (api.larkWrites[1]?.turn_trigger !== "addressed") throw new Error("Trigger not restored"); + if (context.errors.length) throw new Error(context.errors.join(" | ")); + return {coverageEntries: await context.close(), note: "Steward group trigger opt-in, persisted readback and revocation; desktop and narrow screen"}; + } catch (error) { await context.close(); throw error; } + }, +}; diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index c489392131..caca356ed5 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -567,6 +567,7 @@ def _lark_connect(self) -> None: "capture_scope", "connection_id", "conversation_kind", + "turn_trigger", "executor_endpoint_id", "chat_id", "chat_name", @@ -623,6 +624,11 @@ def _lark_connect(self) -> None: or stored_routing.get("conversation_kind") or "goal" ) + if "turn_trigger" in body: + from .control_plane.collaboration import conversation_trigger + if conversation_kind != "manager" or app_refs_by_agent is not None: + raise ValueError("turn_trigger is only supported for manager connections") + conversation_trigger(body["turn_trigger"]) # The machine owns its manager channel's executor, so the machine # setting -- not a stored connection field or a request field -- # decides which endpoint this connection runs on and which Session @@ -743,6 +749,7 @@ def _lark_connect(self) -> None: agent_id=agent_id, connection_id=connection_id, conversation_kind=conversation_kind, + turn_trigger=body.get("turn_trigger"), executor_endpoint_id=executor_endpoint_id if conversation_kind == "manager" else None, diff --git a/loopx/control_plane/collaboration/__init__.py b/loopx/control_plane/collaboration/__init__.py index a7d14f6f7c..e738ae3b16 100644 --- a/loopx/control_plane/collaboration/__init__.py +++ b/loopx/control_plane/collaboration/__init__.py @@ -4,7 +4,16 @@ from typing import Any -from ..effect_runtime import effect_runtime_result +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + + +def conversation_trigger(mode=None, **evidence) -> dict[str, Any]: + try: + return effect_runtime_result("collaboration.conversation.trigger", { + "mode": mode, **evidence, + }) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc def conversation_scope(session: dict[str, Any], *, origin: str | None = None) -> dict[str, Any]: diff --git a/loopx/control_plane/collaboration/conversation_trigger.ts b/loopx/control_plane/collaboration/conversation_trigger.ts new file mode 100644 index 0000000000..b23e4b3588 --- /dev/null +++ b/loopx/control_plane/collaboration/conversation_trigger.ts @@ -0,0 +1,24 @@ +/** Admission from provider-verified addressing evidence. Capture alone never + * grants a turn; this does not grant tools, delegation or protected operations. */ +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; + +export function resolveConversationTrigger(input: JsonObject): JsonObject { + const mode = input.mode ?? "addressed"; + if (mode !== "addressed" && mode !== "human_messages") { + throw new EffectRuntimeRequestError("conversation trigger must be addressed or human_messages"); + } + let reason = "not_addressed"; + let authorized = false; + if (input.historical === true) reason = "historical_context_only"; + else if (input.self_message === true) reason = "self_message"; + else if (mode === "human_messages" && input.bot_message === true) reason = "bot_message"; + else if (input.addressed === true) { + authorized = true; + reason = "addressed"; + } else if (mode === "human_messages" && input.human === true) { + authorized = true; + reason = "configured_human_message"; + } + return {mode, authorized, reason}; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8150dd2c85..861889f018 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -14,6 +14,7 @@ import {projectTodoSuccession} from "./todos/succession.ts"; import {projectLegacyTodoWorkCounts} from "./todos/summary_lanes.ts"; import {sealProjectionEnvelope} from "./projection_envelope.ts"; import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "./collaboration/delegation.ts"; +import {resolveConversationTrigger} from "./collaboration/conversation_trigger.ts"; import {planChatMode} from "./collaboration/chat_mode.ts"; import {resolveConversationScope} from "./collaboration/conversation_scope.ts"; import {previewTeamPlan, planTeamTransaction, teamTransactionIdentity} from "./work_items/team_plan.ts"; @@ -719,6 +720,7 @@ export function createEffectRuntimeHandlers( ["collaboration.delegation.inventory_query", delegationInventoryQuery], ["collaboration.delegation.inventory_item", delegationInventoryItem], ["collaboration.chat_mode", planChatMode], + ["collaboration.conversation.trigger", resolveConversationTrigger], ["collaboration.conversation.scope", resolveConversationScope], ["collaboration.delegation.observe", transitionDelegationObservation], ["collaboration.delegation.recover_validated_settlement", recoverValidatedDelegationSettlement], diff --git a/loopx/extensions/lark/docs/lark-event-inbox.md b/loopx/extensions/lark/docs/lark-event-inbox.md index 90be2964a4..17b9314466 100644 --- a/loopx/extensions/lark/docs/lark-event-inbox.md +++ b/loopx/extensions/lark/docs/lark-event-inbox.md @@ -528,6 +528,26 @@ loopx lark-inbox material-review \ --execute ``` +### Steward groups without mentions + +In App **Settings → Lark → edit the steward connection → When to respond**, +select **Respond to group members without @** to admit new human messages in +that one connected group. All group members can start a conversation in this +mode. The Lark application must have permission to receive all group messages; +a local switch cannot grant that provider permission. Save the connection and +reopen it to verify the setting; select **Only when mentioned or replied to** +to disable it. Existing connections default to that addressed-only mode. + +The persisted connection field is `routing.turn_trigger` (`addressed` or +`human_messages`), exposed by the existing connection API. Capture scope remains +independent. The shared TypeScript conversation admission rule consumes verified +provider sender/addressing evidence; it does not inspect message keywords. +Historical backfill and bot messages do not start turns in the no-mention mode; +unknown senders without addressing stay context-only. Existing worker Topics +keep route priority. The same durable inbox/effect/reply path prevents duplicate +processing. Reception does not grant host tools, evidence access, delegation or +protected operations; the external-audience runtime profile is unchanged. + Urgency classification stays local. Under `configured_chat_all`, provider-native mention evidence is normalized into a compact `addressed_to_bot` flag before the event is persisted. Only that typed flag or a provider-verified direct reply can diff --git a/loopx/extensions/lark/goal_topic_connections.py b/loopx/extensions/lark/goal_topic_connections.py index 95c9b6e305..57b7b7b183 100644 --- a/loopx/extensions/lark/goal_topic_connections.py +++ b/loopx/extensions/lark/goal_topic_connections.py @@ -369,6 +369,7 @@ def connect_lark_goal_topic( capture_scope: str | None = None, ingress_mode: str | None = None, conversation_kind: str | None = None, + turn_trigger: str | None = None, executor_endpoint_id: str | None = None, runtime_root: str | Path | None = None, reply_mode: str = "topic_reply", @@ -406,6 +407,15 @@ def connect_lark_goal_topic( ingress_mode=ingress_mode, runtime_root=runtime_root, ) + from ...control_plane.collaboration import conversation_trigger + if turn_trigger is not None and conversation_kind != "manager": + raise ValueError("turn_trigger is only supported for manager connections") + effective_turn_trigger = ( + conversation_trigger( + turn_trigger if turn_trigger is not None + else (editing or {}).get("routing", {}).get("turn_trigger") + )["mode"] if conversation_kind == "manager" else "addressed" + ) if conversation_kind == "manager": agent_id = MANAGER_AGENT_GOAL_ID normalized_agent_id = normalize_todo_claimed_by(agent_id) @@ -548,6 +558,7 @@ def connect_lark_goal_topic( ), limit=120, ), + **({"turn_trigger": effective_turn_trigger} if conversation_kind == "manager" else {}), "incoming_mode": effective_incoming_mode, "capture_scope": effective_capture_scope, "ingress_mode": ingress_mode, @@ -834,6 +845,7 @@ def connect_lark_goal_topic( "created_automatically": True, }, "routing": { + **({"turn_trigger": effective_turn_trigger} if conversation_kind == "manager" else {}), "incoming_mode": effective_incoming_mode, "capture_scope": effective_capture_scope, "ingress_mode": ingress_mode, @@ -883,6 +895,7 @@ def connect_lark_goal_topic( "chat_name": public_safe_compact_text(chat_name, limit=60), "target_ref": target_name, "topic_name": topic_name, + **({"turn_trigger": effective_turn_trigger} if conversation_kind == "manager" else {}), "incoming_mode": effective_incoming_mode, "capture_scope": effective_capture_scope, "ingress_mode": ingress_mode, @@ -1112,6 +1125,7 @@ def list_lark_connections( routing.get("conversation_kind") or "goal" ), "session_bound": bool(binding.get("session_id")), + "turn_trigger": routing.get("turn_trigger", "addressed"), "incoming_mode": str(routing.get("incoming_mode") or "mentions"), "capture_scope": capture_scope, "ingress_mode": ingress_mode, diff --git a/loopx/extensions/lark/manager_routing.py b/loopx/extensions/lark/manager_routing.py index 5e3743ce81..fc4a91b9e1 100644 --- a/loopx/extensions/lark/manager_routing.py +++ b/loopx/extensions/lark/manager_routing.py @@ -7,6 +7,7 @@ from typing import Any from pathlib import Path +from ...control_plane.collaboration import conversation_trigger from ...chat_manager import ( manager_channel, manager_connection_executor_endpoint, @@ -166,7 +167,17 @@ def ignored(reason: str) -> dict[str, Any]: if not _valid_manager_binding(goal_id, binding, routing): return ignored("invalid_routing_state") profile = str(identity.get("sender_profile") or "default") - turn_authorized = is_event_addressed_to_bot(event, identity) + try: + trigger = conversation_trigger( + routing.get("turn_trigger"), + addressed=is_event_addressed_to_bot(event, identity), + bot_message=event.get("sender_type") == "app", + human=event.get("sender_type") == "user" and bool(event.get("sender_id")), + historical=event.get("historical_context_only") is True, + ) + except ValueError: + return ignored("invalid_routing_state") + turn_authorized = trigger["authorized"] executor_endpoint_id, executor_endpoint_source = ( manager_connection_executor_endpoint(runtime_root) ) @@ -189,10 +200,10 @@ def ignored(reason: str) -> dict[str, Any]: "message_id": message_id, "event_id": str(event.get("event_id") or message_id), "topic_root_message_id": topic_root, - # Capture and authority are intentionally separate. The unique - # configured manager chat may retain non-self messages as bounded - # context, but only a provider-native mention or verified reply - # may enqueue a manager Turn. + # Provider evidence is adapted to the shared conversation rule. + # Capture is not admission; the explicit trigger selects live input. + "turn_trigger": trigger["mode"], + "trigger_reason": trigger["reason"], "capture_scope": "configured_chat_all", "authority_mode": ( ManagerAuthorityMode.TURN_AUTHORIZED.value diff --git a/tests/control_plane_ts/conversation_trigger.test.ts b/tests/control_plane_ts/conversation_trigger.test.ts new file mode 100644 index 0000000000..417635b3ad --- /dev/null +++ b/tests/control_plane_ts/conversation_trigger.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {resolveConversationTrigger as trigger} from "../../loopx/control_plane/collaboration/conversation_trigger.ts"; + +test("capture or human identity alone does not grant a default turn", () => { + assert.equal(trigger({human: true}).authorized, false); + assert.equal(trigger({mode: "addressed", human: true}).authorized, false); + assert.equal(trigger({addressed: true}).authorized, true); +}); +test("explicit human-message admission preserves origin and replay boundaries", () => { + assert.equal(trigger({mode: "human_messages", human: true}).authorized, true); + for (const evidence of [{}, {human: false}, {human: true, historical: true}, + {addressed: true, historical: true}, {human: true, self_message: true}, + {addressed: true, bot_message: true}]) { + assert.equal(trigger({mode: "human_messages", ...evidence}).authorized, false); + } + for (const mode of ["all", "", false, {}, []]) { + assert.throws(() => trigger({mode}), /conversation trigger/); + } +}); diff --git a/tests/extensions/test_lark_goal_topic_connections.py b/tests/extensions/test_lark_goal_topic_connections.py index c77a31c4c2..233119d0ae 100644 --- a/tests/extensions/test_lark_goal_topic_connections.py +++ b/tests/extensions/test_lark_goal_topic_connections.py @@ -3210,3 +3210,58 @@ def test_an_authorized_manager_session_must_run_on_the_machine_executor( assert authorized_manager_goal_ids( snapshot, {**bound, "agent_id": "dsh"}, runtime_root=runtime_root ) == ["goal-alpha"] + + +def test_manager_direct_group_messages_roundtrip_and_revoke(tmp_path, monkeypatch): + """The real route/inbox pipeline admits live human input once after opt-in.""" + from datetime import UTC, datetime + from loopx.extensions.lark import goal_topic_runtime as runtime + from test_lark_goal_topic_runtime import _reply_runner + + kwargs, _, bindings = _manager_fixture(tmp_path) + binding = binding_for_goal(bindings, "goal-alpha") + edit = {**kwargs, "agent_id": "loopx-manager", "connection_id": binding["connection_id"], + "session_id": "manager-session", "conversation_kind": "manager"} + before = kwargs["binding_path"].read_bytes() + preview = connect_lark_goal_topic(**{**edit, "execute": False}, turn_trigger="human_messages") + assert preview["details"]["turn_trigger"] == "human_messages" + assert kwargs["binding_path"].read_bytes() == before + assert connect_lark_goal_topic(**edit, turn_trigger="human_messages")["ok"] + # Older clients editing another field must preserve the explicit choice. + assert connect_lark_goal_topic(**edit)["details"]["turn_trigger"] == "human_messages" + def options(): + return {"target_payload": read_goal_channel_targets(kwargs["target_path"]), + "binding_payloads": {"goal-alpha": read_goal_channel_binding(kwargs["binding_path"])}} + event = {"chat_id": CHAT_ID, "message_id": "om_direct_request", "event_id": "evt_direct_request", + "sender_type": "user", "sender_id": "ou_human", "mentions": [], + "create_time": datetime.now(UTC).isoformat(), "content": "帮我总结一下当前进展。"} + decision = decide_lark_topic_event(**options(), event=event) + assert decision["route"]["authority_mode"] == "turn_authorized" + assert decision["route"]["trigger_reason"] == "configured_human_message" + for patch in [{"sender_type": "app"}, {"sender_type": ""}, {"sender_id": ""}, + {"historical_context_only": True}, + {"historical_context_only": True, "mentions": [{"id": APP_ID}]}]: + assert decide_lark_topic_event(**options(), event={**event, **patch})["route"]["authority_mode"] == "context_only" + monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", lambda **_: {"ok": True}) + answers, replies = [], {} + def answer(route, text): + answers.append(text) + return {"response_text": "已整理当前进展。", "effect_receipt": runtime._session_turn_effect(route)} + # Transport doubles do not replace the TS rule, JSON inbox, locks or receipts. + def reply_runner(args): + result = _reply_runner(replies)(args) + result["stdout"] = result["stdout"].replace("linkmacbot", "LoopX Mew") + return result + result = runtime.process_lark_goal_topic_event(**options(), event=event, + runtime_root=tmp_path / "runtime", answer=answer, reply_runner=reply_runner) + assert result["status"] == "replied_and_acknowledged", json.dumps(result, ensure_ascii=False, indent=2) + replay = runtime.process_lark_goal_topic_event(**options(), event=event, + runtime_root=tmp_path / "runtime", answer=answer, reply_runner=reply_runner) + assert replay["status"] == "already_acknowledged", replay + assert answers == [event["content"]] + assert replies["reply_text"] == "已整理当前进展。" + assert connect_lark_goal_topic(**edit, turn_trigger="addressed")["ok"] + assert decide_lark_topic_event(**options(), event={**event, "message_id": "om_after_revoke"})["route"]["authority_mode"] == "context_only" + assert decide_lark_topic_event(**options(), event={**event, "mentions": [{"id": APP_ID}]})["route"]["authority_mode"] == "turn_authorized" + with pytest.raises(ValueError, match="conversation trigger"): + connect_lark_goal_topic(**edit, turn_trigger="all") diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index f4ec57d3a9..d8361eb932 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -164,7 +164,7 @@ def run(args: list[str]) -> dict[str, Any]: } elif args[3:6] == ["im", "chats", "get"]: payload = {"data": {"chat_id": "oc_public_fixture"}} - elif "+messages-reply" in args: + elif "+messages-reply" in args or "+messages-send" in args: if "--content" in args: state["reply_content"] = args[args.index("--content") + 1] state["reply_type"] = "post" diff --git a/tests/test_chat_lark_api_contract.py b/tests/test_chat_lark_api_contract.py index 7109d6fc83..5b1e6f0756 100644 --- a/tests/test_chat_lark_api_contract.py +++ b/tests/test_chat_lark_api_contract.py @@ -801,6 +801,7 @@ def _read_json(self): "chat_id": "oc_public_fixture", "chat_name": "Product", "conversation_kind": "manager", + "turn_trigger": "human_messages", "execute": execute, } @@ -823,6 +824,7 @@ def _send_error(self, message, **kwargs): Handler()._lark_connect() assert calls[0]["conversation_kind"] == "manager" + assert calls[0]["turn_trigger"] == "human_messages" assert calls[0]["ingress_mode"] == "session_queue" assert calls[0]["session_id"] == ("manager-session" if execute else None) assert len(opened) == int(execute) From 4495506d34500b0c93480d714f82a7cde5779611 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:13:57 +0800 Subject: [PATCH 2/3] refactor(lark): type conversation trigger and narrow routing readback Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/collaboration/__init__.py | 8 ++++---- loopx/extensions/lark/goal_topic_connections.py | 14 ++++---------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/loopx/control_plane/collaboration/__init__.py b/loopx/control_plane/collaboration/__init__.py index e738ae3b16..2db920135b 100644 --- a/loopx/control_plane/collaboration/__init__.py +++ b/loopx/control_plane/collaboration/__init__.py @@ -2,16 +2,16 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result -def conversation_trigger(mode=None, **evidence) -> dict[str, Any]: +def conversation_trigger(mode: str | None = None, **evidence: bool) -> dict[str, Any]: try: - return effect_runtime_result("collaboration.conversation.trigger", { + return cast(dict[str, Any], effect_runtime_result("collaboration.conversation.trigger", { "mode": mode, **evidence, - }) + })) except EffectRuntimeRejected as exc: raise ValueError(str(exc)) from exc diff --git a/loopx/extensions/lark/goal_topic_connections.py b/loopx/extensions/lark/goal_topic_connections.py index 57b7b7b183..a88b77d400 100644 --- a/loopx/extensions/lark/goal_topic_connections.py +++ b/loopx/extensions/lark/goal_topic_connections.py @@ -1071,11 +1071,8 @@ def list_lark_connections( if isinstance(binding.get("topic"), Mapping) else {} ) - routing = ( - binding.get("routing") - if isinstance(binding.get("routing"), Mapping) - else {} - ) + raw_routing = binding.get("routing") + routing = raw_routing if isinstance(raw_routing, Mapping) else {} connector_status: dict[str, Any] | None = None try: capture_scope, ingress_mode, reply_mode = _connection_routing_modes(routing) @@ -1232,11 +1229,8 @@ def decide_lark_topic_event( or binding_channel.get("pinned_message_id") or "" ) - routing = ( - binding.get("routing") - if isinstance(binding.get("routing"), Mapping) - else {} - ) + raw_routing = binding.get("routing") + routing = raw_routing if isinstance(raw_routing, Mapping) else {} try: capture_scope, ingress_mode, reply_mode = _connection_routing_modes(routing) connector = binding.get("connector") From ca8923be8105b3ac40ec1d4ad07b4d4f4812b415 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:13:57 +0800 Subject: [PATCH 3/3] chore(semantics): reconcile registry census locations after main integration Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../semantics/project_registry_io_manifest_v1.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 69f74e3948..34539c92a7 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -927,7 +927,7 @@ }, { "site": "loopx/control_plane/coordination/local_authority_shadow_adapter.py::._drain_prelude::codec_read:load_registry#1", - "line": 607, + "line": 220, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -935,7 +935,7 @@ }, { "site": "loopx/control_plane/coordination/local_authority_shadow_adapter.py::.effective_runtime_root::codec_read:load_registry#1", - "line": 59, + "line": 50, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -943,7 +943,7 @@ }, { "site": "loopx/control_plane/coordination/local_authority_shadow_adapter.py::.local_authority_shadow_status::codec_read:load_registry#1", - "line": 775, + "line": 306, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1599,15 +1599,15 @@ }, { "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.goal_channel_gate_sync_failure::codec_read:load_registry#1", - "line": 80, - "column": 28, + "line": 209, + "column": 32, "kind": "codec_read", "api": "load_registry", "classification": "codec_api" }, { "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.sync_human_gate_after_refresh::codec_read:load_registry#1", - "line": 118, + "line": 257, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -1615,7 +1615,7 @@ }, { "site": "loopx/extensions/lark/goal_channel_lifecycle.py::.sync_human_gate_after_refresh::codec_read:load_registry#2", - "line": 129, + "line": 268, "column": 14, "kind": "codec_read", "api": "load_registry",