Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ function larkGroupHistoryPermissionUrl(connection: LarkGoalConnection): string |
return connection.history_permission_guidance?.api_document_url ?? null;
}

// lark-cli is resolved once when the Chat service starts, so these codes stay
// true until the operator installs it and restarts LoopX.
const larkCliUnavailableCodes = new Set(["lark_cli_not_installed", "lark_cli_not_executable"]);

function larkCliUnavailable(cause: unknown): boolean {
return cause instanceof ChatApiError && larkCliUnavailableCodes.has(String(cause.payload.error_code ?? ""));
}

function larkErrorMessage(cause: unknown, fallback: string, t: WorkspaceTranslate): string {
if (cause instanceof ChatApiError) {
const code = String(cause.payload.error_code ?? "");
Expand Down Expand Up @@ -219,6 +227,7 @@ export function LarkSettingsPage({
const [setupBrand, setSetupBrand] = useState<"feishu" | "lark">("feishu");
const [setupSnapshot, setSetupSnapshot] = useState<LarkAppSetup | null>(null);
const [setupStarting, setSetupStarting] = useState(false);
const [cliUnavailable, setCliUnavailable] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const setupPopup = useRef<Window | null>(null);
const openedSetupUrl = useRef<string | null>(null);
Expand All @@ -234,8 +243,10 @@ export function LarkSettingsPage({
]);
setApps(nextApps);
setConnections(nextConnections);
setCliUnavailable(false);
setAppRef((current) => current || nextApps.find((app) => app.reply_ready)?.app_ref || nextApps.find((app) => app.ready)?.app_ref || nextApps[0]?.app_ref || "");
} catch (cause) {
setCliUnavailable(larkCliUnavailable(cause));
setError(larkErrorMessage(cause, t("lark.error.configuration"), t));
} finally {
setLoading(false);
Expand Down Expand Up @@ -405,6 +416,9 @@ export function LarkSettingsPage({
}

function openSetup() {
// Every setup entry (toolbar, connection dialog) meets the same fact:
// without lark-cli the setup can only fail until LoopX restarts.
if (cliUnavailable) return;
setSetupSnapshot(null);
setSetupError(null);
openedSetupUrl.current = null;
Expand Down Expand Up @@ -517,7 +531,7 @@ export function LarkSettingsPage({

{!loading && tab === "apps" ? (
<div className="personal-lark-apps">
<div className="personal-lark-app-toolbar"><span>{t("lark.reusableApps", { count: apps.length })}</span><button className="personal-primary-action" onClick={openSetup} type="button"><Plus size={16} />{t("lark.newApp")}</button></div>
<div className="personal-lark-app-toolbar"><span>{t("lark.reusableApps", { count: apps.length })}</span><button className="personal-primary-action" disabled={cliUnavailable} onClick={openSetup} type="button"><Plus size={16} />{t("lark.newApp")}</button></div>
<div className="personal-lark-app-grid">
{apps.map((app) => (
<article className="personal-lark-app-card" key={app.app_ref}>
Expand Down Expand Up @@ -583,7 +597,7 @@ export function LarkSettingsPage({
<label><span>{t("lark.bindGoal")}</span><div>{editingConnection.goal_title}</div></label>
<small>{t("lark.editPreservesIdentity")}</small>
</> : <>
<label><span>{t("lark.appProfile")}</span><select aria-label={t("lark.appProfile")} disabled={loading} onChange={(event) => { if (event.target.value === "__register__") openSetup(); else { setAppRef(event.target.value); setAgentAppRefs({}); } }} value={appRef}>{loading ? <option value="">{t("lark.appLoading")}</option> : <>{apps.map((app) => <option disabled={!app.ready} key={app.app_ref} value={app.app_ref}>{app.label}{app.reply_ready ? "" : app.ready ? ` · ${t("lark.needsMessagePermissions")}` : ` · ${t("lark.needsSetup")}`}</option>)}<option value="__register__">{t("lark.registerAnother")}</option></>}</select><small>{t("lark.defaultAgentAppDescription")}</small></label>
<label><span>{t("lark.appProfile")}</span><select aria-label={t("lark.appProfile")} disabled={loading} onChange={(event) => { if (event.target.value === "__register__") openSetup(); else { setAppRef(event.target.value); setAgentAppRefs({}); } }} value={appRef}>{loading ? <option value="">{t("lark.appLoading")}</option> : <>{apps.map((app) => <option disabled={!app.ready} key={app.app_ref} value={app.app_ref}>{app.label}{app.reply_ready ? "" : app.ready ? ` · ${t("lark.needsMessagePermissions")}` : ` · ${t("lark.needsSetup")}`}</option>)}<option disabled={cliUnavailable} value="__register__">{t("lark.registerAnother")}</option></>}</select><small>{t("lark.defaultAgentAppDescription")}</small></label>
{selectedApp?.ready && !selectedApp.reply_ready ? <div className="personal-lark-group-state is-error" role="alert">{t("lark.appPermissions")}</div> : null}
<label>
<span>{t("lark.groupChat")}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1031,15 +1031,22 @@ export function PersonalWorkspacePage({
return;
}
let cancelled = false;
void Promise.all([fetchGoalContexts(), fetchLarkConnections()])
.then(([contexts, connections]) => {
if (cancelled) return;
setGoalContexts(Object.fromEntries(contexts.map((row) => [row.goal_id, row.repository])));
setLarkConnections(connections);
// Goal repositories and Lark connections are independent optional sources:
// a missing lark-cli must not also hide every Goal's repository context.
void fetchGoalContexts()
.then((contexts) => {
if (!cancelled) setGoalContexts(Object.fromEntries(contexts.map((row) => [row.goal_id, row.repository])));
})
.catch(() => {
// Local context is optional; the Goal workspace stays usable without it.
});
void fetchLarkConnections()
.then((connections) => {
if (!cancelled) setLarkConnections(connections);
})
.catch(() => {
// Lark is optional; Settings reports why it is unavailable.
});
return () => { cancelled = true; };
}, [readOnly]);

Expand Down
3 changes: 2 additions & 1 deletion examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ import { goalActivityScenario } from "./personal-workspace-browser/goal-activity
import { stewardGroupTriggerScenario } from "./personal-workspace-browser/steward-group-trigger.mjs";

import { goalDraftScenario } from "./personal-workspace-browser/goal-draft.mjs";
import { larkCliMissingScenario } from "./personal-workspace-browser/lark-cli-missing.mjs";
import { executionServiceOfflineScenario } from "./personal-workspace-browser/execution-service-offline.mjs";

const scenarioCatalog = [goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, executionServiceOfflineScenario];
const scenarioCatalog = [goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, larkCliMissingScenario, executionServiceOfflineScenario];
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
75 changes: 75 additions & 0 deletions examples/personal-workspace-browser/lark-cli-missing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { openWorkspacePage } from "./scenario-context.mjs";

// The Chat service answers every Lark request with this 503 when lark-cli is
// not installed. Lark is optional: its absence must not hide unrelated Goal
// context, and Settings must not offer an App setup that can only fail.
const larkCliMissing = {
error: "Install lark-cli, then restart the LoopX Chat service.",
error_code: "lark_cli_not_installed",
ok: false,
};

const larkReads = /\/api\/chat\/lark\/(apps|connections)(\?.*)?$/u;
const answerCliMissing = (route) => route.fulfill({ contentType: "application/json", json: larkCliMissing, status: 503 });

export const larkCliMissingScenario = {
id: "lark-cli-missing",
async run({ browser, collectCoverage, url }) {
const setupRequests = [];
const context = await openWorkspacePage(browser, url, {
beforeGoto: async (_api, page) => {
await page.route(larkReads, answerCliMissing);
await page.route(/\/api\/chat\/lark\/app-setups/u, (route) => {
setupRequests.push(route.request().method());
return route.fulfill({ contentType: "application/json", json: larkCliMissing, status: 503 });
});
},
collectCoverage,
});
const { page } = context;
try {
await page.locator(".personal-goal-link", { hasText: "LoopX meta" }).click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click();
await page.getByRole("button", { name: "Goal 信息", exact: true }).click();
await page.locator(".personal-goal-repository").getByText("loopx-ai/loopx", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });

// The Goal drawer's own entry opens the connection dialog; its
// "register another App" option must not reach the setup either.
await page.getByRole("dialog").getByRole("button", { name: "连接 Lark App", exact: true }).click();
const registerOption = page.locator('select[aria-label="Lark App"] option[value="__register__"]');
await registerOption.waitFor({ state: "attached", timeout: 10_000 });
// The dialog opens before its Lark read fails; the option must settle disabled.
await page.waitForFunction(() => document.querySelector('select[aria-label="Lark App"] option[value="__register__"]')?.disabled === true, null, { timeout: 10_000 })
.catch(() => { throw new Error("The connection dialog still offered Lark App setup without lark-cli"); });
if (await page.locator("#new-lark-app-title").count()) throw new Error("Lark App setup opened without lark-cli");
await page.goto(url, { waitUntil: "networkidle" });
await page.getByTestId("personal-goal-home").waitFor({ state: "visible" });

await page.getByRole("button", { name: "设置", exact: true }).click();
await page.getByRole("button", { name: "Lark", exact: true }).click();
await page.getByText("未发现 lark-cli。请先安装 lark-cli,然后重新启动 LoopX。", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
await page.locator(".personal-lark-tabs").getByRole("button", { name: /^Lark Apps/u }).click();
if (!await page.getByRole("button", { name: "新建 Lark App" }).isDisabled()) {
throw new Error("New Lark App stayed available while lark-cli is missing");
}
if (setupRequests.length) throw new Error(`Lark App setup was requested without lark-cli: ${setupRequests.join(",")}`);

// With lark-cli available again the same entries stay usable.
await page.unroute(larkReads, answerCliMissing);
await page.reload({ waitUntil: "networkidle" });
await page.getByTestId("personal-goal-home").waitFor({ state: "visible" });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.getByRole("button", { name: "Lark", exact: true }).click();
await page.locator(".personal-lark-tabs").getByRole("button", { name: /^Lark Apps/u }).click();
if (await page.getByRole("button", { name: "新建 Lark App" }).isDisabled()) {
throw new Error("New Lark App stayed disabled with lark-cli available");
}
} finally {
await context.close();
}
return {
coverageEntries: context.coverageEntries,
note: "A missing lark-cli keeps Goal repository context and disables Lark App setup with its reason.",
};
},
};
Loading