From f8287cd14f152b448d63071ac781a842cc4c2102 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 13 Nov 2026 16:31:31 +0800 Subject: [PATCH] fix(chat): honor the configured steward across default entrypoints Signed-off-by: huangruiteng --- .../dashboard/smoke/app-steward-smoke.ts | 23 +++++++++++++++++- .../smoke/bound-conversation-http-fixture.py | 8 +++++++ .../personal-workspace/app-steward-entry.tsx | 24 ++++++++++++------- .../personal-workspace-model.ts | 2 +- .../personal-workspace-page.tsx | 3 ++- .../dashboard/src/views/dashboard-page.tsx | 2 +- .../app-conversation-and-async-inbox-v0.md | 7 ++++++ ...p-conversation-and-async-inbox-v0.zh-CN.md | 4 ++++ loopx/chat/session_open.py | 19 +++++++++++++++ .../extensions/lark/conversation_identity.py | 11 +++++++-- .../lark/private_conversation_api.py | 4 ++++ tests/test_lark_private_conversations.py | 3 ++- 12 files changed, 95 insertions(+), 15 deletions(-) diff --git a/apps/presentation/dashboard/smoke/app-steward-smoke.ts b/apps/presentation/dashboard/smoke/app-steward-smoke.ts index fe2e48c9bd..91e9a07821 100644 --- a/apps/presentation/dashboard/smoke/app-steward-smoke.ts +++ b/apps/presentation/dashboard/smoke/app-steward-smoke.ts @@ -4,7 +4,7 @@ import {once} from "node:events"; import {resolve} from "node:path"; import {createInterface} from "node:readline"; import {resolveTestPython} from "../../../../scripts/test-python.mjs"; -import {fetchAppStewardTarget, fetchPrivateConversations, selectAppSteward, +import {ChatApiError, fetchAppStewardTarget, fetchPrivateConversations, selectAppSteward, fetchChatHistory, resolveBoundChatSession} from "../src/data/chat"; const repoRoot = resolve(process.cwd(), "../../.."); @@ -31,6 +31,12 @@ try { globalThis.fetch = (input, init) => originalFetch(new URL(String(input), origin), init); let connections = await fetchPrivateConversations(); const inspect = async () => {child.stdin.write("inspect\n"); return next();}; + const openDefault = async (overrides = {}) => { + const response = await fetch("/api/chat/sessions", {method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({context_kind: "manager", ...overrides})}); + return {status: response.status, body: await response.json()}; + }; const before = await inspect(); assert.equal(before.sessions, 3); assert.equal(await fetchAppStewardTarget(), null, "No implicit selection between two original stewards"); @@ -39,6 +45,12 @@ try { connections = await selectAppSteward(bindingId, connections.revision); const target = {bindingId, sessionId}; assert.deepEqual(await fetchAppStewardTarget(), target); + const opened = await openDefault(); + assert.equal(opened.status, 200); + assert.equal(opened.body.session_id, sessionId, "Default API entry must reuse the configured private conversation"); + for (const overrides of [{mode: "new"}, {agent_model_id: "different-model"}]) { + assert.equal((await openDefault(overrides)).status, 400, "Explicit overrides cannot silently fork the selected source"); + } await assert.rejects(() => selectAppSteward(peer.binding_id, connections.revision - 1), /selection could not/); assert.deepEqual(await fetchAppStewardTarget(), target, "Stale selection cannot retarget the App"); const history = await fetchChatHistory({bound: target}); @@ -53,8 +65,14 @@ try { child.stdin.write("deny\n"); await next(); await assert.rejects(() => fetchAppStewardTarget(), /selected original steward is unavailable/); await assert.rejects(() => selectAppSteward(bindingId, connections.revision), /selection could not/); + assert.equal((await openDefault()).status, 400, "Failed identity verification must not fall back to a local manager"); + child.stdin.write("owner-login-missing\n"); await next(); + await assert.rejects(() => fetchAppStewardTarget(), error => error instanceof ChatApiError + && error.payload.error_code === "lark_owner_identity_unavailable"); + assert.equal((await openDefault()).status, 400); child.stdin.write("recover\n"); await next(); assert.deepEqual(await fetchAppStewardTarget(), target); + assert.equal((await openDefault()).body.session_id, sessionId, "Login recovery returns to the same original Session"); const after = await inspect(); assert.equal(after.sessions, before.sessions); assert.deepEqual(after.messages, before.messages); @@ -76,6 +94,9 @@ try { assert.equal((await fetchPrivateConversations()).connections[0].binding_id, peer.binding_id); connections = await selectAppSteward(null, connections.revision); assert.equal(await fetchAppStewardTarget(), null, "Only an explicit clear restores the ordinary entry"); + const local = await openDefault(); + assert.equal(local.status, 201); + assert.equal(local.body.session.channel_id, "manager", "Cleared selection preserves the existing local manager path"); const routedFetch = globalThis.fetch; for (const malformed of [{ok: true}, {ok: true, binding_id: null, session_id: sessionId}, {ok: true, binding_id: bindingId, session_id: "../other"}]) { diff --git a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py index 8bbd6259c4..862b468eaf 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py +++ b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py @@ -30,6 +30,7 @@ class SyntheticPrivateSource: def __init__(self, root, *, steward=False, peer=False): self.root, self.verified = root, True + self.owner_login_missing = False self.steward, self.peer = steward, peer self.peer_provider = None default_compact_reply = "0" if os.environ.get("LOOPX_REPLY_BEFORE") == "1" else "1" @@ -38,6 +39,9 @@ def __init__(self, root, *, steward=False, peer=False): self.transport_ref = "synthetic-peer" def observe(self): + if self.owner_login_missing: + from loopx.extensions.lark.conversation_identity import LarkOwnerIdentityUnavailable + raise LarkOwnerIdentityUnavailable() provider = ("e" if self.peer else "c") * 24 operator = ("f" if self.peer else "d") * 24 if "--reply-context" in sys.argv and not self.peer: @@ -186,7 +190,11 @@ def control(): command = line.strip() if command in {"deny", "recover"}: self.verified = command == "recover" + self.owner_login_missing = False result = {"source_verified": self.verified} + elif command == "owner-login-missing": + self.owner_login_missing = True + result = {"owner_login_missing": True} elif command == "source": accepted, _ = runtime.enqueue_turn(session_id=sid, message="A new synthetic message from the original private source.", client_turn_id="synthetic-source-update", origin="lark", work_dir=self.root / "project", objective="") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx b/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx index 1a23420147..f24833ff38 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx @@ -1,15 +1,22 @@ import {useEffect, useRef, useState, type ReactNode} from "react"; import {Button} from "../../components/ui/button"; -import {fetchAppStewardTarget, type BoundConversationTarget} from "../../data/chat"; +import {ChatApiError, fetchAppStewardTarget, type BoundConversationTarget} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; +export function appStewardUnavailableMessage(ownerLoginUnavailable: boolean, zh: boolean) { + return ownerLoginUnavailable + ? (zh ? "已连接飞书管家,但本人登录不可用。请用原账号为同一飞书应用重新登录,再重试;原会话仍保留。" + : "The Lark steward is connected, but its owner login is unavailable. Sign in to the same App with the original account, then retry. Your original conversation is retained.") + : (zh ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry."); +} + /** One read-only resolver for both App entrypoints. Pending/failed discovery * never exposes the ordinary manager queue. Explicit bound routes bypass it. */ export function useAppStewardTarget(enabled: boolean) { const [read, setRead] = useState<{enabled: boolean; revision: number; ready: boolean; - target: BoundConversationTarget | null; unavailable: boolean}>( - {enabled, revision: 0, ready: false, target: null, unavailable: false}); + target: BoundConversationTarget | null; unavailable: boolean; ownerLoginUnavailable: boolean}>( + {enabled, revision: 0, ready: false, target: null, unavailable: false, ownerLoginUnavailable: false}); // Invalidate in the render that changes scope, before a composer can submit // using a previous ordinary result. React retries this component immediately. if (read.enabled !== enabled) setRead({...read, enabled, ready: false, target: null, unavailable: false}); @@ -24,17 +31,18 @@ export function useAppStewardTarget(enabled: boolean) { let active = true; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - const settle = (target: BoundConversationTarget | null, unavailable: boolean) => { + const settle = (target: BoundConversationTarget | null, unavailable: boolean, ownerLoginUnavailable = false) => { if (active) setRead(value => value.enabled === enabled && value.revision === revision - ? {...value, ready: true, target, unavailable} : value); + ? {...value, ready: true, target, unavailable, ownerLoginUnavailable} : value); }; void fetchAppStewardTarget(controller.signal).then(target => settle(target, false)) - .catch(() => settle(null, true)).finally(() => clearTimeout(timeout)); + .catch(error => settle(null, true, error instanceof ChatApiError + && error.payload.error_code === "lark_owner_identity_unavailable")).finally(() => clearTimeout(timeout)); return () => {active = false; controller.abort(); clearTimeout(timeout);}; }, [enabled, revision]); const ready = enabled && read.enabled === enabled && read.ready; return {target: ready ? read.target : null, loading: enabled && !ready, - unavailable: ready && read.unavailable, retry}; + unavailable: ready && read.unavailable, ownerLoginUnavailable: ready && read.ownerLoginUnavailable, retry}; } export function AppStewardEntry({children, enabled, intercept, onOpen}: { @@ -61,7 +69,7 @@ export function AppStewardEntry({children, enabled, intercept, onOpen}: { {blocked ?

{zh ? "LoopX 管家" : "LoopX steward"}

{!entry.unavailable ?

{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}

: <> -

{zh ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry."}

+

{appStewardUnavailableMessage(entry.ownerLoginUnavailable, zh)}

{zh ? "打开会话设置" : "Open conversation settings"} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 744f28456a..a7b9fc82cc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -115,7 +115,7 @@ export type WorkspaceConversationDirectory = { onSelect: (projectRef: string | null) => void; onOpenOriginal?: (target: BoundConversationTarget) => void; /** Local manager entry discovery blocks sending, never Goal navigation. */ - entryGate?: {unavailable: boolean; retry: () => void}; + entryGate?: {unavailable: boolean; ownerLoginUnavailable?: boolean; retry: () => void}; bound?: { target: BoundConversationTarget; session?: ChatSessionSummary; source?: BoundConversationSource; error?: string; retry: () => void }; managed?: {target: import("../../data/chat").ManagedConversationTarget; session?: import("../../data/chat").ChatSessionSummary; error?: string; retry: () => void}; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 72884d084e..1acb29c6f7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1,4 +1,5 @@ import {DocumentAttachment, isTextDocument} from "./document-attachment"; +import {appStewardUnavailableMessage} from "./app-steward-entry"; import {AlertCircle, Bot, CalendarClock, CircleHelp, FileText, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, Square, X} from "lucide-react"; import {ContextHelp} from "./context-help"; import {readSettingsRoute, writeSettingsRoute} from "./workspace-settings-route"; @@ -3044,7 +3045,7 @@ export function PersonalWorkspacePage({ {!rootUnavailable ?