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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/presentation/dashboard/smoke/app-steward-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), "../../..");
Expand All @@ -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");
Expand All @@ -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});
Expand All @@ -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);
Expand All @@ -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"}]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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="")
Expand Down
Original file line number Diff line number Diff line change
@@ -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});
Expand All @@ -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}: {
Expand All @@ -61,7 +69,7 @@ export function AppStewardEntry({children, enabled, intercept, onOpen}: {
{blocked ? <main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center gap-5 p-6 text-slate-900 dark:text-zinc-100">
<h1 className="text-xl font-semibold">{zh ? "LoopX 管家" : "LoopX steward"}</h1>
{!entry.unavailable ? <p role="status">{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}</p> : <>
<p role="alert">{zh ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry."}</p>
<p role="alert">{appStewardUnavailableMessage(entry.ownerLoginUnavailable, zh)}</p>
<div className="flex flex-wrap gap-3">
<Button onClick={entry.retry}>{zh ? "重试" : "Retry"}</Button>
<a className="inline-flex min-h-9 items-center underline underline-offset-4" href={settings.href}>{zh ? "打开会话设置" : "Open conversation settings"}</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -3044,7 +3045,7 @@ export function PersonalWorkspacePage({
{!rootUnavailable ? <div className="personal-composer-wrap" hidden={agentWorkActive && !managedWorkActive}>
{workspaceConversations?.entryGate ? <div className="personal-history-notice" role={workspaceConversations.entryGate.unavailable ? "alert" : "status"}>
<span>{workspaceConversations.entryGate.unavailable
? (locale === "zh-CN" ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry.")
? appStewardUnavailableMessage(workspaceConversations.entryGate.ownerLoginUnavailable === true, locale === "zh-CN")
: (locale === "zh-CN" ? "正在核验管家会话…" : "Verifying the steward conversation…")}</span>
{workspaceConversations.entryGate.unavailable ? <>
<button type="button" onClick={workspaceConversations.entryGate.retry}>{t("history.retry")}</button>
Expand Down
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3109,7 +3109,7 @@ function PersonalGoalHome({
observedConversationRunning={observedConversationRunning}
workspaceConversations={{ ...workspaceProjects, selectedRef: selectedWorkspaceRef, onSelect: onSelectWorkspace,
onOpenOriginal: onOpenOriginalConversation,
entryGate: appEntryBlocked ? {unavailable: appEntry.unavailable, retry: appEntry.retry} : undefined, managed: managedTarget ? {target: managedTarget, ...managedConversation} : undefined, bound: boundConversation ? { target: boundConversation, ...originalConversation } : undefined }}
entryGate: appEntryBlocked ? {unavailable: appEntry.unavailable, ownerLoginUnavailable: appEntry.ownerLoginUnavailable, retry: appEntry.retry} : undefined, managed: managedTarget ? {target: managedTarget, ...managedConversation} : undefined, bound: boundConversation ? { target: boundConversation, ...originalConversation } : undefined }}
agents={(fixedSession ? [selectedAgent] : agentOptions).map((agent) => ({
adapterKind: agent.adapterKind,
agentId: agent.agentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ it cannot silently fall back to a peer or ordinary conversation. Goal-scoped,
project, explicit original, managed-Agent and remote/read-only views retain their
own navigation. A newly selected preference does not retarget an already frozen
original conversation.
The default `POST /api/chat/sessions` manager entry resolves the same saved
choice on the server, including for API callers. It resumes that original
Session or rejects the request; a new-session/model override cannot silently
create a separate local steward. Existing explicitly addressed Sessions retain
their identity. A verified Lark bot with an unavailable owner login is reported
as an owner-login recovery, without clearing the selection or weakening source
verification. Restore the original owner's login for the same App and retry.
Overview quick prompts and its composer use the same explicit choice and the
existing exact Session send, active-Turn queue, stream recovery and readback.
Resolving the entry does not open Chat or block Goal navigation. Sending stays
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ App 因此可以一起呈现原正文、精确引用和已核验的话题节选
或被替换时保持不可用,等待恢复或明确取消选择,不悄悄回退到另一管家或普通会话。
Goal 范围、项目、明确原会话、受管 Agent 以及远端/只读视图保留各自导航。
后来修改默认选择,不挪走已固定的原会话。
默认 `POST /api/chat/sessions` 管家入口也在服务端解析同一已存选择,API 调用同样
遵守;只能恢复原 Session 或拒绝请求,新会话/模型覆盖不能悄悄创建独立本机管家。
明确指定的既有 Session 保持原身份。飞书机器人已核验但本人登录不可用时,明确
提示恢复本人登录,不清除选择、不放宽来源核验;用原账号为同一 App 恢复登录后重试。
概览中的快捷提问与输入框使用同一个明确选择,复用确切原 Session 的发送、
活动回合排队、流恢复和读回;选择核验不自动打开聊天,也不遮住 Goal 导航。
核验期间禁止发送,失败时在输入框旁显示重试与会话设置,不切回普通管家。
Expand Down
19 changes: 19 additions & 0 deletions loopx/chat/session_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ def open_chat_session(
raise ValueError("a registered managed Agent requires its local Goal conversation")

if context_kind == "manager":
# The App's persisted source choice owns the default entry on the
# server too. API callers must not fork a local manager when the
# selected private conversation is unavailable.
bindings = controller.project_contexts.conversation_bindings
if bindings is not None:
target = bindings.app_steward_target()
if target["binding_id"] is not None:
if mode != "resume_latest":
raise ValueError("Continue the selected original steward conversation; start a new conversation from its original source.")
session = controller.store.load_session(target["session_id"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 关闭与打开并发时,默认入口把已关闭会话报为恢复成功。 默认 manager POST 刚完成所选来源核验时,另一请求用现有 DELETE 入口关闭该空闲 Session,再让 POST 继续读取。 DELETE 已成功,候选仍返回 HTTP 200、resumed=true、status=closed、resumable=false。随后的发现 GET 返回 409,新的默认 POST 返回 400。 让默认入口从既有绑定解析器返回同一份经过校验的可用 Session,并在返回前拒绝已关闭或身份、选择已变化的结果。 扩展现有 HTTP/Core smoke,在发现和返回之间并发关闭原 Session,断言默认入口拒绝且保留历史,再核正常打开、恢复和显式清除。

if session is None:
raise ValueError("The selected original steward Session is unavailable.")
if any(requested and requested != session.get(field) for requested, field in (
(requested_endpoint, "executor_endpoint_id"),
(agent_model_id, "agent_model_id"),
(agent_reasoning_effort, "agent_reasoning_effort"),
)):
raise ValueError("Change the selected steward's model in Settings before continuing its original conversation.")
return session, True
return open_manager_session(
controller=controller,
goal_id=goal_id,
Expand Down
11 changes: 9 additions & 2 deletions loopx/extensions/lark/conversation_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ def identity_ref(*values: str) -> str:
return hashlib.sha256("\0".join(values).encode("utf-8")).hexdigest()[:24]


class LarkOwnerIdentityUnavailable(ValueError):
"""A verified App lacks the separately verified private-chat owner."""

def __init__(self) -> None:
super().__init__("The Lark App is verified, but its owner login is unavailable. Sign in to the same App with the original owner, then retry.")


def observe_lark_conversation_identity(*, profile: str, runner: CommandRunner,
cli_bin: str, audience: str = "owner") -> dict[str, Any]:
if audience not in {"owner", "group"}:
Expand All @@ -45,10 +52,10 @@ def observe_lark_conversation_identity(*, profile: str, runner: CommandRunner,
if audience == "group":
operator_ref = identity_ref(provider_ref, "group")
elif not isinstance(owner, Mapping):
raise ValueError("verify this App and its owner independently before binding private Chat")
raise LarkOwnerIdentityUnavailable()
else:
if not (owner.get("available") is True and owner.get("verified") is True):
raise ValueError("verify this App and its owner independently before binding private Chat")
raise LarkOwnerIdentityUnavailable()
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")
Expand Down
4 changes: 4 additions & 0 deletions loopx/extensions/lark/private_conversation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,12 @@ def _private_conversations(self) -> None:
for row in bindings.stop_recoveries(current)]})

def _app_steward(self) -> None:
from .conversation_identity import LarkOwnerIdentityUnavailable
try:
target = self.server.runtime_controller.project_contexts.conversation_bindings.app_steward_target()
except LarkOwnerIdentityUnavailable as exc:
self._send_error(str(exc), status=409, error_code="lark_owner_identity_unavailable")
return
except (ValueError, KeyError, OSError, RuntimeError):
self._send_error("The selected original steward is unavailable. Check its connection in Settings and retry.", status=409)
return
Expand Down
3 changes: 2 additions & 1 deletion tests/test_lark_private_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4608,7 +4608,8 @@ def test_group_identity_requires_verified_bot_and_private_identity_requires_veri
provider.owner_ready = False
group = observe_lark_conversation_identity(profile="notes-app", runner=provider, cli_bin="lark-cli", audience="group")
assert group["operator_ref"] != identity_ref(group["provider_ref"], "ou_notes_app")
with pytest.raises(ValueError, match="owner independently"):
from loopx.extensions.lark.conversation_identity import LarkOwnerIdentityUnavailable
with pytest.raises(LarkOwnerIdentityUnavailable):
observe_lark_conversation_identity(profile="notes-app", runner=provider, cli_bin="lark-cli")
provider.bot_ready = False
with pytest.raises(ValueError, match="App identity"):
Expand Down
Loading